diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 45ac921d..9a3261ea 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,6 +40,9 @@ jobs: uses: ./ci/image env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CODER_URL: ${{ secrets.CODER_URL }} + CODER_EMAIL: ${{ secrets.CODER_EMAIL }} + CODER_PASSWORD: ${{ secrets.CODER_PASSWORD }} with: args: make -j test/coverage gendocs: diff --git a/ci/integration/envs_test.go b/ci/integration/envs_test.go index e915b017..1acfcbf5 100644 --- a/ci/integration/envs_test.go +++ b/ci/integration/envs_test.go @@ -144,6 +144,20 @@ func TestEnvsCLI(t *testing.T) { tcli.StdoutMatches(regexp.QuoteMeta(name)), ) + // filter by provider that does not exist should fail + doesntExist := randString(10) + c.Run(ctx, fmt.Sprintf("coder envs ls --provider %s", doesntExist)).Assert(t, + tcli.Error(), + tcli.StderrMatches(regexp.QuoteMeta(fmt.Sprintf("fatal: no environments found for workspace provider %q", doesntExist))), + ) + + // filter by provider that does exist should succeed + var envs []coder.Environment + c.Run(ctx, "coder envs ls --provider built-in").Assert(t, + tcli.Success(), + tcli.StdoutJSONUnmarshal(&envs), + ) + var env coder.Environment c.Run(ctx, fmt.Sprintf(`coder envs ls -o json | jq '.[] | select(.name == "%s")'`, name)).Assert(t, tcli.Success(), diff --git a/coder-sdk/env.go b/coder-sdk/env.go index 050db088..a911d813 100644 --- a/coder-sdk/env.go +++ b/coder-sdk/env.go @@ -338,3 +338,11 @@ func (c *DefaultClient) EnvironmentByID(ctx context.Context, id string) (*Enviro } return &env, nil } + +func (c *DefaultClient) EnvironmentsByWorkspaceProvider(ctx context.Context, wpID string) ([]Environment, error) { + var envs []Environment + if err := c.requestBody(ctx, http.MethodGet, "/api/private/resource-pools/"+wpID+"/environments/", nil, &envs); err != nil { + return nil, err + } + return envs, nil +} diff --git a/coder-sdk/interface.go b/coder-sdk/interface.go index 82971340..235666a7 100644 --- a/coder-sdk/interface.go +++ b/coder-sdk/interface.go @@ -130,6 +130,9 @@ type Client interface { // EnvironmentByID get the details of an environment by its id. EnvironmentByID(ctx context.Context, id string) (*Environment, error) + // EnvironmentsByWorkspaceProvider returns environments that belong to a particular workspace provider. + EnvironmentsByWorkspaceProvider(ctx context.Context, wpID string) ([]Environment, error) + // ImportImage creates a new image and optionally a new registry. ImportImage(ctx context.Context, req ImportImageReq) (*Image, error) diff --git a/coder-sdk/workspace_providers.go b/coder-sdk/workspace_providers.go index 33dceddd..b252a1b9 100644 --- a/coder-sdk/workspace_providers.go +++ b/coder-sdk/workspace_providers.go @@ -15,7 +15,7 @@ type KubernetesProvider struct { ID string `json:"id" table:"-"` Name string `json:"name" table:"Name"` Status WorkspaceProviderStatus `json:"status" table:"Status"` - Local bool `json:"local" table:"-"` + BuiltIn bool `json:"built_in" table:"-"` EnvproxyAccessURL string `json:"envproxy_access_url" validate:"required" table:"Access URL"` DevurlHost string `json:"devurl_host" table:"Devurl Host"` OrgWhitelist []string `json:"org_whitelist" table:"-"` @@ -41,6 +41,14 @@ const ( WorkspaceProviderReady WorkspaceProviderStatus = "ready" ) +// WorkspaceProviderType represents the type of workspace provider. +type WorkspaceProviderType string + +// Workspace Provider types. +const ( + WorkspaceProviderKubernetes WorkspaceProviderType = "kubernetes" +) + // WorkspaceProviderByID fetches a workspace provider entity by its unique ID. func (c *DefaultClient) WorkspaceProviderByID(ctx context.Context, id string) (*KubernetesProvider, error) { var wp KubernetesProvider @@ -63,7 +71,10 @@ func (c *DefaultClient) WorkspaceProviders(ctx context.Context) (*WorkspaceProvi // CreateWorkspaceProviderReq defines the request parameters for creating a new workspace provider entity. type CreateWorkspaceProviderReq struct { - Name string `json:"name"` + Name string `json:"name"` + Type WorkspaceProviderType `json:"type"` + Hostname string `json:"hostname"` + ClusterAddress string `json:"cluster_address"` } // CreateWorkspaceProviderRes defines the response from creating a new workspace provider entity. diff --git a/go.mod b/go.mod index 8a141975..a2ae1c46 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,12 @@ require ( cdr.dev/slog v1.3.0 cdr.dev/wsep v0.0.0-20200728013649-82316a09813f github.com/briandowns/spinner v1.11.1 - github.com/fatih/color v1.9.0 + github.com/fatih/color v1.10.0 github.com/google/go-cmp v0.4.0 github.com/gorilla/websocket v1.4.2 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/klauspost/compress v1.10.8 // indirect - github.com/manifoldco/promptui v0.7.0 - github.com/mattn/go-colorable v0.1.8 // indirect + github.com/manifoldco/promptui v0.8.0 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 github.com/spf13/cobra v1.1.1 diff --git a/go.sum b/go.sum index c6d25323..c4cc5343 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -197,8 +197,8 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx 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/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= -github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/internal/cmd/ceapi.go b/internal/cmd/ceapi.go index 0b2e719c..e032a161 100644 --- a/internal/cmd/ceapi.go +++ b/internal/cmd/ceapi.go @@ -202,3 +202,30 @@ func getUserOrgs(ctx context.Context, client coder.Client, email string) ([]code } return lookupUserOrgs(u, orgs), nil } + +func getProviderByName(ctx context.Context, client coder.Client, wpName string) (*coder.KubernetesProvider, error) { + providers, err := client.WorkspaceProviders(ctx) + if err != nil { + return nil, err + } + + for _, provider := range providers.Kubernetes { + if provider.Name == wpName { + return &provider, nil + } + } + return nil, xerrors.Errorf("workspace provider %q not found", wpName) +} + +func getEnvsByProvider(ctx context.Context, client coder.Client, wpName string) ([]coder.Environment, error) { + wp, err := getProviderByName(ctx, client, wpName) + if err != nil { + return nil, err + } + + envs, err := client.EnvironmentsByWorkspaceProvider(ctx, wp.ID) + if err != nil { + return nil, err + } + return envs, nil +} diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go new file mode 100644 index 00000000..d1c639dc --- /dev/null +++ b/internal/cmd/cli_test.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "strings" + "testing" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "cdr.dev/slog/sloggers/slogtest/assert" + "golang.org/x/xerrors" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/internal/config" + "cdr.dev/coder-cli/pkg/clog" +) + +func init() { + tmpDir, err := ioutil.TempDir("", "coder-cli-config-dir") + if err != nil { + panic(err) + } + config.SetRoot(tmpDir) + + // TODO: might need to make this a command scoped option to make assertions against its output + clog.SetOutput(ioutil.Discard) + + email := os.Getenv("CODER_EMAIL") + password := os.Getenv("CODER_PASSWORD") + rawURL := os.Getenv("CODER_URL") + if email == "" || password == "" || rawURL == "" { + panic("CODER_EMAIL, CODER_PASSWORD, and CODER_URL are required environment variables") + } + u, err := url.Parse(rawURL) + if err != nil { + panic("invalid CODER_URL: " + err.Error()) + } + client, err := coder.NewClient(coder.ClientOptions{ + BaseURL: u, + Email: email, + Password: password, + }) + if err != nil { + panic("new client: " + err.Error()) + } + if err := config.URL.Write(rawURL); err != nil { + panic("write config url: " + err.Error()) + } + if err := config.Session.Write(client.Token()); err != nil { + panic("write config token: " + err.Error()) + } +} + +type result struct { + outBuffer *bytes.Buffer + errBuffer *bytes.Buffer + exitErr error +} + +func (r result) success(t *testing.T) { + t.Helper() + assert.Success(t, "execute command", r.exitErr) +} + +//nolint +func (r result) stdoutContains(t *testing.T, substring string) { + t.Helper() + if !strings.Contains(r.outBuffer.String(), substring) { + slogtest.Fatal(t, "stdout contains substring", slog.F("substring", substring), slog.F("stdout", r.outBuffer.String())) + } +} + +//nolint +func (r result) stdoutUnmarshals(t *testing.T, target interface{}) { + t.Helper() + err := json.Unmarshal(r.outBuffer.Bytes(), target) + assert.Success(t, "unmarshal json", err) +} + +//nolint +func (r result) stdoutEmpty(t *testing.T) { + t.Helper() + assert.Equal(t, "stdout empty", "", r.outBuffer.String()) +} + +//nolint +func (r result) stderrEmpty(t *testing.T) { + t.Helper() + assert.Equal(t, "stderr empty", "", r.errBuffer.String()) +} + +//nolint +func (r result) stderrContains(t *testing.T, substring string) { + t.Helper() + if !strings.Contains(r.errBuffer.String(), substring) { + slogtest.Fatal(t, "stderr contains substring", slog.F("substring", substring), slog.F("stderr", r.errBuffer.String())) + } +} + +//nolint +func (r result) clogError(t *testing.T) clog.CLIError { + t.Helper() + var cliErr clog.CLIError + if !xerrors.As(r.exitErr, &cliErr) { + slogtest.Fatal(t, "expected clog error, none found", slog.Error(r.exitErr), slog.F("type", fmt.Sprintf("%T", r.exitErr))) + } + slogtest.Debug(t, "clog error", slog.F("message", cliErr.String())) + return cliErr +} + +func execute(t *testing.T, in io.Reader, args ...string) result { + cmd := Make() + + var outStream bytes.Buffer + var errStream bytes.Buffer + + cmd.SetArgs(args) + + cmd.SetIn(in) + cmd.SetOut(&outStream) + cmd.SetErr(&errStream) + + err := cmd.Execute() + + slogtest.Debug(t, "execute command", + slog.F("out_buffer", outStream.String()), + slog.F("err_buffer", errStream.String()), + slog.F("args", args), + slog.F("execute_error", err), + ) + return result{ + outBuffer: &outStream, + errBuffer: &errStream, + exitErr: err, + } +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ddc39c9c..8a7a7d21 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -2,8 +2,6 @@ package cmd import ( - "os" - "github.com/spf13/cobra" "github.com/spf13/cobra/doc" @@ -106,13 +104,13 @@ $ coder completion fish > ~/.config/fish/completions/coder.fish Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - _ = cmd.Root().GenBashCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenBashCompletion(cmd.OutOrStdout()) // Best effort. case "zsh": - _ = cmd.Root().GenZshCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenZshCompletion(cmd.OutOrStdout()) // Best effort. case "fish": - _ = cmd.Root().GenFishCompletion(os.Stdout, true) // Best effort. + _ = cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) // Best effort. case "powershell": - _ = cmd.Root().GenPowerShellCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) // Best effort. } }, } diff --git a/internal/cmd/envs.go b/internal/cmd/envs.go index 8560748c..582e9c76 100644 --- a/internal/cmd/envs.go +++ b/internal/cmd/envs.go @@ -8,7 +8,6 @@ import ( "io" "io/ioutil" "net/url" - "os" "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/coderutil" @@ -52,6 +51,7 @@ func lsEnvsCommand() *cobra.Command { var ( outputFmt string user string + provider string ) cmd := &cobra.Command{ @@ -68,6 +68,12 @@ func lsEnvsCommand() *cobra.Command { if err != nil { return err } + if provider != "" { + envs, err = getEnvsByProvider(ctx, client, provider) + if err != nil { + return err + } + } if len(envs) < 1 { clog.LogInfo("no environments found") envs = []coder.Environment{} // ensures that json output still marshals @@ -75,14 +81,14 @@ func lsEnvsCommand() *cobra.Command { switch outputFmt { case humanOutput: - err := tablewriter.WriteTable(len(envs), func(i int) interface{} { + err := tablewriter.WriteTable(cmd.OutOrStdout(), len(envs), func(i int) interface{} { return envs[i] }) if err != nil { return xerrors.Errorf("write table: %w", err) } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(envs) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(envs) if err != nil { return xerrors.Errorf("write environments as JSON: %w", err) } @@ -95,6 +101,7 @@ func lsEnvsCommand() *cobra.Command { cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") cmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human | json") + cmd.Flags().StringVarP(&provider, "provider", "p", "", "Filter environments by a particular workspace provider name.") return cmd } diff --git a/internal/cmd/envs_test.go b/internal/cmd/envs_test.go new file mode 100644 index 00000000..c7cc6451 --- /dev/null +++ b/internal/cmd/envs_test.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "testing" + + "cdr.dev/coder-cli/coder-sdk" +) + +func Test_envs_ls(t *testing.T) { + res := execute(t, nil, "envs", "ls") + res.success(t) + + res = execute(t, nil, "envs", "ls", "--output=json") + res.success(t) + + var envs []coder.Environment + res.stdoutUnmarshals(t, &envs) +} diff --git a/internal/cmd/images.go b/internal/cmd/images.go index b4ee6158..70364a59 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -62,7 +61,7 @@ func lsImgsCommand(user *string) *cobra.Command { switch outputFmt { case jsonOutput: - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(cmd.OutOrStdout()) // pretty print the json enc.SetIndent("", "\t") @@ -71,7 +70,7 @@ func lsImgsCommand(user *string) *cobra.Command { } return nil case humanOutput: - err = tablewriter.WriteTable(len(imgs), func(i int) interface{} { + err = tablewriter.WriteTable(cmd.OutOrStdout(), len(imgs), func(i int) interface{} { return imgs[i] }) if err != nil { diff --git a/internal/cmd/login.go b/internal/cmd/login.go index cbb0f38f..691178bf 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -4,8 +4,8 @@ import ( "bufio" "context" "fmt" + "io" "net/url" - "os" "strings" "github.com/pkg/browser" @@ -40,7 +40,7 @@ func loginCmd() *cobra.Command { // From this point, the commandline is correct. // Don't return errors as it would print the usage. - if err := login(cmd.Context(), u); err != nil { + if err := login(cmd, u); err != nil { return xerrors.Errorf("login error: %w", err) } return nil @@ -60,7 +60,7 @@ func storeConfig(envURL *url.URL, sessionToken string, urlCfg, sessionCfg config return nil } -func login(ctx context.Context, envURL *url.URL) error { +func login(cmd *cobra.Command, envURL *url.URL) error { authURL := *envURL authURL.Path = envURL.Path + "/internal-auth" q := authURL.Query() @@ -73,8 +73,8 @@ func login(ctx context.Context, envURL *url.URL) error { fmt.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String()) } - token := readLine("Paste token here: ") - if err := pingAPI(ctx, envURL, token); err != nil { + token := readLine("Paste token here: ", cmd.InOrStdin()) + if err := pingAPI(cmd.Context(), envURL, token); err != nil { return xerrors.Errorf("ping API with credentials: %w", err) } if err := storeConfig(envURL, token, config.URL, config.Session); err != nil { @@ -84,8 +84,8 @@ func login(ctx context.Context, envURL *url.URL) error { return nil } -func readLine(prompt string) string { - reader := bufio.NewReader(os.Stdin) +func readLine(prompt string, r io.Reader) string { + reader := bufio.NewReader(r) fmt.Print(prompt) text, _ := reader.ReadString('\n') return strings.TrimSuffix(text, "\n") diff --git a/internal/cmd/providers.go b/internal/cmd/providers.go index e771dcf1..aec63d3c 100644 --- a/internal/cmd/providers.go +++ b/internal/cmd/providers.go @@ -3,11 +3,12 @@ package cmd import ( "fmt" + "cdr.dev/coder-cli/internal/x/xcobra" + "github.com/spf13/cobra" "golang.org/x/xerrors" "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" "cdr.dev/coder-cli/pkg/clog" "cdr.dev/coder-cli/pkg/tablewriter" ) @@ -29,13 +30,18 @@ func providersCmd() *cobra.Command { } func createProviderCmd() *cobra.Command { + var ( + hostname string + clusterAddress string + ) cmd := &cobra.Command{ - Use: "create [workspace_provider_name]", - Short: "create a new workspace provider.", + Use: "create [name] --hostname=[hostname] --clusterAddress=[clusterAddress]", Args: xcobra.ExactArgs(1), + Short: "create a new workspace provider.", Long: "Create a new Coder workspace provider.", Example: `# create a new workspace provider in a pending state -coder providers create my-new-workspace-provider`, + +coder providers create my-provider --hostname=https://provider.example.com --cluster-address=https://255.255.255.255`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -46,7 +52,10 @@ coder providers create my-new-workspace-provider`, // ExactArgs(1) ensures our name value can't panic on an out of bounds. createReq := &coder.CreateWorkspaceProviderReq{ - Name: args[0], + Name: args[0], + Type: coder.WorkspaceProviderKubernetes, + Hostname: hostname, + ClusterAddress: clusterAddress, } wp, err := client.CreateWorkspaceProvider(ctx, *createReq) @@ -54,7 +63,7 @@ coder providers create my-new-workspace-provider`, return xerrors.Errorf("create workspace provider: %w", err) } - err = tablewriter.WriteTable(1, func(i int) interface{} { + err = tablewriter.WriteTable(cmd.OutOrStdout(), 1, func(i int) interface{} { return *wp }) if err != nil { @@ -63,6 +72,11 @@ coder providers create my-new-workspace-provider`, return nil }, } + + cmd.Flags().StringVar(&hostname, "hostname", "", "workspace provider hostname") + cmd.Flags().StringVar(&clusterAddress, "cluster-address", "", "kubernetes cluster apiserver endpoint") + _ = cmd.MarkFlagRequired("hostname") + _ = cmd.MarkFlagRequired("cluster-address") return cmd } @@ -86,7 +100,7 @@ coder providers ls`, return xerrors.Errorf("list workspace providers: %w", err) } - err = tablewriter.WriteTable(len(wps.Kubernetes), func(i int) interface{} { + err = tablewriter.WriteTable(cmd.OutOrStdout(), len(wps.Kubernetes), func(i int) interface{} { return wps.Kubernetes[i] }) if err != nil { diff --git a/internal/cmd/providers_test.go b/internal/cmd/providers_test.go new file mode 100644 index 00000000..14900759 --- /dev/null +++ b/internal/cmd/providers_test.go @@ -0,0 +1,10 @@ +package cmd + +import ( + "testing" +) + +func Test_providers_ls(t *testing.T) { + res := execute(t, nil, "providers", "ls") + res.success(t) +} diff --git a/internal/cmd/rebuild.go b/internal/cmd/rebuild.go index 978b3f58..f2fbbe8e 100644 --- a/internal/cmd/rebuild.go +++ b/internal/cmd/rebuild.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "os" "strings" "time" @@ -11,7 +10,6 @@ import ( "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" - "golang.org/x/crypto/ssh/terminal" "golang.org/x/xerrors" "cdr.dev/coder-cli/coder-sdk" @@ -85,7 +83,7 @@ func trailBuildLogs(ctx context.Context, client coder.Client, envID string) erro newSpinner := func() *spinner.Spinner { return spinner.New(spinner.CharSets[11], 100*time.Millisecond) } // this tells us whether to show dynamic loaders when printing output - isTerminal := terminal.IsTerminal(int(os.Stdout.Fd())) + isTerminal := showInteractiveOutput logs, err := client.FollowEnvironmentBuildLog(ctx, envID) if err != nil { diff --git a/internal/cmd/resourcemanager.go b/internal/cmd/resourcemanager.go index 775596e1..01f4875c 100644 --- a/internal/cmd/resourcemanager.go +++ b/internal/cmd/resourcemanager.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "io" - "os" "sort" "text/tabwriter" @@ -96,7 +95,7 @@ func runResourceTop(options *resourceTopOptions) func(cmd *cobra.Command, args [ return xerrors.Errorf("unknown --group %q", options.group) } - return printResourceTop(os.Stdout, groups, labeler, options.showEmptyGroups, options.sortBy) + return printResourceTop(cmd.OutOrStdout(), groups, labeler, options.showEmptyGroups, options.sortBy) } } diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go index 35df969d..d33dbbbf 100644 --- a/internal/cmd/shell.go +++ b/internal/cmd/shell.go @@ -24,6 +24,12 @@ import ( "cdr.dev/coder-cli/pkg/clog" ) +var ( + showInteractiveOutput = terminal.IsTerminal(int(os.Stdout.Fd())) + outputFd = os.Stdout.Fd() + inputFd = os.Stdin.Fd() +) + func getEnvsForCompletion(user string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := cmd.Context() @@ -146,15 +152,14 @@ func shell(cmd *cobra.Command, cmdArgs []string) error { } // TODO: Verify this is the correct behavior - isInteractive := terminal.IsTerminal(int(os.Stdout.Fd())) - if isInteractive { // checkAndRebuildEnvironment requires an interactive shell + if showInteractiveOutput { // checkAndRebuildEnvironment requires an interactive shell // Checks & Rebuilds the environment if needed. if err := checkAndRebuildEnvironment(ctx, client, env); err != nil { return err } } - if err := runCommand(ctx, client, env, command, args); err != nil { + if err := runCommand(cmd, client, env, command, args); err != nil { if exitErr, ok := err.(wsep.ExitError); ok { os.Exit(exitErr.Code) } @@ -309,26 +314,23 @@ func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process) } } -func runCommand(ctx context.Context, client coder.Client, env *coder.Environment, command string, args []string) error { - termFD := os.Stdout.Fd() - - isInteractive := terminal.IsTerminal(int(termFD)) - if isInteractive { +func runCommand(cmd *cobra.Command, client coder.Client, env *coder.Environment, command string, args []string) error { + if showInteractiveOutput { // If the client has a tty, take over it by setting the raw mode. // This allows for all input to be directly forwarded to the remote process, // otherwise, the local terminal would buffer input, interpret special keys, etc. - stdinState, err := xterminal.MakeRaw(os.Stdin.Fd()) + stdinState, err := xterminal.MakeRaw(inputFd) if err != nil { return err } defer func() { // Best effort. If this fails it will result in a broken terminal, // but there is nothing we can do about it. - _ = xterminal.Restore(os.Stdin.Fd(), stdinState) + _ = xterminal.Restore(inputFd, stdinState) }() } - ctx, cancel := context.WithCancel(ctx) + ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() conn, err := coderutil.DialEnvWsep(ctx, client, env) @@ -338,7 +340,7 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment go heartbeat(ctx, conn, 15*time.Second) var cmdEnv []string - if isInteractive { + if showInteractiveOutput { term := os.Getenv("TERM") if term == "" { term = "xterm" @@ -350,7 +352,7 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment process, err := execer.Start(ctx, wsep.Command{ Command: command, Args: args, - TTY: isInteractive, + TTY: showInteractiveOutput, Stdin: true, Env: cmdEnv, }) @@ -363,8 +365,8 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment } // Now that the remote process successfully started, if we have a tty, start the resize event watcher. - if isInteractive { - go sendResizeEvents(ctx, termFD, process) + if showInteractiveOutput { + go sendResizeEvents(ctx, outputFd, process) } go func() { @@ -373,17 +375,17 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment ap := activity.NewPusher(client, env.ID, sshActivityName) wr := ap.Writer(stdin) - if _, err := io.Copy(wr, os.Stdin); err != nil { + if _, err := io.Copy(wr, cmd.InOrStdin()); err != nil { cancel() } }() go func() { - if _, err := io.Copy(os.Stdout, process.Stdout()); err != nil { + if _, err := io.Copy(cmd.OutOrStdout(), process.Stdout()); err != nil { cancel() } }() go func() { - if _, err := io.Copy(os.Stderr, process.Stderr()); err != nil { + if _, err := io.Copy(cmd.ErrOrStderr(), process.Stderr()); err != nil { cancel() } }() diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index c1c09bca..fc059ac0 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -90,11 +90,15 @@ func makeRunSync(init *bool) func(cmd *cobra.Command, args []string) error { } s := sync.Sync{ - Init: *init, - Env: *env, - RemoteDir: remoteDir, - LocalDir: absLocal, - Client: client, + Init: *init, + Env: *env, + RemoteDir: remoteDir, + LocalDir: absLocal, + Client: client, + OutW: cmd.OutOrStdout(), + ErrW: cmd.ErrOrStderr(), + InputReader: cmd.InOrStdin(), + IsInteractiveOutput: showInteractiveOutput, } localVersion := rsyncVersion() diff --git a/internal/cmd/tags.go b/internal/cmd/tags.go index 13163e60..91d7ba19 100644 --- a/internal/cmd/tags.go +++ b/internal/cmd/tags.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -108,12 +107,12 @@ func tagsLsCmd() *cobra.Command { switch outputFmt { case humanOutput: - err = tablewriter.WriteTable(len(tags), func(i int) interface{} { return tags[i] }) + err = tablewriter.WriteTable(cmd.OutOrStdout(), len(tags), func(i int) interface{} { return tags[i] }) if err != nil { return err } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(tags) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(tags) if err != nil { return err } diff --git a/internal/cmd/tokens.go b/internal/cmd/tokens.go index 66d11230..21fd478f 100644 --- a/internal/cmd/tokens.go +++ b/internal/cmd/tokens.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -49,14 +48,14 @@ func lsTokensCmd() *cobra.Command { switch outputFmt { case humanOutput: - err := tablewriter.WriteTable(len(tokens), func(i int) interface{} { + err := tablewriter.WriteTable(cmd.OutOrStdout(), len(tokens), func(i int) interface{} { return tokens[i] }) if err != nil { return xerrors.Errorf("write table: %w", err) } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(tokens) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(tokens) if err != nil { return xerrors.Errorf("write tokens as JSON: %w", err) } diff --git a/internal/cmd/urls.go b/internal/cmd/urls.go index 8c3308ff..587f843d 100644 --- a/internal/cmd/urls.go +++ b/internal/cmd/urls.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "regexp" "strconv" "strings" @@ -100,14 +99,14 @@ func listDevURLsCmd(outputFmt *string) func(cmd *cobra.Command, args []string) e clog.LogInfo(fmt.Sprintf("no devURLs found for environment %q", envName)) return nil } - err := tablewriter.WriteTable(len(devURLs), func(i int) interface{} { + err := tablewriter.WriteTable(cmd.OutOrStdout(), len(devURLs), func(i int) interface{} { return devURLs[i] }) if err != nil { return xerrors.Errorf("write table: %w", err) } case jsonOutput: - if err := json.NewEncoder(os.Stdout).Encode(devURLs); err != nil { + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(devURLs); err != nil { return xerrors.Errorf("encode DevURLs as json: %w", err) } default: diff --git a/internal/cmd/users.go b/internal/cmd/users.go index a9d6725f..03929366 100644 --- a/internal/cmd/users.go +++ b/internal/cmd/users.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -47,11 +46,11 @@ func listUsers(outputFmt *string) func(cmd *cobra.Command, args []string) error case humanOutput: // For each element, return the user. each := func(i int) interface{} { return users[i] } - if err := tablewriter.WriteTable(len(users), each); err != nil { + if err := tablewriter.WriteTable(cmd.OutOrStdout(), len(users), each); err != nil { return xerrors.Errorf("write table: %w", err) } case "json": - if err := json.NewEncoder(os.Stdout).Encode(users); err != nil { + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(users); err != nil { return xerrors.Errorf("encode users as json: %w", err) } default: diff --git a/internal/coderutil/env.go b/internal/coderutil/env.go index bffa87d3..1d0e3f8a 100644 --- a/internal/coderutil/env.go +++ b/internal/coderutil/env.go @@ -66,7 +66,7 @@ func DefaultWorkspaceProvider(ctx context.Context, c coder.Client) (*coder.Kuber return nil, err } for _, p := range provider.Kubernetes { - if p.Local { + if p.BuiltIn { return &p, nil } } diff --git a/internal/config/dir.go b/internal/config/dir.go index 34cc7ab5..aff69fca 100644 --- a/internal/config/dir.go +++ b/internal/config/dir.go @@ -8,14 +8,17 @@ import ( "github.com/kirsle/configdir" ) -func dir() string { - return configdir.LocalConfig("coder") +var configRoot = configdir.LocalConfig("coder") + +// SetRoot overrides the package-level config root configuration. +func SetRoot(root string) { + configRoot = root } // open opens a file in the configuration directory, // creating all intermediate directories. func open(path string, flag int, mode os.FileMode) (*os.File, error) { - path = filepath.Join(dir(), path) + path = filepath.Join(configRoot, path) err := os.MkdirAll(filepath.Dir(path), 0750) if err != nil { @@ -45,5 +48,5 @@ func read(path string) ([]byte, error) { } func rm(path string) error { - return os.Remove(filepath.Join(dir(), path)) + return os.Remove(filepath.Join(configRoot, path)) } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index e9f16be8..cc6d22ea 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -41,8 +41,12 @@ type Sync struct { // DisableMetrics disables activity metric pushing. DisableMetrics bool - Env coder.Environment - Client coder.Client + Env coder.Environment + Client coder.Client + OutW io.Writer + ErrW io.Writer + InputReader io.Reader + IsInteractiveOutput bool } // See https://lxadm.com/Rsync_exit_codes#List_of_standard_rsync_exit_codes. @@ -71,9 +75,9 @@ func (s Sync) syncPaths(delete bool, local, remote string) error { // (AB): compression sped up the initial sync of the enterprise repo by 30%, leading me to believe it's // good in general for codebases. cmd := exec.Command("rsync", args...) - cmd.Stdout = os.Stdout + cmd.Stdout = s.OutW cmd.Stderr = ioutil.Discard - cmd.Stdin = os.Stdin + cmd.Stdin = s.InputReader if err := cmd.Run(); err != nil { if exitError, ok := err.(*exec.ExitError); ok { @@ -106,8 +110,8 @@ func (s Sync) remoteCmd(ctx context.Context, prog string, args ...string) error return xerrors.Errorf("exec remote process: %w", err) } // NOTE: If the copy routine fail, it will result in `process.Wait` to unblock and report an error. - go func() { _, _ = io.Copy(os.Stdout, process.Stdout()) }() // Best effort. - go func() { _, _ = io.Copy(os.Stderr, process.Stderr()) }() // Best effort. + go func() { _, _ = io.Copy(s.OutW, process.Stdout()) }() // Best effort. + go func() { _, _ = io.Copy(s.ErrW, process.Stderr()) }() // Best effort. if err := process.Wait(); err != nil { if code, ok := err.(wsep.ExitError); ok { @@ -235,7 +239,7 @@ func (s Sync) workEventGroup(evs []timedEvent) { var wg sync.WaitGroup for _, ev := range cache.ConcurrentEvents() { - setConsoleTitle(fmtUpdateTitle(ev.Path())) + setConsoleTitle(fmtUpdateTitle(ev.Path()), s.IsInteractiveOutput) wg.Add(1) // TODO: Document why this error is discarded. See https://github.com/cdr/coder-cli/issues/122 for reference. @@ -326,7 +330,7 @@ func (s Sync) Run() error { ap := activity.NewPusher(s.Client, s.Env.ID, activityName) ap.Push(ctx) - setConsoleTitle("⏳ syncing project") + setConsoleTitle("⏳ syncing project", s.IsInteractiveOutput) if err := s.initSync(); err != nil { return err } @@ -363,7 +367,7 @@ func (s Sync) Run() error { defer dispatchEventGroup.Stop() for { const watchingFilesystemTitle = "🛰 watching filesystem" - setConsoleTitle(watchingFilesystemTitle) + setConsoleTitle(watchingFilesystemTitle, s.IsInteractiveOutput) select { case ev := <-timedEvents: diff --git a/internal/sync/title.go b/internal/sync/title.go index c9a91c8b..ae7630d8 100644 --- a/internal/sync/title.go +++ b/internal/sync/title.go @@ -2,14 +2,11 @@ package sync import ( "fmt" - "os" "path/filepath" - - "golang.org/x/crypto/ssh/terminal" ) -func setConsoleTitle(title string) { - if !terminal.IsTerminal(int(os.Stdout.Fd())) { +func setConsoleTitle(title string, isInteractiveOutput bool) { + if !isInteractiveOutput { return } fmt.Printf("\033]0;%s\007", title) diff --git a/pkg/clog/clog.go b/pkg/clog/clog.go index eafc18aa..0a523e1f 100644 --- a/pkg/clog/clog.go +++ b/pkg/clog/clog.go @@ -3,6 +3,7 @@ package clog import ( "errors" "fmt" + "io" "os" "strings" @@ -10,6 +11,13 @@ import ( "golang.org/x/xerrors" ) +var writer io.Writer = os.Stderr + +// SetOutput sets the package-level writer target for log functions. +func SetOutput(w io.Writer) { + writer = w +} + // CLIMessage provides a human-readable message for CLI errors and messages. type CLIMessage struct { Level string @@ -45,12 +53,12 @@ func Log(err error) { if !xerrors.As(err, &cliErr) { cliErr = Fatal(err.Error()) } - fmt.Fprintln(os.Stderr, cliErr.String()) + fmt.Fprintln(writer, cliErr.String()) } // LogInfo prints the given info message to stderr. func LogInfo(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "info", Color: color.FgBlue, Header: header, @@ -60,7 +68,7 @@ func LogInfo(header string, lines ...string) { // LogSuccess prints the given info message to stderr. func LogSuccess(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "success", Color: color.FgGreen, Header: header, @@ -70,7 +78,7 @@ func LogSuccess(header string, lines ...string) { // LogWarn prints the given warn message to stderr. func LogWarn(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "warning", Color: color.FgYellow, Header: header, diff --git a/pkg/clog/clog_test.go b/pkg/clog/clog_test.go index 8d1c88b4..4c75a3e5 100644 --- a/pkg/clog/clog_test.go +++ b/pkg/clog/clog_test.go @@ -20,7 +20,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close() @@ -39,7 +39,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close() @@ -59,7 +59,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close() diff --git a/pkg/tablewriter/tablewriter.go b/pkg/tablewriter/tablewriter.go index 36a86bdf..9f12becd 100644 --- a/pkg/tablewriter/tablewriter.go +++ b/pkg/tablewriter/tablewriter.go @@ -3,7 +3,6 @@ package tablewriter import ( "fmt" "io" - "os" "reflect" "strings" "text/tabwriter" @@ -55,19 +54,16 @@ func StructFieldNames(data interface{}) string { return s.String() } -// The output io.Writer for WriteTable. This is globally defined to allow overriding in tests. -var tableOutput io.Writer = os.Stdout - // WriteTable writes the given list elements to stdout in a human readable // tabular format. Headers abide by the `table` struct tag. // // `table:"-"` omits the field and no tag defaults to the Go identifier. // `table:"_"` flattens a fields subfields. -func WriteTable(length int, each func(i int) interface{}) error { +func WriteTable(writer io.Writer, length int, each func(i int) interface{}) error { if length < 1 { return nil } - w := tabwriter.NewWriter(tableOutput, 0, 0, 4, ' ', 0) + w := tabwriter.NewWriter(writer, 0, 0, 4, ' ', 0) defer func() { _ = w.Flush() }() // Best effort. for ix := 0; ix < length; ix++ { item := each(ix) diff --git a/pkg/tablewriter/tablewriter_test.go b/pkg/tablewriter/tablewriter_test.go index 5318dd1e..e611e52c 100644 --- a/pkg/tablewriter/tablewriter_test.go +++ b/pkg/tablewriter/tablewriter_test.go @@ -49,8 +49,7 @@ func TestTableWriter(t *testing.T) { } buf := bytes.NewBuffer(nil) - tableOutput = buf - err := WriteTable(len(items), func(i int) interface{} { return items[i] }) + err := WriteTable(buf, len(items), func(i int) interface{} { return items[i] }) assert.Success(t, "write table", err) assertGolden(t, "table_output.golden", buf.Bytes())