coder licenses add CLI command (#3632)

* coder licenses add CLI command

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix up lint

Signed-off-by: Spike Curtis <spike@coder.com>

* Fix t.parallel call

Signed-off-by: Spike Curtis <spike@coder.com>

* Code review improvements

Signed-off-by: Spike Curtis <spike@coder.com>

* Lint

Signed-off-by: Spike Curtis <spike@coder.com>

Signed-off-by: Spike Curtis <spike@coder.com>
This commit is contained in:
Spike Curtis
2022-08-23 13:55:39 -07:00
committed by GitHub
parent 6dacf70898
commit 184f0625e1
32 changed files with 357 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ func deleteWorkspace() *cobra.Command {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ func statePull() *cobra.Command {
Use: "pull <workspace> [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 <workspace> <file>",
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
}

View File

@ -25,7 +25,7 @@ func stop() *cobra.Command {
return err
}
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}

View File

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

View File

@ -23,7 +23,7 @@ func templateDelete() *cobra.Command {
templates = []codersdk.Template{}
)
client, err := createClient(cmd)
client, err := CreateClient(cmd)
if err != nil {
return err
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

114
enterprise/cli/licenses.go Normal file
View File

@ -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 [-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
}
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,
Validate: validJWT,
})
if err != nil {
return err
}
case filename != "" && license == "":
var r io.Reader
if filename == "-" {
r = cmd.InOrStdin()
} else {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
r = f
}
lb, err := io.ReadAll(r)
if err != nil {
return err
}
license = string(lb)
}
license = strings.Trim(license, " \n")
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())
enc.SetIndent("", " ")
return enc.Encode(licResp)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "License with ID %d added\n", licResp.ID)
return nil
},
}
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(s) {
return nil
}
return xerrors.New("Invalid license")
}

View File

@ -0,0 +1,192 @@
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) {
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.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.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.ExecuteContext(ctx)
}()
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()
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)
require.NoError(t, err)
cmd := setupFakeLicenseServerTest(t, "license", "add", "-f", filename)
pty := attachPty(t, cmd)
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
require.NoError(t, <-errC)
pty.ExpectMatch("License with ID 1 added")
})
t.Run("StdIn", func(t *testing.T) {
t.Parallel()
cmd := setupFakeLicenseServerTest(t, "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.ExecuteContext(ctx)
}()
_, 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()
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.ExecuteContext(ctx)
}()
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)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
errC := make(chan error)
go func() {
errC <- cmd.ExecuteContext(ctx)
}()
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, args ...string) *cobra.Command {
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)
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 {
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)
}

View File

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