feat: Add "coder" CLI (#221)

* feat: Add "coder" CLI

* Add CLI test for login

* Add "bin/coder" target to Makefile

* Update promptui to fix race

* Fix error scope

* Don't run CLI tests on Windows

* Fix requested changes
This commit is contained in:
Kyle Carberry 2022-02-10 08:33:27 -06:00 committed by GitHub
parent 277318bdb4
commit 07fe5ced68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 921 additions and 7 deletions

View File

@ -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",

View File

@ -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.

38
cli/clitest/clitest.go Normal file
View File

@ -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
}

71
cli/config/file.go Normal file
View File

@ -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)
}

38
cli/config/file_test.go Normal file
View File

@ -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)
})
}

135
cli/login.go Normal file
View File

@ -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 <url>",
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
},
}
}

56
cli/login_test.go Normal file
View File

@ -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)
})
}

151
cli/projectcreate.go Normal file
View File

@ -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
}

16
cli/projectplan.go Normal file
View File

@ -0,0 +1,16 @@
package cli
import (
"github.com/spf13/cobra"
)
func projectPlan() *cobra.Command {
return &cobra.Command{
Use: "plan <directory>",
Args: cobra.MinimumNArgs(1),
Short: "Plan a project update from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}

30
cli/projects.go Normal file
View File

@ -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 <name>") + `
- Update the project. Your developers can update their workspaces
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
}
cmd.AddCommand(projectCreate())
cmd.AddCommand(projectPlan())
cmd.AddCommand(projectUpdate())
return cmd
}

14
cli/projectupdate.go Normal file
View File

@ -0,0 +1,14 @@
package cli
import "github.com/spf13/cobra"
func projectUpdate() *cobra.Command {
return &cobra.Command{
Use: "update <name>",
Args: cobra.MinimumNArgs(1),
Short: "Update a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}

170
cli/root.go Normal file
View File

@ -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 <directory>") + `
- Create a workspace for a specific project
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create <project>") + `
- Maintain consistency by updating a workspace
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update <workspace>"),
}
// 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 <org>"
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
}

1
cli/ssh.go Normal file
View File

@ -0,0 +1 @@
package cli

10
cli/users.go Normal file
View File

@ -0,0 +1,10 @@
package cli
import "github.com/spf13/cobra"
func users() *cobra.Command {
cmd := &cobra.Command{
Use: "users",
}
return cmd
}

11
cli/workspaces.go Normal file
View File

@ -0,0 +1,11 @@
package cli
import "github.com/spf13/cobra"
func workspaces() *cobra.Command {
cmd := &cobra.Command{
Use: "workspaces",
}
return cmd
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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(

View File

@ -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

View File

@ -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) {

View File

@ -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.

View File

@ -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) {

15
go.mod
View File

@ -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

19
go.sum
View File

@ -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=