From 2580a17b9c55bae26cabfed5f079a9769896b158 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 19 Aug 2022 12:31:02 -0700 Subject: [PATCH 1/5] coder licenses add CLI command Signed-off-by: Spike Curtis --- cli/clitest/clitest.go | 8 +- cli/configssh.go | 2 +- cli/create.go | 2 +- cli/delete.go | 2 +- cli/features.go | 2 +- cli/list.go | 2 +- cli/logout.go | 2 +- cli/parameterslist.go | 2 +- cli/portforward.go | 2 +- cli/publickey.go | 2 +- cli/root.go | 8 +- cli/schedule.go | 8 +- cli/show.go | 2 +- cli/ssh.go | 2 +- cli/start.go | 2 +- cli/state.go | 4 +- cli/stop.go | 2 +- cli/templatecreate.go | 2 +- cli/templatedelete.go | 2 +- cli/templateedit.go | 2 +- cli/templatelist.go | 2 +- cli/templatepull.go | 2 +- cli/templatepush.go | 2 +- cli/templateversions.go | 2 +- cli/update.go | 2 +- cli/usercreate.go | 2 +- cli/userlist.go | 4 +- cli/userstatus.go | 2 +- cli/wireguardtunnel.go | 2 +- enterprise/cli/licenses.go | 114 +++++++++++++++++++++ enterprise/cli/licenses_test.go | 176 ++++++++++++++++++++++++++++++++ enterprise/cli/root.go | 9 +- 32 files changed, 341 insertions(+), 38 deletions(-) create mode 100644 enterprise/cli/licenses.go create mode 100644 enterprise/cli/licenses_test.go diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index c37e652bef39f..5a7160ffd843f 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -21,7 +21,13 @@ import ( // New creates a CLI instance with a configuration pointed to a // temporary testing directory. func New(t *testing.T, args ...string) (*cobra.Command, config.Root) { - cmd := cli.Root(cli.AGPL()) + return NewWithSubcommands(t, cli.AGPL(), args...) +} + +func NewWithSubcommands( + t *testing.T, subcommands []*cobra.Command, args ...string, +) (*cobra.Command, config.Root) { + cmd := cli.Root(subcommands) dir := t.TempDir() root := config.Root(dir) cmd.SetArgs(append([]string{"--global-config", dir}, args...)) diff --git a/cli/configssh.go b/cli/configssh.go index a3f9a4517c8e0..9f6177a6be5a1 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -158,7 +158,7 @@ func configSSH() *cobra.Command { ), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, _ []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/create.go b/cli/create.go index f1fe396fbe088..1829cd71f099b 100644 --- a/cli/create.go +++ b/cli/create.go @@ -27,7 +27,7 @@ func create() *cobra.Command { Use: "create [name]", Short: "Create a workspace from a template", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/delete.go b/cli/delete.go index 5950a7b03445c..37134880ce108 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -28,7 +28,7 @@ func deleteWorkspace() *cobra.Command { return err } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/features.go b/cli/features.go index d7f0fbdee1056..5d631fc04977f 100644 --- a/cli/features.go +++ b/cli/features.go @@ -36,7 +36,7 @@ func featuresList() *cobra.Command { Use: "list", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/list.go b/cli/list.go index 4ecc7737d4a68..3259d3f3f09a4 100644 --- a/cli/list.go +++ b/cli/list.go @@ -65,7 +65,7 @@ func list() *cobra.Command { Aliases: []string{"ls"}, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/logout.go b/cli/logout.go index cab5f46203177..3ad84e7b61163 100644 --- a/cli/logout.go +++ b/cli/logout.go @@ -16,7 +16,7 @@ func logout() *cobra.Command { Use: "logout", Short: "Remove the local authenticated session", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/parameterslist.go b/cli/parameterslist.go index b9c675b4cfe5e..438b15acea419 100644 --- a/cli/parameterslist.go +++ b/cli/parameterslist.go @@ -22,7 +22,7 @@ func parameterList() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { scope, name := args[0], args[1] - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/portforward.go b/cli/portforward.go index 9a13ef6eab6dd..b9db1ffaa4c22 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -70,7 +70,7 @@ func portForward() *cobra.Command { return xerrors.New("no port-forwards requested") } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/publickey.go b/cli/publickey.go index 0be912bcd9cd1..5b603516ba7fa 100644 --- a/cli/publickey.go +++ b/cli/publickey.go @@ -20,7 +20,7 @@ func publickey() *cobra.Command { Aliases: []string{"pubkey"}, Short: "Output your public key for Git operations", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return xerrors.Errorf("create codersdk client: %w", err) } diff --git a/cli/root.go b/cli/root.go index 250052282bde9..cbece56fcf4cc 100644 --- a/cli/root.go +++ b/cli/root.go @@ -114,7 +114,7 @@ func Root(subcommands []*cobra.Command) *cobra.Command { return nil } - client, err := createClient(cmd) + client, err := CreateClient(cmd) // If the client is unauthenticated we can ignore the check. // The child commands should handle an unauthenticated client. if xerrors.Is(err, errUnauthenticated) { @@ -190,9 +190,9 @@ func isTest() bool { return flag.Lookup("test.v") != nil } -// createClient returns a new client from the command context. +// CreateClient returns a new client from the command context. // It reads from global configuration files if flags are not set. -func createClient(cmd *cobra.Command) (*codersdk.Client, error) { +func CreateClient(cmd *cobra.Command) (*codersdk.Client, error) { root := createConfig(cmd) rawURL, err := cmd.Flags().GetString(varURL) if err != nil || rawURL == "" { @@ -226,7 +226,7 @@ func createClient(cmd *cobra.Command) (*codersdk.Client, error) { } // createAgentClient returns a new client from the command context. -// It works just like createClient, but uses the agent token and URL instead. +// It works just like CreateClient, but uses the agent token and URL instead. func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) { rawURL, err := cmd.Flags().GetString(varAgentURL) if err != nil { diff --git a/cli/schedule.go b/cli/schedule.go index b583915baf7f7..5e9de6c4869f1 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -77,7 +77,7 @@ func scheduleShow() *cobra.Command { Long: scheduleShowDescriptionLong, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } @@ -106,7 +106,7 @@ func scheduleStart() *cobra.Command { Long: scheduleStartDescriptionLong, Args: cobra.RangeArgs(2, 4), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } @@ -156,7 +156,7 @@ func scheduleStop() *cobra.Command { Short: "Edit workspace stop schedule", Long: scheduleStopDescriptionLong, RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } @@ -207,7 +207,7 @@ func scheduleOverride() *cobra.Command { return err } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return xerrors.Errorf("create client: %w", err) } diff --git a/cli/show.go b/cli/show.go index 145e8d6e3f3d0..3a0ed3973a96f 100644 --- a/cli/show.go +++ b/cli/show.go @@ -14,7 +14,7 @@ func show() *cobra.Command { Short: "Show details of a workspace's resources and agents", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/ssh.go b/cli/ssh.go index 53e6ce88f9c75..084a59e2ba576 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -54,7 +54,7 @@ func ssh() *cobra.Command { ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/start.go b/cli/start.go index a3fd586a0d9b9..9ceeed2c2b998 100644 --- a/cli/start.go +++ b/cli/start.go @@ -17,7 +17,7 @@ func start() *cobra.Command { Short: "Build a workspace with the start state", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/state.go b/cli/state.go index 498397a88ce9d..d6e5a4e549608 100644 --- a/cli/state.go +++ b/cli/state.go @@ -27,7 +27,7 @@ func statePull() *cobra.Command { Use: "pull [file]", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } @@ -68,7 +68,7 @@ func statePush() *cobra.Command { Use: "push ", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/stop.go b/cli/stop.go index 381e7634b9a90..c2bfa76e4df02 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -25,7 +25,7 @@ func stop() *cobra.Command { return err } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 50d2e16619edd..3f8c7fd2e62e9 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -32,7 +32,7 @@ func templateCreate() *cobra.Command { Short: "Create a template from the current directory or as specified by flag", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/templatedelete.go b/cli/templatedelete.go index aa4f5d6dda68e..8b1b1903a9b68 100644 --- a/cli/templatedelete.go +++ b/cli/templatedelete.go @@ -23,7 +23,7 @@ func templateDelete() *cobra.Command { templates = []codersdk.Template{} ) - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/templateedit.go b/cli/templateedit.go index 166c29793b9cc..df3c582203d13 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -25,7 +25,7 @@ func templateEdit() *cobra.Command { Args: cobra.ExactArgs(1), Short: "Edit the metadata of a template by name.", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return xerrors.Errorf("create client: %w", err) } diff --git a/cli/templatelist.go b/cli/templatelist.go index 2350f44086102..b7dc29ac497fb 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -16,7 +16,7 @@ func templateList() *cobra.Command { Short: "List all the templates available for the organization", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/templatepull.go b/cli/templatepull.go index 2b2509ae0b0bf..5660261c5158d 100644 --- a/cli/templatepull.go +++ b/cli/templatepull.go @@ -29,7 +29,7 @@ func templatePull() *cobra.Command { dest = args[1] } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return xerrors.Errorf("create client: %w", err) } diff --git a/cli/templatepush.go b/cli/templatepush.go index c4be5165ed6c3..ccc6e800b01d4 100644 --- a/cli/templatepush.go +++ b/cli/templatepush.go @@ -29,7 +29,7 @@ func templatePush() *cobra.Command { Args: cobra.MaximumNArgs(1), Short: "Push a new template version from the current directory or as specified by flag", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/templateversions.go b/cli/templateversions.go index 8dcb51bd957ff..5c27970d6bedd 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -38,7 +38,7 @@ func templateVersionsList() *cobra.Command { Args: cobra.ExactArgs(1), Short: "List all the versions of the specified template", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return xerrors.Errorf("create client: %w", err) } diff --git a/cli/update.go b/cli/update.go index 53d73800255c9..ed3a900cc1917 100644 --- a/cli/update.go +++ b/cli/update.go @@ -22,7 +22,7 @@ func update() *cobra.Command { Args: cobra.ExactArgs(1), Short: "Update a workspace to the latest template version", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/usercreate.go b/cli/usercreate.go index 899b8b4fb3d98..73bc0fb8f9947 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -21,7 +21,7 @@ func userCreate() *cobra.Command { cmd := &cobra.Command{ Use: "create", RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/userlist.go b/cli/userlist.go index a3e0c6a9f36c6..b9e9116791c61 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -26,7 +26,7 @@ func userList() *cobra.Command { Use: "list", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } @@ -76,7 +76,7 @@ func userSingle() *cobra.Command { ), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/userstatus.go b/cli/userstatus.go index 577be8e91f822..0b249416bed58 100644 --- a/cli/userstatus.go +++ b/cli/userstatus.go @@ -43,7 +43,7 @@ func createUserStatusCommand(sdkStatus codersdk.UserStatus) *cobra.Command { }, ), RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/cli/wireguardtunnel.go b/cli/wireguardtunnel.go index 592f8b4069f0d..488f7ad342074 100644 --- a/cli/wireguardtunnel.go +++ b/cli/wireguardtunnel.go @@ -67,7 +67,7 @@ func wireguardPortForward() *cobra.Command { return xerrors.New("no port-forwards requested") } - client, err := createClient(cmd) + client, err := CreateClient(cmd) if err != nil { return err } diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go new file mode 100644 index 0000000000000..9e3544bfe6d20 --- /dev/null +++ b/enterprise/cli/licenses.go @@ -0,0 +1,114 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "regexp" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + agpl "github.com/coder/coder/cli" + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +var jwtRegexp = regexp.MustCompile("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$") + +func licenses() *cobra.Command { + cmd := &cobra.Command{ + Short: "Add, remove, and list licenses", + Use: "licenses", + Aliases: []string{"license"}, + } + cmd.AddCommand( + licenseAdd(), + ) + return cmd +} + +func licenseAdd() *cobra.Command { + var ( + filename string + license string + debug bool + ) + cmd := &cobra.Command{ + Use: "add", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := agpl.CreateClient(cmd) + if err != nil { + return err + } + if filename != "" && license != "" { + return xerrors.New("only one of (--filename, --license) may be specified") + } + + if filename == "" && license == "" { + license, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Paste license:", + Secret: true, + Validate: validJWT, + }) + if err != nil { + return err + } + } + + if filename != "" && license == "" { + var r io.Reader + if filename == "-" { + r = cmd.InOrStdin() + } else { + f, err := os.Open(filename) + defer f.Close() + r = f + if err != nil { + return err + } + } + lb, err := io.ReadAll(r) + if err != nil { + return err + } + license = string(lb) + } + err = validJWT(license) + if err != nil { + return err + } + + licResp, err := client.AddLicense( + cmd.Context(), + codersdk.AddLicenseRequest{License: license}, + ) + if err != nil { + return err + } + if debug { + enc := json.NewEncoder(cmd.OutOrStdout()) + return enc.Encode(licResp) + } + _, _ = fmt.Fprintln( + cmd.OutOrStdout(), + fmt.Sprintf("License with ID %d added", licResp.ID), + ) + return nil + }, + } + cmd.Flags().StringVarP(&filename, "filename", "f", "", "Load license from file") + cmd.Flags().StringVarP(&license, "license", "l", "", "License string") + cmd.Flags().BoolVar(&debug, "debug", false, "Output license claims for debugging") + return cmd +} + +func validJWT(s string) error { + if jwtRegexp.MatchString(strings.Trim(s, " ")) { + return nil + } + return xerrors.New("Invalid license") +} diff --git a/enterprise/cli/licenses_test.go b/enterprise/cli/licenses_test.go new file mode 100644 index 0000000000000..1039db12f9581 --- /dev/null +++ b/enterprise/cli/licenses_test.go @@ -0,0 +1,176 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/cli" + "github.com/coder/coder/enterprise/coderd" + "github.com/coder/coder/pty/ptytest" + "github.com/coder/coder/testutil" +) + +const fakeLicenseJWT = "test.jwt.sig" + +func TestLicensesAddSuccess(t *testing.T) { + // We can't check a real license into the git repo, and can't patch out the keys from here, + // so instead we have to fake the HTTP interaction. t.Parallel() + t.Run("LFlag", func(t *testing.T) { + t.Parallel() + cmd, pty := setupFakeLicenseServerTest(t, true, "licenses", "add", "-l", fakeLicenseJWT) + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + require.NoError(t, <-errC) + pty.ExpectMatch("License with ID 1 added") + }) + t.Run("Prompt", func(t *testing.T) { + t.Parallel() + cmd, pty := setupFakeLicenseServerTest(t, true, "license", "add") + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + pty.ExpectMatch("Paste license:") + pty.WriteLine(fakeLicenseJWT) + require.NoError(t, <-errC) + pty.ExpectMatch("License with ID 1 added") + }) + t.Run("File", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + filename := filepath.Join(dir, "license.jwt") + err := os.WriteFile(filename, []byte(fakeLicenseJWT), 0666) + require.NoError(t, err) + cmd, pty := setupFakeLicenseServerTest(t, true, "license", "add", "-f", filename) + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + require.NoError(t, <-errC) + pty.ExpectMatch("License with ID 1 added") + }) + t.Run("StdIn", func(t *testing.T) { + t.Parallel() + cmd, _ := setupFakeLicenseServerTest(t, false, "license", "add", "-f", "-") + r, w := io.Pipe() + cmd.SetIn(r) + stdout := new(bytes.Buffer) + cmd.SetOut(stdout) + errC := make(chan error) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + go func() { + errC <- cmd.Execute() + }() + _, err := w.Write([]byte(fakeLicenseJWT)) + require.NoError(t, err) + err = w.Close() + require.NoError(t, err) + select { + case err = <-errC: + require.NoError(t, err) + case <-ctx.Done(): + t.Error("timed out") + } + assert.Equal(t, "License with ID 1 added\n", stdout.String()) + }) + t.Run("DebugOutput", func(t *testing.T) { + t.Parallel() + cmd, pty := setupFakeLicenseServerTest(t, true, "licenses", "add", "-l", fakeLicenseJWT, "--debug") + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + require.NoError(t, <-errC) + pty.ExpectMatch("\"f2\":2") + }) +} + +func TestLicensesAddFail(t *testing.T) { + t.Parallel() + t.Run("LFlag", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{APIBuilder: coderd.NewEnterprise}) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), + "licenses", "add", "-l", fakeLicenseJWT) + clitest.SetupConfig(t, client, root) + + errC := make(chan error) + go func() { + errC <- cmd.Execute() + }() + err := <-errC + var coderError *codersdk.Error + require.True(t, xerrors.As(err, &coderError)) + assert.Equal(t, 400, coderError.StatusCode()) + assert.Contains(t, "Invalid license", coderError.Message) + }) +} + +func setupFakeLicenseServerTest(t *testing.T, withPty bool, args ...string) (*cobra.Command, *ptytest.PTY) { + t.Helper() + s := httptest.NewServer(&fakeAddLicenseServer{t}) + t.Cleanup(s.Close) + cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), args...) + err := root.URL().Write(s.URL) + require.NoError(t, err) + err = root.Session().Write("sessiontoken") + require.NoError(t, err) + var pty *ptytest.PTY + if withPty { + pty = ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + } + return cmd, pty +} + +type fakeAddLicenseServer struct { + t *testing.T +} + +func (s *fakeAddLicenseServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/buildinfo" { + return + } + assert.Equal(s.t, http.MethodPost, r.Method) + assert.Equal(s.t, "/api/v2/licenses", r.URL.Path) + var req codersdk.AddLicenseRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(s.t, err) + assert.Equal(s.t, "test.jwt.sig", req.License) + + resp := codersdk.License{ + ID: 1, + UploadedAt: time.Now(), + Claims: map[string]interface{}{ + "h1": "claim1", + "features": map[string]int64{ + "f1": 1, + "f2": 2, + }, + }, + } + rw.WriteHeader(http.StatusCreated) + err = json.NewEncoder(rw).Encode(resp) + assert.NoError(s.t, err) +} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 114c283fa6f2e..31546b5d679d0 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -7,7 +7,14 @@ import ( "github.com/coder/coder/enterprise/coderd" ) +func enterpriseOnly() []*cobra.Command { + return []*cobra.Command{ + agpl.Server(coderd.NewEnterprise), + licenses(), + } +} + func EnterpriseSubcommands() []*cobra.Command { - all := append(agpl.Core(), agpl.Server(coderd.NewEnterprise)) + all := append(agpl.Core(), enterpriseOnly()...) return all } From 26309c4a0a8da60d699ad86c212559c8f2ecf747 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 22 Aug 2022 15:39:55 -0700 Subject: [PATCH 2/5] Fix up lint Signed-off-by: Spike Curtis --- enterprise/cli/licenses.go | 11 ++++------- enterprise/cli/licenses_test.go | 33 +++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 9e3544bfe6d20..38ff1017f6195 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -16,7 +16,7 @@ import ( "github.com/coder/coder/codersdk" ) -var jwtRegexp = regexp.MustCompile("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$") +var jwtRegexp = regexp.MustCompile(`^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$`) func licenses() *cobra.Command { cmd := &cobra.Command{ @@ -65,11 +65,11 @@ func licenseAdd() *cobra.Command { r = cmd.InOrStdin() } else { f, err := os.Open(filename) - defer f.Close() - r = f if err != nil { return err } + defer f.Close() + r = f } lb, err := io.ReadAll(r) if err != nil { @@ -93,10 +93,7 @@ func licenseAdd() *cobra.Command { enc := json.NewEncoder(cmd.OutOrStdout()) return enc.Encode(licResp) } - _, _ = fmt.Fprintln( - cmd.OutOrStdout(), - fmt.Sprintf("License with ID %d added", licResp.ID), - ) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "License with ID %d added\n", licResp.ID) return nil }, } diff --git a/enterprise/cli/licenses_test.go b/enterprise/cli/licenses_test.go index 1039db12f9581..517133a081ec5 100644 --- a/enterprise/cli/licenses_test.go +++ b/enterprise/cli/licenses_test.go @@ -33,7 +33,8 @@ func TestLicensesAddSuccess(t *testing.T) { // so instead we have to fake the HTTP interaction. t.Parallel() t.Run("LFlag", func(t *testing.T) { t.Parallel() - cmd, pty := setupFakeLicenseServerTest(t, true, "licenses", "add", "-l", fakeLicenseJWT) + cmd := setupFakeLicenseServerTest(t, "licenses", "add", "-l", fakeLicenseJWT) + pty := attachPty(t, cmd) errC := make(chan error) go func() { errC <- cmd.Execute() @@ -43,7 +44,8 @@ func TestLicensesAddSuccess(t *testing.T) { }) t.Run("Prompt", func(t *testing.T) { t.Parallel() - cmd, pty := setupFakeLicenseServerTest(t, true, "license", "add") + cmd := setupFakeLicenseServerTest(t, "license", "add") + pty := attachPty(t, cmd) errC := make(chan error) go func() { errC <- cmd.Execute() @@ -57,9 +59,10 @@ func TestLicensesAddSuccess(t *testing.T) { t.Parallel() dir := t.TempDir() filename := filepath.Join(dir, "license.jwt") - err := os.WriteFile(filename, []byte(fakeLicenseJWT), 0666) + err := os.WriteFile(filename, []byte(fakeLicenseJWT), 0600) require.NoError(t, err) - cmd, pty := setupFakeLicenseServerTest(t, true, "license", "add", "-f", filename) + cmd := setupFakeLicenseServerTest(t, "license", "add", "-f", filename) + pty := attachPty(t, cmd) errC := make(chan error) go func() { errC <- cmd.Execute() @@ -69,7 +72,7 @@ func TestLicensesAddSuccess(t *testing.T) { }) t.Run("StdIn", func(t *testing.T) { t.Parallel() - cmd, _ := setupFakeLicenseServerTest(t, false, "license", "add", "-f", "-") + cmd := setupFakeLicenseServerTest(t, "license", "add", "-f", "-") r, w := io.Pipe() cmd.SetIn(r) stdout := new(bytes.Buffer) @@ -94,7 +97,8 @@ func TestLicensesAddSuccess(t *testing.T) { }) t.Run("DebugOutput", func(t *testing.T) { t.Parallel() - cmd, pty := setupFakeLicenseServerTest(t, true, "licenses", "add", "-l", fakeLicenseJWT, "--debug") + cmd := setupFakeLicenseServerTest(t, "licenses", "add", "-l", fakeLicenseJWT, "--debug") + pty := attachPty(t, cmd) errC := make(chan error) go func() { errC <- cmd.Execute() @@ -126,7 +130,7 @@ func TestLicensesAddFail(t *testing.T) { }) } -func setupFakeLicenseServerTest(t *testing.T, withPty bool, args ...string) (*cobra.Command, *ptytest.PTY) { +func setupFakeLicenseServerTest(t *testing.T, args ...string) *cobra.Command { t.Helper() s := httptest.NewServer(&fakeAddLicenseServer{t}) t.Cleanup(s.Close) @@ -135,13 +139,14 @@ func setupFakeLicenseServerTest(t *testing.T, withPty bool, args ...string) (*co require.NoError(t, err) err = root.Session().Write("sessiontoken") require.NoError(t, err) - var pty *ptytest.PTY - if withPty { - pty = ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) - } - return cmd, pty + return cmd +} + +func attachPty(t *testing.T, cmd *cobra.Command) *ptytest.PTY { + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + return pty } type fakeAddLicenseServer struct { From 09403d30bd107c212b3810f020c4f9a155068262 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Mon, 22 Aug 2022 15:52:17 -0700 Subject: [PATCH 3/5] Fix t.parallel call Signed-off-by: Spike Curtis --- enterprise/cli/licenses_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise/cli/licenses_test.go b/enterprise/cli/licenses_test.go index 517133a081ec5..df7a71eeeae27 100644 --- a/enterprise/cli/licenses_test.go +++ b/enterprise/cli/licenses_test.go @@ -29,8 +29,9 @@ import ( const fakeLicenseJWT = "test.jwt.sig" func TestLicensesAddSuccess(t *testing.T) { + t.Parallel() // We can't check a real license into the git repo, and can't patch out the keys from here, - // so instead we have to fake the HTTP interaction. t.Parallel() + // so instead we have to fake the HTTP interaction. t.Run("LFlag", func(t *testing.T) { t.Parallel() cmd := setupFakeLicenseServerTest(t, "licenses", "add", "-l", fakeLicenseJWT) From a886c3100919deba94ff18ee8fd87209cafa4cc4 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 12:55:22 -0700 Subject: [PATCH 4/5] Code review improvements Signed-off-by: Spike Curtis --- enterprise/cli/licenses.go | 27 ++++++++++++++++----------- enterprise/cli/licenses_test.go | 24 +++++++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 38ff1017f6195..891a19be7d98c 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -16,7 +16,7 @@ import ( "github.com/coder/coder/codersdk" ) -var jwtRegexp = regexp.MustCompile(`^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$`) +var jwtRegexp = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`) func licenses() *cobra.Command { cmd := &cobra.Command{ @@ -37,18 +37,21 @@ func licenseAdd() *cobra.Command { debug bool ) cmd := &cobra.Command{ - Use: "add", - Args: cobra.NoArgs, + Use: "add [-f file | -l license]", + Short: "Add license to Coder deployment", + Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { client, err := agpl.CreateClient(cmd) if err != nil { return err } - if filename != "" && license != "" { - return xerrors.New("only one of (--filename, --license) may be specified") - } - if filename == "" && license == "" { + switch { + + case filename != "" && license != "": + return xerrors.New("only one of (--file, --license) may be specified") + + case filename == "" && license == "": license, err = cliui.Prompt(cmd, cliui.PromptOptions{ Text: "Paste license:", Secret: true, @@ -57,9 +60,8 @@ func licenseAdd() *cobra.Command { if err != nil { return err } - } - if filename != "" && license == "" { + case filename != "" && license == "": var r io.Reader if filename == "-" { r = cmd.InOrStdin() @@ -76,7 +78,9 @@ func licenseAdd() *cobra.Command { return err } license = string(lb) + } + license = strings.Trim(license, " \n") err = validJWT(license) if err != nil { return err @@ -91,20 +95,21 @@ func licenseAdd() *cobra.Command { } if debug { enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") return enc.Encode(licResp) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "License with ID %d added\n", licResp.ID) return nil }, } - cmd.Flags().StringVarP(&filename, "filename", "f", "", "Load license from file") + cmd.Flags().StringVarP(&filename, "file", "f", "", "Load license from file") cmd.Flags().StringVarP(&license, "license", "l", "", "License string") cmd.Flags().BoolVar(&debug, "debug", false, "Output license claims for debugging") return cmd } func validJWT(s string) error { - if jwtRegexp.MatchString(strings.Trim(s, " ")) { + if jwtRegexp.MatchString(s) { return nil } return xerrors.New("Invalid license") diff --git a/enterprise/cli/licenses_test.go b/enterprise/cli/licenses_test.go index df7a71eeeae27..f56be0b79ec45 100644 --- a/enterprise/cli/licenses_test.go +++ b/enterprise/cli/licenses_test.go @@ -34,22 +34,26 @@ func TestLicensesAddSuccess(t *testing.T) { // so instead we have to fake the HTTP interaction. t.Run("LFlag", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() cmd := setupFakeLicenseServerTest(t, "licenses", "add", "-l", fakeLicenseJWT) pty := attachPty(t, cmd) errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() require.NoError(t, <-errC) pty.ExpectMatch("License with ID 1 added") }) t.Run("Prompt", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() cmd := setupFakeLicenseServerTest(t, "license", "add") pty := attachPty(t, cmd) errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() pty.ExpectMatch("Paste license:") pty.WriteLine(fakeLicenseJWT) @@ -58,6 +62,8 @@ func TestLicensesAddSuccess(t *testing.T) { }) t.Run("File", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() dir := t.TempDir() filename := filepath.Join(dir, "license.jwt") err := os.WriteFile(filename, []byte(fakeLicenseJWT), 0600) @@ -66,7 +72,7 @@ func TestLicensesAddSuccess(t *testing.T) { pty := attachPty(t, cmd) errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() require.NoError(t, <-errC) pty.ExpectMatch("License with ID 1 added") @@ -82,7 +88,7 @@ func TestLicensesAddSuccess(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() _, err := w.Write([]byte(fakeLicenseJWT)) require.NoError(t, err) @@ -98,14 +104,16 @@ func TestLicensesAddSuccess(t *testing.T) { }) t.Run("DebugOutput", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() cmd := setupFakeLicenseServerTest(t, "licenses", "add", "-l", fakeLicenseJWT, "--debug") pty := attachPty(t, cmd) errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() require.NoError(t, <-errC) - pty.ExpectMatch("\"f2\":2") + pty.ExpectMatch("\"f2\": 2") }) } @@ -119,9 +127,11 @@ func TestLicensesAddFail(t *testing.T) { "licenses", "add", "-l", fakeLicenseJWT) clitest.SetupConfig(t, client, root) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() errC := make(chan error) go func() { - errC <- cmd.Execute() + errC <- cmd.ExecuteContext(ctx) }() err := <-errC var coderError *codersdk.Error From 9c399dd7ae8380c269f62c0f4736fd5195539a8e Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 23 Aug 2022 13:02:33 -0700 Subject: [PATCH 5/5] Lint Signed-off-by: Spike Curtis --- enterprise/cli/licenses.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index 891a19be7d98c..c548b74f31893 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -47,7 +47,6 @@ func licenseAdd() *cobra.Command { } switch { - case filename != "" && license != "": return xerrors.New("only one of (--file, --license) may be specified") @@ -78,7 +77,6 @@ func licenseAdd() *cobra.Command { return err } license = string(lb) - } license = strings.Trim(license, " \n") err = validJWT(license)