diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b78316937691..1c6d6a8f8c189 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,16 +31,22 @@ "drpcconn", "drpcmux", "drpcserver", + "fatih", "goleak", "hashicorp", "httpmw", + "isatty", "Jobf", + "kirsle", + "manifoldco", + "mattn", "moby", "nhooyr", "nolint", "nosec", "oneof", "parameterscopeid", + "promptui", "protobuf", "provisionerd", "provisionersdk", diff --git a/Makefile b/Makefile index 9bd712e7bd17f..e14562b46e848 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,14 @@ +bin/coder: + mkdir -p bin + go build -o bin/coder cmd/coder/main.go +.PHONY: bin/coder + bin/coderd: mkdir -p bin go build -o bin/coderd cmd/coderd/main.go .PHONY: bin/coderd -build: site/out bin/coderd +build: site/out bin/coder bin/coderd .PHONY: build # Runs migrations to output a dump of the database. diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go new file mode 100644 index 0000000000000..7166843e93dac --- /dev/null +++ b/cli/clitest/clitest.go @@ -0,0 +1,38 @@ +package clitest + +import ( + "bufio" + "io" + "testing" + + "github.com/spf13/cobra" + + "github.com/coder/coder/cli" + "github.com/coder/coder/cli/config" +) + +func New(t *testing.T, args ...string) (*cobra.Command, config.Root) { + cmd := cli.Root() + dir := t.TempDir() + root := config.Root(dir) + cmd.SetArgs(append([]string{"--global-config", dir}, args...)) + return cmd, root +} + +func StdoutLogs(t *testing.T) io.Writer { + reader, writer := io.Pipe() + scanner := bufio.NewScanner(reader) + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + go func() { + for scanner.Scan() { + if scanner.Err() != nil { + return + } + t.Log(scanner.Text()) + } + }() + return writer +} diff --git a/cli/config/file.go b/cli/config/file.go new file mode 100644 index 0000000000000..45f70e580c0ac --- /dev/null +++ b/cli/config/file.go @@ -0,0 +1,71 @@ +package config + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +// Root represents the configuration directory. +type Root string + +func (r Root) Session() File { + return File(filepath.Join(string(r), "session")) +} + +func (r Root) URL() File { + return File(filepath.Join(string(r), "url")) +} + +func (r Root) Organization() File { + return File(filepath.Join(string(r), "organization")) +} + +// File provides convenience methods for interacting with *os.File. +type File string + +// Delete deletes the file. +func (f File) Delete() error { + return os.Remove(string(f)) +} + +// Write writes the string to the file. +func (f File) Write(s string) error { + return write(string(f), 0600, []byte(s)) +} + +// Read reads the file to a string. +func (f File) Read() (string, error) { + byt, err := read(string(f)) + return string(byt), err +} + +// open opens a file in the configuration directory, +// creating all intermediate directories. +func open(path string, flag int, mode os.FileMode) (*os.File, error) { + err := os.MkdirAll(filepath.Dir(path), 0750) + if err != nil { + return nil, err + } + + return os.OpenFile(path, flag, mode) +} + +func write(path string, mode os.FileMode, dat []byte) error { + fi, err := open(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode) + if err != nil { + return err + } + defer fi.Close() + _, err = fi.Write(dat) + return err +} + +func read(path string) ([]byte, error) { + fi, err := open(path, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer fi.Close() + return ioutil.ReadAll(fi) +} diff --git a/cli/config/file_test.go b/cli/config/file_test.go new file mode 100644 index 0000000000000..b3ca15322e217 --- /dev/null +++ b/cli/config/file_test.go @@ -0,0 +1,38 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/config" +) + +func TestFile(t *testing.T) { + t.Parallel() + + t.Run("Write", func(t *testing.T) { + t.Parallel() + err := config.Root(t.TempDir()).Session().Write("test") + require.NoError(t, err) + }) + + t.Run("Read", func(t *testing.T) { + t.Parallel() + root := config.Root(t.TempDir()) + err := root.Session().Write("test") + require.NoError(t, err) + data, err := root.Session().Read() + require.NoError(t, err) + require.Equal(t, "test", data) + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + root := config.Root(t.TempDir()) + err := root.Session().Write("test") + require.NoError(t, err) + err = root.Session().Delete() + require.NoError(t, err) + }) +} diff --git a/cli/login.go b/cli/login.go new file mode 100644 index 0000000000000..73758719d0128 --- /dev/null +++ b/cli/login.go @@ -0,0 +1,135 @@ +package cli + +import ( + "fmt" + "net/url" + "os/user" + "strings" + + "github.com/fatih/color" + "github.com/go-playground/validator/v10" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/codersdk" +) + +func login() *cobra.Command { + return &cobra.Command{ + Use: "login ", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rawURL := args[0] + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + scheme := "https" + if strings.HasPrefix(rawURL, "localhost") { + scheme = "http" + } + rawURL = fmt.Sprintf("%s://%s", scheme, rawURL) + } + serverURL, err := url.Parse(rawURL) + if err != nil { + return xerrors.Errorf("parse raw url %q: %w", rawURL, err) + } + // Default to HTTPs. Enables simple URLs like: master.cdr.dev + if serverURL.Scheme == "" { + serverURL.Scheme = "https" + } + + client := codersdk.New(serverURL) + hasInitialUser, err := client.HasInitialUser(cmd.Context()) + if err != nil { + return xerrors.Errorf("has initial user: %w", err) + } + if !hasInitialUser { + if !isTTY(cmd.InOrStdin()) { + return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">")) + + _, err := runPrompt(cmd, &promptui.Prompt{ + Label: "Would you like to create the first user?", + IsConfirm: true, + Default: "y", + }) + if err != nil { + return xerrors.Errorf("create user prompt: %w", err) + } + currentUser, err := user.Current() + if err != nil { + return xerrors.Errorf("get current user: %w", err) + } + username, err := runPrompt(cmd, &promptui.Prompt{ + Label: "What username would you like?", + Default: currentUser.Username, + }) + if err != nil { + return xerrors.Errorf("pick username prompt: %w", err) + } + + organization, err := runPrompt(cmd, &promptui.Prompt{ + Label: "What is the name of your organization?", + Default: "acme-corp", + }) + if err != nil { + return xerrors.Errorf("pick organization prompt: %w", err) + } + + email, err := runPrompt(cmd, &promptui.Prompt{ + Label: "What's your email?", + Validate: func(s string) error { + err := validator.New().Var(s, "email") + if err != nil { + return xerrors.New("That's not a valid email address!") + } + return err + }, + }) + if err != nil { + return xerrors.Errorf("specify email prompt: %w", err) + } + + password, err := runPrompt(cmd, &promptui.Prompt{ + Label: "Enter a password:", + Mask: '*', + }) + if err != nil { + return xerrors.Errorf("specify password prompt: %w", err) + } + + _, err = client.CreateInitialUser(cmd.Context(), coderd.CreateInitialUserRequest{ + Email: email, + Username: username, + Password: password, + Organization: organization, + }) + if err != nil { + return xerrors.Errorf("create initial user: %w", err) + } + resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{ + Email: email, + Password: password, + }) + if err != nil { + return xerrors.Errorf("login with password: %w", err) + } + config := createConfig(cmd) + err = config.Session().Write(resp.SessionToken) + if err != nil { + return xerrors.Errorf("write session token: %w", err) + } + err = config.URL().Write(serverURL.String()) + if err != nil { + return xerrors.Errorf("write server url: %w", err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username)) + return nil + } + + return nil + }, + } +} diff --git a/cli/login_test.go b/cli/login_test.go new file mode 100644 index 0000000000000..f2102177d6710 --- /dev/null +++ b/cli/login_test.go @@ -0,0 +1,56 @@ +//go:build !windows + +package cli_test + +import ( + "testing" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/stretchr/testify/require" + + "github.com/Netflix/go-expect" +) + +func TestLogin(t *testing.T) { + t.Parallel() + t.Run("InitialUserNoTTY", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + root, _ := clitest.New(t, "login", client.URL.String()) + err := root.Execute() + require.Error(t, err) + }) + + t.Run("InitialUserTTY", func(t *testing.T) { + t.Parallel() + console, err := expect.NewConsole(expect.WithStdout(clitest.StdoutLogs(t))) + require.NoError(t, err) + client := coderdtest.New(t) + root, _ := clitest.New(t, "login", client.URL.String()) + root.SetIn(console.Tty()) + root.SetOut(console.Tty()) + go func() { + err := root.Execute() + require.NoError(t, err) + }() + + matches := []string{ + "first user?", "y", + "username", "testuser", + "organization", "testorg", + "email", "user@coder.com", + "password", "password", + } + for i := 0; i < len(matches); i += 2 { + match := matches[i] + value := matches[i+1] + _, err = console.ExpectString(match) + require.NoError(t, err) + _, err = console.SendLine(value) + require.NoError(t, err) + } + _, err = console.ExpectString("Welcome to Coder") + require.NoError(t, err) + }) +} diff --git a/cli/projectcreate.go b/cli/projectcreate.go new file mode 100644 index 0000000000000..eb5219cb816cb --- /dev/null +++ b/cli/projectcreate.go @@ -0,0 +1,151 @@ +package cli + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/briandowns/spinner" + "github.com/fatih/color" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/database" +) + +func projectCreate() *cobra.Command { + return &cobra.Command{ + Use: "create", + Short: "Create a project from the current directory", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + organization, err := currentOrganization(cmd, client) + if err != nil { + return err + } + + workingDir, err := os.Getwd() + if err != nil { + return err + } + + _, err = runPrompt(cmd, &promptui.Prompt{ + Default: "y", + IsConfirm: true, + Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", workingDir)), + }) + if err != nil { + return err + } + + name, err := runPrompt(cmd, &promptui.Prompt{ + Default: filepath.Base(workingDir), + Label: "What's your project's name?", + Validate: func(s string) error { + _, err = client.Project(cmd.Context(), organization.Name, s) + if err == nil { + return xerrors.New("A project already exists with that name!") + } + return nil + }, + }) + if err != nil { + return err + } + + spin := spinner.New(spinner.CharSets[0], 50*time.Millisecond) + spin.Suffix = " Uploading current directory..." + spin.Start() + defer spin.Stop() + + bytes, err := tarDirectory(workingDir) + if err != nil { + return err + } + + resp, err := client.UploadFile(cmd.Context(), codersdk.ContentTypeTar, bytes) + if err != nil { + return err + } + + job, err := client.CreateProjectVersionImportProvisionerJob(cmd.Context(), organization.Name, coderd.CreateProjectImportJobRequest{ + StorageMethod: database.ProvisionerStorageMethodFile, + StorageSource: resp.Hash, + Provisioner: database.ProvisionerTypeTerraform, + // SkipResources on first import to detect variables defined by the project. + SkipResources: true, + }) + if err != nil { + return err + } + spin.Stop() + + logs, err := client.FollowProvisionerJobLogsAfter(context.Background(), organization.Name, job.ID, time.Time{}) + if err != nil { + return err + } + for { + log, ok := <-logs + if !ok { + break + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[parse]"), log.Output) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Create project %q!\n", name) + return nil + }, + } +} + +func tarDirectory(directory string) ([]byte, error) { + var buffer bytes.Buffer + tarWriter := tar.NewWriter(&buffer) + err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error { + if err != nil { + return err + } + header, err := tar.FileInfoHeader(fileInfo, file) + if err != nil { + return err + } + rel, err := filepath.Rel(directory, file) + if err != nil { + return err + } + header.Name = rel + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if fileInfo.IsDir() { + return nil + } + data, err := os.Open(file) + if err != nil { + return err + } + if _, err := io.Copy(tarWriter, data); err != nil { + return err + } + return data.Close() + }) + if err != nil { + return nil, err + } + err = tarWriter.Flush() + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} diff --git a/cli/projectplan.go b/cli/projectplan.go new file mode 100644 index 0000000000000..963c02d975cb1 --- /dev/null +++ b/cli/projectplan.go @@ -0,0 +1,16 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func projectPlan() *cobra.Command { + return &cobra.Command{ + Use: "plan ", + Args: cobra.MinimumNArgs(1), + Short: "Plan a project update from the current directory", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } +} diff --git a/cli/projects.go b/cli/projects.go new file mode 100644 index 0000000000000..07d68c0155967 --- /dev/null +++ b/cli/projects.go @@ -0,0 +1,30 @@ +package cli + +import ( + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func projects() *cobra.Command { + cmd := &cobra.Command{ + Use: "projects", + Long: "Testing something", + Example: ` + - Create a project for developers to create workspaces + + ` + color.New(color.FgHiMagenta).Sprint("$ coder projects create") + ` + + - Make changes to your project, and plan the changes + + ` + color.New(color.FgHiMagenta).Sprint("$ coder projects plan ") + ` + + - Update the project. Your developers can update their workspaces + + ` + color.New(color.FgHiMagenta).Sprint("$ coder projects update "), + } + cmd.AddCommand(projectCreate()) + cmd.AddCommand(projectPlan()) + cmd.AddCommand(projectUpdate()) + + return cmd +} diff --git a/cli/projectupdate.go b/cli/projectupdate.go new file mode 100644 index 0000000000000..19f7b2b06404f --- /dev/null +++ b/cli/projectupdate.go @@ -0,0 +1,14 @@ +package cli + +import "github.com/spf13/cobra" + +func projectUpdate() *cobra.Command { + return &cobra.Command{ + Use: "update ", + Args: cobra.MinimumNArgs(1), + Short: "Update a project from the current directory", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } +} diff --git a/cli/root.go b/cli/root.go new file mode 100644 index 0000000000000..85db65385291a --- /dev/null +++ b/cli/root.go @@ -0,0 +1,170 @@ +package cli + +import ( + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/fatih/color" + "github.com/kirsle/configdir" + "github.com/manifoldco/promptui" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/config" + "github.com/coder/coder/coderd" + "github.com/coder/coder/codersdk" +) + +const ( + varGlobalConfig = "global-config" +) + +func Root() *cobra.Command { + cmd := &cobra.Command{ + Use: "coder", + Long: ` ▄█▀ ▀█▄ + ▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█ + ▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀ +█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █ + ██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀ + ` + color.New(color.Underline).Sprint("Self-hosted developer workspaces on your infra") + ` + +`, + Example: ` + - Create a project for developers to create workspaces + + ` + color.New(color.FgHiMagenta).Sprint("$ coder projects create ") + ` + + - Create a workspace for a specific project + + ` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create ") + ` + + - Maintain consistency by updating a workspace + + ` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update "), + } + // Customizes the color of headings to make subcommands + // more visually appealing. + header := color.New(color.FgHiBlack) + cmd.SetUsageTemplate(strings.NewReplacer( + `Usage:`, header.Sprint("Usage:"), + `Examples:`, header.Sprint("Examples:"), + `Available Commands:`, header.Sprint("Commands:"), + `Global Flags:`, header.Sprint("Global Flags:"), + `Flags:`, header.Sprint("Flags:"), + `Additional help topics:`, header.Sprint("Additional help:"), + ).Replace(cmd.UsageTemplate())) + + cmd.AddCommand(login()) + cmd.AddCommand(projects()) + cmd.AddCommand(workspaces()) + cmd.AddCommand(users()) + + cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory") + + return cmd +} + +func createClient(cmd *cobra.Command) (*codersdk.Client, error) { + root := createConfig(cmd) + rawURL, err := root.URL().Read() + if err != nil { + return nil, err + } + serverURL, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + token, err := root.Session().Read() + if err != nil { + return nil, err + } + client := codersdk.New(serverURL) + return client, client.SetSessionToken(token) +} + +func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) { + orgs, err := client.UserOrganizations(cmd.Context(), "me") + if err != nil { + return coderd.Organization{}, nil + } + // For now, we won't use the config to set this. + // Eventually, we will support changing using "coder switch " + return orgs[0], nil +} + +func createConfig(cmd *cobra.Command) config.Root { + globalRoot, err := cmd.Flags().GetString(varGlobalConfig) + if err != nil { + panic(err) + } + return config.Root(globalRoot) +} + +// isTTY returns whether the passed reader is a TTY or not. +// This accepts a reader to work with Cobra's "InOrStdin" +// function for simple testing. +func isTTY(reader io.Reader) bool { + file, ok := reader.(*os.File) + if !ok { + return false + } + return isatty.IsTerminal(file.Fd()) +} + +func runPrompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) { + var ok bool + prompt.Stdin, ok = cmd.InOrStdin().(io.ReadCloser) + if !ok { + return "", xerrors.New("stdin must be a readcloser") + } + prompt.Stdout, ok = cmd.OutOrStdout().(io.WriteCloser) + if !ok { + return "", xerrors.New("stdout must be a readcloser") + } + + // The prompt library displays defaults in a jarring way for the user + // by attempting to autocomplete it. This sets no default enabling us + // to customize the display. + defaultValue := prompt.Default + if !prompt.IsConfirm { + prompt.Default = "" + } + + // Rewrite the confirm template to remove bold, and fit to the Coder style. + confirmEnd := fmt.Sprintf("[y/%s] ", color.New(color.Bold).Sprint("N")) + if prompt.Default == "y" { + confirmEnd = fmt.Sprintf("[%s/n] ", color.New(color.Bold).Sprint("Y")) + } + confirm := color.HiBlackString("?") + ` {{ . }} ` + confirmEnd + + // Customize to remove bold. + valid := color.HiBlackString("?") + " {{ . }} " + if defaultValue != "" { + valid += fmt.Sprintf("(%s) ", defaultValue) + } + + success := valid + invalid := valid + if prompt.IsConfirm { + success = confirm + invalid = confirm + } + + prompt.Templates = &promptui.PromptTemplates{ + Confirm: confirm, + Success: success, + Invalid: invalid, + Valid: valid, + } + value, err := prompt.Run() + if value == "" && !prompt.IsConfirm { + value = defaultValue + } + + return value, err +} diff --git a/cli/ssh.go b/cli/ssh.go new file mode 100644 index 0000000000000..7f1e458cd3abe --- /dev/null +++ b/cli/ssh.go @@ -0,0 +1 @@ +package cli diff --git a/cli/users.go b/cli/users.go new file mode 100644 index 0000000000000..7dd3f309d44b3 --- /dev/null +++ b/cli/users.go @@ -0,0 +1,10 @@ +package cli + +import "github.com/spf13/cobra" + +func users() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + } + return cmd +} diff --git a/cli/workspaces.go b/cli/workspaces.go new file mode 100644 index 0000000000000..4140d8c9ed7a2 --- /dev/null +++ b/cli/workspaces.go @@ -0,0 +1,11 @@ +package cli + +import "github.com/spf13/cobra" + +func workspaces() *cobra.Command { + cmd := &cobra.Command{ + Use: "workspaces", + } + + return cmd +} diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 741337dc0372d..3daffadbb921d 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -1,7 +1,14 @@ package main -import "fmt" +import ( + "os" + + "github.com/coder/coder/cli" +) func main() { - _, _ = fmt.Println("Hello World!") + err := cli.Root().Execute() + if err != nil { + os.Exit(1) + } } diff --git a/coderd/cmd/root.go b/coderd/cmd/root.go index e63f4a50a901c..9087ac435b715 100644 --- a/coderd/cmd/root.go +++ b/coderd/cmd/root.go @@ -1,9 +1,14 @@ package cmd import ( + "context" + "io" + "io/ioutil" "net" "net/http" + "net/url" "os" + "time" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -11,8 +16,13 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/coderd" + "github.com/coder/coder/codersdk" "github.com/coder/coder/database" "github.com/coder/coder/database/databasefake" + "github.com/coder/coder/provisioner/terraform" + "github.com/coder/coder/provisionerd" + "github.com/coder/coder/provisionersdk" + "github.com/coder/coder/provisionersdk/proto" ) func Root() *cobra.Command { @@ -22,8 +32,9 @@ func Root() *cobra.Command { root := &cobra.Command{ Use: "coderd", RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.Make(sloghuman.Sink(os.Stderr)) handler := coderd.New(&coderd.Options{ - Logger: slog.Make(sloghuman.Sink(os.Stderr)), + Logger: logger, Database: databasefake.New(), Pubsub: database.NewPubsubInMemory(), }) @@ -34,6 +45,16 @@ func Root() *cobra.Command { } defer listener.Close() + client := codersdk.New(&url.URL{ + Scheme: "http", + Host: address, + }) + closer, err := newProvisionerDaemon(cmd.Context(), client, logger) + if err != nil { + return xerrors.Errorf("create provisioner daemon: %w", err) + } + defer closer.Close() + errCh := make(chan error) go func() { defer close(errCh) @@ -56,3 +77,31 @@ func Root() *cobra.Command { return root } + +func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) { + terraformClient, terraformServer := provisionersdk.TransportPipe() + go func() { + err := terraform.Serve(ctx, &terraform.ServeOptions{ + ServeOptions: &provisionersdk.ServeOptions{ + Listener: terraformServer, + }, + Logger: logger, + }) + if err != nil { + panic(err) + } + }() + tempDir, err := ioutil.TempDir("", "provisionerd") + if err != nil { + return nil, err + } + return provisionerd.New(client.ProvisionerDaemonClient, &provisionerd.Options{ + Logger: logger, + PollInterval: 50 * time.Millisecond, + UpdateInterval: 50 * time.Millisecond, + Provisioners: provisionerd.Provisioners{ + string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)), + }, + WorkDirectory: tempDir, + }), nil +} diff --git a/coderd/coderd.go b/coderd/coderd.go index bb800ee954805..fcc6530c6811c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,6 +37,7 @@ func New(options *Options) http.Handler { r.Post("/login", api.postLogin) r.Post("/logout", api.postLogout) // Used for setup. + r.Get("/user", api.user) r.Post("/user", api.postUser) r.Route("/users", func(r chi.Router) { r.Use( diff --git a/coderd/users.go b/coderd/users.go index 0644a78d01aff..ab6328fbe1984 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -55,6 +55,26 @@ type LoginWithPasswordResponse struct { SessionToken string `json:"session_token" validate:"required"` } +// Returns whether the initial user has been created or not. +func (api *api) user(rw http.ResponseWriter, r *http.Request) { + userCount, err := api.Database.GetUserCount(r.Context()) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get user count: %s", err.Error()), + }) + return + } + if userCount == 0 { + httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ + Message: "The initial user has not been created!", + }) + return + } + httpapi.Write(rw, http.StatusOK, httpapi.Response{ + Message: "The initial user has already been created!", + }) +} + // Creates the initial user for a Coder deployment. func (api *api) postUser(rw http.ResponseWriter, r *http.Request) { var createUser CreateInitialUserRequest diff --git a/coderd/users_test.go b/coderd/users_test.go index b3f36b3dd0914..8ef1464856f75 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -13,6 +13,26 @@ import ( "github.com/coder/coder/httpmw" ) +func TestUser(t *testing.T) { + t.Parallel() + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + has, err := client.HasInitialUser(context.Background()) + require.NoError(t, err) + require.False(t, has) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + _ = coderdtest.CreateInitialUser(t, client) + has, err := client.HasInitialUser(context.Background()) + require.NoError(t, err) + require.True(t, has) + }) +} + func TestPostUser(t *testing.T) { t.Parallel() t.Run("BadRequest", func(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index c3a8c13b847ee..b17b5a9931e6e 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -9,6 +9,23 @@ import ( "github.com/coder/coder/coderd" ) +// HasInitialUser returns whether the initial user has already been +// created or not. +func (c *Client) HasInitialUser(ctx context.Context) (bool, error) { + res, err := c.request(ctx, http.MethodGet, "/api/v2/user", nil) + if err != nil { + return false, err + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return false, nil + } + if res.StatusCode != http.StatusOK { + return false, readBodyAsError(res) + } + return true, nil +} + // CreateInitialUser attempts to create the first user on a Coder deployment. // This initial user has superadmin privileges. If >0 users exist, this request // will fail. diff --git a/codersdk/users_test.go b/codersdk/users_test.go index 3425c9204f3ca..8e2fcd6881483 100644 --- a/codersdk/users_test.go +++ b/codersdk/users_test.go @@ -10,6 +10,26 @@ import ( "github.com/coder/coder/coderd/coderdtest" ) +func TestHasInitialUser(t *testing.T) { + t.Parallel() + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + has, err := client.HasInitialUser(context.Background()) + require.NoError(t, err) + require.False(t, has) + }) + + t.Run("Found", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + _ = coderdtest.CreateInitialUser(t, client) + has, err := client.HasInitialUser(context.Background()) + require.NoError(t, err) + require.True(t, has) + }) +} + func TestCreateInitialUser(t *testing.T) { t.Parallel() t.Run("Error", func(t *testing.T) { diff --git a/go.mod b/go.mod index 14386bcf6927b..ffd3165824c4a 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module github.com/coder/coder go 1.17 +// Required until https://github.com/manifoldco/promptui/pull/169 is merged. +replace github.com/manifoldco/promptui => github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2 + // Required until https://github.com/hashicorp/terraform-exec/pull/275 and https://github.com/hashicorp/terraform-exec/pull/276 are merged. replace github.com/hashicorp/terraform-exec => github.com/kylecarbs/terraform-exec v0.15.1-0.20220202050609-a1ce7181b180 @@ -10,7 +13,10 @@ replace github.com/hashicorp/terraform-config-inspect => github.com/kylecarbs/te require ( cdr.dev/slog v1.4.1 + github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 + github.com/briandowns/spinner v1.18.1 github.com/coder/retry v1.3.0 + github.com/fatih/color v1.13.0 github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/render v1.0.1 github.com/go-playground/validator/v10 v10.10.0 @@ -21,7 +27,10 @@ require ( github.com/hashicorp/terraform-exec v0.15.0 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 github.com/justinas/nosurf v1.1.1 + github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/lib/pq v1.10.4 + github.com/manifoldco/promptui v0.9.0 + github.com/mattn/go-isatty v0.0.14 github.com/moby/moby v20.10.12+incompatible github.com/ory/dockertest/v3 v3.8.1 github.com/pion/datachannel v1.5.2 @@ -52,7 +61,9 @@ require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/containerd/continuity v0.2.2 // indirect + github.com/creack/pty v1.1.17 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dhui/dktest v0.3.9 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect @@ -60,7 +71,6 @@ require ( github.com/docker/docker v20.10.12+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect - github.com/fatih/color v1.13.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -75,10 +85,11 @@ require ( github.com/hashicorp/terraform-json v0.13.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/leodido/go-urn v1.2.1 // indirect + github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect diff --git a/go.sum b/go.sum index 8bf39c8e1bb37..d14ed9f0057d8 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/Microsoft/hcsshim v0.8.23/go.mod h1:4zegtUJth7lAvFyc6cH2gGQ5B3OFQim01 github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5hlzMzRKMLyo42nCZ9oml8AdTlq/0cvIaBv6tK1RehU= github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -189,6 +191,8 @@ github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= +github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= @@ -206,8 +210,11 @@ github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg= github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= @@ -345,8 +352,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= @@ -787,6 +795,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= @@ -800,6 +810,8 @@ github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0Lh github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= +github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -829,6 +841,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= +github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2 h1:MUREBTh4kybLY1KyuBfSx+QPfTB8XiUHs6ZxUhOPTnU= +github.com/kylecarbs/promptui v0.8.1-0.20201231190244-d8f2159af2b2/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/kylecarbs/terraform-config-inspect v0.0.0-20211215004401-bbc517866b88 h1:tvG/qs5c4worwGyGnbbb4i/dYYLjpFwDMqcIT3awAf8= github.com/kylecarbs/terraform-config-inspect v0.0.0-20211215004401-bbc517866b88/go.mod h1:Z0Nnk4+3Cy89smEbrq+sl1bxc9198gIP4I7wcQF6Kqs= github.com/kylecarbs/terraform-exec v0.15.1-0.20220202050609-a1ce7181b180 h1:yafC0pmxjs18fnO5RdKFLSItJIjYwGfSHTfcUvlZb3E= @@ -848,6 +862,8 @@ github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -1469,6 +1485,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy