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=