From 7270e01390e2b31e7597fb89be8d94186285a23e Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Thu, 16 Apr 2026 09:44:34 -0700 Subject: [PATCH] feat: add CLI support for user secrets (#24270) Adds a coder secret command group for managing user secrets from the CLI, with create, update, list, and delete subcommands backed by the existing user secret API. This branch adds CLI test coverage and refreshes the generated help output and CLI reference docs for the new command group. --- cli/root.go | 10 +- cli/secret.go | 437 +++++++++++++ cli/secret_internal_test.go | 125 ++++ cli/secret_test.go | 589 ++++++++++++++++++ cli/testdata/coder_--help.golden | 1 + cli/testdata/coder_secret_--help.golden | 39 ++ .../coder_secret_create_--help.golden | 27 + .../coder_secret_delete_--help.golden | 15 + cli/testdata/coder_secret_list_--help.golden | 20 + .../coder_secret_update_--help.golden | 29 + docs/admin/security/secrets.md | 46 ++ docs/manifest.json | 25 + docs/reference/cli/index.md | 1 + docs/reference/cli/secret.md | 47 ++ docs/reference/cli/secret_create.md | 50 ++ docs/reference/cli/secret_delete.md | 25 + docs/reference/cli/secret_list.md | 40 ++ docs/reference/cli/secret_update.md | 50 ++ 18 files changed, 1569 insertions(+), 7 deletions(-) create mode 100644 cli/secret.go create mode 100644 cli/secret_internal_test.go create mode 100644 cli/secret_test.go create mode 100644 cli/testdata/coder_secret_--help.golden create mode 100644 cli/testdata/coder_secret_create_--help.golden create mode 100644 cli/testdata/coder_secret_delete_--help.golden create mode 100644 cli/testdata/coder_secret_list_--help.golden create mode 100644 cli/testdata/coder_secret_update_--help.golden create mode 100644 docs/reference/cli/secret.md create mode 100644 docs/reference/cli/secret_create.md create mode 100644 docs/reference/cli/secret_delete.md create mode 100644 docs/reference/cli/secret_list.md create mode 100644 docs/reference/cli/secret_update.md diff --git a/cli/root.go b/cli/root.go index 8af6b3c96a..ed97bb88f5 100644 --- a/cli/root.go +++ b/cli/root.go @@ -104,6 +104,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.portForward(), r.publickey(), r.resetPassword(), + r.secrets(), r.sharing(), r.state(), r.tasksCommand(), @@ -319,14 +320,9 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err cmd.Walk(func(cmd *serpent.Command) { // TODO: we should really be consistent about naming. if cmd.Name() == "delete" || cmd.Name() == "remove" { - if slices.Contains(cmd.Aliases, "rm") { - merr = errors.Join( - merr, - xerrors.Errorf("command %q shouldn't have alias %q since it's added automatically", cmd.FullName(), "rm"), - ) - return + if !slices.Contains(cmd.Aliases, "rm") { + cmd.Aliases = append(cmd.Aliases, "rm") } - cmd.Aliases = append(cmd.Aliases, "rm") } }) diff --git a/cli/secret.go b/cli/secret.go new file mode 100644 index 0000000000..2fb6d75c4f --- /dev/null +++ b/cli/secret.go @@ -0,0 +1,437 @@ +package cli + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/dustin/go-humanize" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) secrets() *serpent.Command { + cmd := &serpent.Command{ + Use: "secret", + Aliases: []string{"secrets"}, + Short: "Manage secrets", + Long: FormatExamples( + Example{ + Description: "Create a secret", + Command: "printf %s \"$MYCLI_API_KEY\" | coder secret create api-key --description \"API key for workspace tools\" --env API_KEY --file \"~/.api-key\"", + }, + Example{ + Description: "Update a secret", + Command: "echo -n \"$NEW_SECRET_VALUE\" | coder secret update api-key --description \"Rotated API key\" --env API_KEY --file \"~/.api-key\"", + }, + Example{ + Description: "List your secrets", + Command: "coder secret list", + }, + Example{ + Description: "Show a specific secret", + Command: "coder secret list api-key", + }, + Example{ + Description: "Delete a secret", + Command: "coder secret delete api-key", + }, + ), + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.secretCreate(), + r.secretUpdate(), + r.secretList(), + r.secretDelete(), + }, + } + + return cmd +} + +func (r *RootCmd) secretCreate() *serpent.Command { + var ( + value string + description string + env string + file string + ) + + cmd := &serpent.Command{ + Use: "create ", + Short: "Create a secret", + Long: "Provide the secret value with --value or non-interactive stdin (pipe or redirect).", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "value", + Flag: "value", + Description: "Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).", + Value: serpent.StringOf(&value), + }, + { + Name: "description", + Flag: "description", + Description: "Set the secret description.", + Value: serpent.StringOf(&description), + }, + { + Name: "env", + Flag: "env", + Description: "Name of the workspace environment variable that this secret will set.", + Value: serpent.StringOf(&env), + }, + { + Name: "file", + Flag: "file", + Description: "Workspace file path where this secret will be written. Must start with ~/ or /.", + Value: serpent.StringOf(&file), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + resolvedValue, ok, err := secretValue(inv, value) + if err != nil { + return err + } + if !ok { + if isTTYIn(inv) { + return xerrors.New("secret value must be provided with --value or stdin via pipe or redirect") + } + return xerrors.New("secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") + } + + secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: inv.Args[0], + Value: resolvedValue, + Description: description, + EnvName: env, + FilePath: file, + }) + if err != nil { + return xerrors.Errorf("create secret %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Created secret %s.\n", cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + +func (r *RootCmd) secretUpdate() *serpent.Command { + var ( + value string + description string + env string + file string + ) + + cmd := &serpent.Command{ + Use: "update ", + Short: "Update a secret", + Long: strings.Join([]string{ + "At least one of --value, --description, --env, or --file must be specified.", + "Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).", + }, " "), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "value", + Flag: "value", + Description: "Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).", + Value: serpent.StringOf(&value), + }, + { + Name: "description", + Flag: "description", + Description: "Update the secret description. Pass an empty string to clear it.", + Value: serpent.StringOf(&description), + }, + { + Name: "env", + Flag: "env", + Description: "Name of the workspace environment variable that this secret will set. Pass an empty string to clear it.", + Value: serpent.StringOf(&env), + }, + { + Name: "file", + Flag: "file", + Description: "Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.", + Value: serpent.StringOf(&file), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + req := codersdk.UpdateUserSecretRequest{} + resolvedValue, ok, err := secretValue(inv, value) + if err != nil { + return err + } + if ok { + req.Value = &resolvedValue + } + if userSetOption(inv, "description") { + req.Description = &description + } + if userSetOption(inv, "env") { + req.EnvName = &env + } + if userSetOption(inv, "file") { + req.FilePath = &file + } + + secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req) + if err != nil { + return xerrors.Errorf("update secret %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Updated secret %s.\n", cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + +func secretValue(inv *serpent.Invocation, value string) (string, bool, error) { + valueProvided := userSetOption(inv, "value") + stdinValue, stdinProvided, err := readInvocationStdin(inv) + if err != nil { + return "", false, err + } + + sourceNames := make([]string, 0, 2) + if valueProvided { + sourceNames = append(sourceNames, "--value") + } + if stdinProvided { + sourceNames = append(sourceNames, "stdin") + } + if len(sourceNames) > 1 { + return "", false, xerrors.Errorf("secret value may be provided by only one source, got %s", strings.Join(sourceNames, ", ")) + } + + if valueProvided { + return value, true, nil + } + + if stdinProvided { + warnSuspiciousTrailingNewline(inv.Stderr, stdinValue) + return stdinValue, true, nil + } + + return "", false, nil +} + +func readInvocationStdin(inv *serpent.Invocation) (string, bool, error) { + if isTTYIn(inv) { + return "", false, nil + } + + bytes, err := io.ReadAll(inv.Stdin) + if err != nil { + return "", false, xerrors.Errorf("reading stdin: %w", err) + } + if len(bytes) == 0 { + return "", false, nil + } + + return string(bytes), true, nil +} + +// Shell helpers like echo usually append a line ending to piped stdin. We +// treat a single trailing LF or CRLF as suspicious, but avoid flagging values +// that are clearly multiline. +func hasSuspiciousTrailingNewline(value string) bool { + switch { + case strings.HasSuffix(value, "\r\n"): + trimmed := strings.TrimSuffix(value, "\r\n") + return !strings.ContainsAny(trimmed, "\r\n") + case strings.HasSuffix(value, "\n"): + trimmed := strings.TrimSuffix(value, "\n") + return !strings.ContainsAny(trimmed, "\r\n") + case strings.HasSuffix(value, "\r"): + trimmed := strings.TrimSuffix(value, "\r") + return !strings.ContainsAny(trimmed, "\r\n") + default: + return false + } +} + +func warnSuspiciousTrailingNewline(w io.Writer, value string) { + if !hasSuspiciousTrailingNewline(value) { + return + } + + cliui.Warn(w, "secret value from stdin ends with a trailing newline") +} + +type secretListRow struct { + codersdk.UserSecret `table:"-"` + + Created string `json:"-" table:"created"` + Name string `json:"-" table:"name,default_sort"` + Updated string `json:"-" table:"updated"` + Env string `json:"-" table:"env"` + File string `json:"-" table:"file"` + Description string `json:"-" table:"description"` +} + +func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow { + return secretListRow{ + UserSecret: secret, + Created: humanize.Time(secret.CreatedAt), + Name: secret.Name, + Updated: humanize.Time(secret.UpdatedAt), + Env: secret.EnvName, + File: secret.FilePath, + Description: secret.Description, + } +} + +func (r *RootCmd) secretList() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat( + []secretListRow{}, + []string{"name", "created", "updated", "env", "file", "description"}, + ), + func(data any) (any, error) { + switch rows := data.(type) { + case []secretListRow: + return rows, nil + case secretListRow: + return []secretListRow{rows}, nil + default: + return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data) + } + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + switch rows := data.(type) { + case []secretListRow: + secrets := make([]codersdk.UserSecret, len(rows)) + for i := range rows { + secrets[i] = rows[i].UserSecret + } + return secrets, nil + case secretListRow: + return []codersdk.UserSecret{rows.UserSecret}, nil + default: + return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data) + } + }, + ), + ) + + cmd := &serpent.Command{ + Use: "list [name]", + Aliases: []string{"ls"}, + Short: "List secrets, or show one by name", + Long: "Secret values are omitted from the output.", + Middleware: serpent.RequireRangeArgs(0, 1), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + var data any + if len(inv.Args) == 1 { + secret, err := client.UserSecretByName(inv.Context(), codersdk.Me, inv.Args[0]) + if err != nil { + return xerrors.Errorf("get secret %q: %w", inv.Args[0], err) + } + data = secretListRowFromSecret(secret) + } else { + secrets, err := client.UserSecrets(inv.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("list secrets: %w", err) + } + + rows := make([]secretListRow, len(secrets)) + for i := range secrets { + rows[i] = secretListRowFromSecret(secrets[i]) + } + data = rows + } + + out, err := formatter.Format(inv.Context(), data) + if err != nil { + return xerrors.Errorf("format secrets: %w", err) + } + if out == "" { + cliui.Infof(inv.Stderr, "No secrets found.") + return nil + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (r *RootCmd) secretDelete() *serpent.Command { + cmd := &serpent.Command{ + Use: "delete ", + Aliases: []string{"remove", "rm"}, + Short: "Delete a secret", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + name := inv.Args[0] + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Delete secret %s?", pretty.Sprint(cliui.DefaultStyles.Code, name)), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + if err = client.DeleteUserSecret(inv.Context(), codersdk.Me, name); err != nil { + return xerrors.Errorf("delete secret %q: %w", name, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Deleted secret %s at %s.\n", cliui.Keyword(name), cliui.Timestamp(time.Now())) + return nil + }, + } + + return cmd +} diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go new file mode 100644 index 0000000000..70b4597feb --- /dev/null +++ b/cli/secret_internal_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/coder/serpent" +) + +func TestHasSuspiciousTrailingNewline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + suspicious bool + }{ + {name: "NoTrailingNewline", input: "token", suspicious: false}, + {name: "SingleTrailingLF", input: "token\n", suspicious: true}, + {name: "SingleTrailingCRLF", input: "token\r\n", suspicious: true}, + {name: "SingleTrailingCR", input: "token\r", suspicious: true}, + {name: "MultilineValue", input: "line1\nline2\n", suspicious: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.suspicious, hasSuspiciousTrailingNewline(tt.input)) + }) + } +} + +func TestReadInvocationStdin(t *testing.T) { + t.Parallel() + + t.Run("ZeroBytesRead", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + got, provided, err := readInvocationStdin(inv) + require.NoError(t, err) + require.False(t, provided) + require.Empty(t, got) + }) + + t.Run("StringRead", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader("token"), nil) + + got, provided, err := readInvocationStdin(inv) + require.NoError(t, err) + require.True(t, provided) + require.Equal(t, "token", got) + }) +} + +func TestTrailingNewlineWarnings(t *testing.T) { + t.Parallel() + + t.Run("WarnSuspiciousValue", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSuspiciousTrailingNewline(&stderr, "token\n") + require.Contains(t, stderr.String(), "secret value from stdin ends with a trailing newline") + }) + + t.Run("DoesNotWarnForMultiline", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSuspiciousTrailingNewline(&stderr, "line1\nline2\n") + require.Empty(t, stderr.String()) + }) + + t.Run("SecretValueWarnsAndPreservesValue", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + inv := newSecretTestInvocation(t, strings.NewReader("token\n"), &stderr) + + got, ok, err := secretValue(inv, "") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "token\n", got) + require.Contains(t, stderr.String(), "secret value from stdin ends with a trailing newline") + }) + + t.Run("SecretValueDoesNotWarnForMultiline", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + inv := newSecretTestInvocation(t, strings.NewReader("line1\nline2\n"), &stderr) + + got, ok, err := secretValue(inv, "") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "line1\nline2\n", got) + require.Empty(t, stderr.String()) + }) +} + +func newSecretTestInvocation(t *testing.T, stdin io.Reader, stderr io.Writer) *serpent.Invocation { + t.Helper() + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + if stderr == nil { + stderr = io.Discard + } + inv := (&serpent.Invocation{ + Stdin: stdin, + Stderr: stderr, + Command: &serpent.Command{}, + Args: []string{"api-key"}, + }).WithTestParsedFlags(t, flags) + return inv +} diff --git a/cli/secret_test.go b/cli/secret_test.go new file mode 100644 index 0000000000..3cbb6b89b8 --- /dev/null +++ b/cli/secret_test.go @@ -0,0 +1,589 @@ +package cli_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestSecretCreate(t *testing.T) { + t.Parallel() + + t.Run("MissingValue", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") + }) + + t.Run("MissingValueOnTTY", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "--force-tty", "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided with --value or stdin via pipe or redirect") + }) + + t.Run("SuccessWithValueFlag", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--value", "super-secret-value", + "--description", "API key for workspace tools", + "--env", "API_KEY", + "--file", "~/.api-key", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "api-key") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "api-key") + require.NoError(t, err) + require.Equal(t, "api-key", secret.Name) + require.Equal(t, "API key for workspace tools", secret.Description) + require.Equal(t, "API_KEY", secret.EnvName) + require.Equal(t, "~/.api-key", secret.FilePath) + }) + + t.Run("ValueFlagConflictsWithStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--value", "super-secret-value", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("different-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value may be provided by only one source, got --value, stdin") + }) + + t.Run("SuccessWithStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--description", "API key for workspace tools", + "--env", "API_KEY", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("super-secret-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "api-key") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "api-key") + require.NoError(t, err) + require.Equal(t, "api-key", secret.Name) + require.Equal(t, "API key for workspace tools", secret.Description) + require.Equal(t, "API_KEY", secret.EnvName) + }) + + t.Run("StdinTrailingNewlineWarnsAndPreservesValue", func(t *testing.T) { + t.Parallel() + + ownerClient, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--description", "API key for workspace tools", + "--env", "API_KEY", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("super-secret-value\n") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "api-key") + require.Contains(t, output.Stderr(), "secret value from stdin ends with a trailing newline") + + secret, err := db.GetUserSecretByUserIDAndName( + dbauthz.AsSystemRestricted(ctx), + database.GetUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: "api-key", + }, + ) + require.NoError(t, err) + require.Equal(t, "super-secret-value\n", secret.Value) + }) + + t.Run("EmptyStdinIsNotProvided", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") + }) +} + +func TestSecretUpdate(t *testing.T) { + t.Parallel() + + t.Run("ServerValidationError", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "update", "my-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "At least one field must be provided") + }) + + t.Run("AllowsClearingFields", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + Description: "original description", + EnvName: "MY_SECRET", + FilePath: "~/.my-secret", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "rotated-secret", + "--description", "", + "--env", "", + "--file", "", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "my-secret") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "my-secret") + require.NoError(t, err) + require.Equal(t, "", secret.Description) + require.Equal(t, "", secret.EnvName) + require.Equal(t, "", secret.FilePath) + }) + + t.Run("UpdatesValueFromEmptyFlag", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "my-secret") + }) + + t.Run("UpdatesValueFromStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "update", "my-secret") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("rotated-secret") + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "my-secret") + }) + + t.Run("ValueFlagConflictsWithStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "rotated-secret", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("different-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value may be provided by only one source, got --value, stdin") + }) +} + +func TestSecretList(t *testing.T) { + t.Parallel() + + t.Run("TableOutput", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "tool-config", + Value: "config-value", + Description: "Tool configuration", + FilePath: "~/.config/tool/config.json", + }) + require.NoError(t, err) + _, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + out := output.Stdout() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "CREATED") + assert.Contains(t, out, "UPDATED") + assert.Contains(t, out, "ENV") + assert.Contains(t, out, "FILE") + assert.Contains(t, out, "DESCRIPTION") + assert.Contains(t, out, "service-token") + assert.Contains(t, out, "SERVICE_TOKEN") + assert.Contains(t, out, "tool-config") + assert.Contains(t, out, "~/.config/tool/config.json") + }) + + t.Run("JSONOutput", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "--output=json") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + var got []codersdk.UserSecret + require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got)) + require.Len(t, got, 1) + require.Equal(t, created, got[0]) + }) + + t.Run("SingleSecretTableOutput", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "tool-config", + Value: "config-value", + Description: "Tool configuration", + FilePath: "~/.config/tool/config.json", + }) + require.NoError(t, err) + _, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "service-token") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + out := output.Stdout() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "CREATED") + assert.Contains(t, out, "UPDATED") + assert.Contains(t, out, "ENV") + assert.Contains(t, out, "FILE") + assert.Contains(t, out, "DESCRIPTION") + assert.Contains(t, out, "service-token") + assert.Contains(t, out, "SERVICE_TOKEN") + assert.NotContains(t, out, "tool-config") + assert.NotContains(t, out, "~/.config/tool/config.json") + }) + + t.Run("SingleSecretJSONOutput", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "service-token", "--output=json") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + var got []codersdk.UserSecret + require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got)) + require.Len(t, got, 1) + require.Equal(t, created, got[0]) + }) + + t.Run("EmptyState", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "list") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + assert.Contains(t, output.Stderr(), "No secrets found.") + }) +} + +func TestSecretDelete(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "delete", "service-token") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + waiter := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Delete secret") + pty.ExpectMatchContext(ctx, "service-token") + pty.WriteLine("yes") + pty.ExpectMatchContext(ctx, "Deleted secret") + + require.NoError(t, waiter.Wait()) + + _, err = client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("YesSkipsPrompt", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "delete", "service-token", "--yes") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "Deleted secret") + require.NotContains(t, output.Stdout(), "Delete secret") + require.Empty(t, output.Stderr()) + + _, err = client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "delete", "missing-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + waiter := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Delete secret") + pty.ExpectMatchContext(ctx, "missing-secret") + pty.WriteLine("yes") + + err := waiter.Wait() + require.ErrorContains(t, err, `delete secret "missing-secret"`) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index ea4ecdc8c6..5fa43d2ebf 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -43,6 +43,7 @@ SUBCOMMANDS: password restart Restart a workspace schedule Schedule automated start and stop times for workspaces + secret Manage secrets server Start a Coder server show Display details of a workspace's resources and agents speedtest Run upload and download tests from your machine to a diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden new file mode 100644 index 0000000000..45447c96e3 --- /dev/null +++ b/cli/testdata/coder_secret_--help.golden @@ -0,0 +1,39 @@ +coder v0.0.0-devel + +USAGE: + coder secret + + Manage secrets + + Aliases: secrets + + - Create a secret: + + $ printf %s "$MYCLI_API_KEY" | coder secret create api-key --description + "API key for workspace tools" --env API_KEY --file "~/.api-key" + + - Update a secret: + + $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description + "Rotated API key" --env API_KEY --file "~/.api-key" + + - List your secrets: + + $ coder secret list + + - Show a specific secret: + + $ coder secret list api-key + + - Delete a secret: + + $ coder secret delete api-key + +SUBCOMMANDS: + create Create a secret + delete Delete a secret + list List secrets, or show one by name + update Update a secret + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_create_--help.golden b/cli/testdata/coder_secret_create_--help.golden new file mode 100644 index 0000000000..0a5d53d119 --- /dev/null +++ b/cli/testdata/coder_secret_create_--help.golden @@ -0,0 +1,27 @@ +coder v0.0.0-devel + +USAGE: + coder secret create [flags] + + Create a secret + + Provide the secret value with --value or non-interactive stdin (pipe or + redirect). + +OPTIONS: + --description string + Set the secret description. + + --env string + Name of the workspace environment variable that this secret will set. + + --file string + Workspace file path where this secret will be written. Must start with + ~/ or /. + + --value string + Set the secret value. For security reasons, prefer non-interactive + stdin (pipe or redirect). + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_delete_--help.golden b/cli/testdata/coder_secret_delete_--help.golden new file mode 100644 index 0000000000..a65cf3bb38 --- /dev/null +++ b/cli/testdata/coder_secret_delete_--help.golden @@ -0,0 +1,15 @@ +coder v0.0.0-devel + +USAGE: + coder secret delete [flags] + + Delete a secret + + Aliases: remove, rm + +OPTIONS: + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden new file mode 100644 index 0000000000..803968373c --- /dev/null +++ b/cli/testdata/coder_secret_list_--help.golden @@ -0,0 +1,20 @@ +coder v0.0.0-devel + +USAGE: + coder secret list [flags] [name] + + List secrets, or show one by name + + Aliases: ls + + Secret values are omitted from the output. + +OPTIONS: + -c, --column [created|name|updated|env|file|description] (default: name,created,updated,env,file,description) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_update_--help.golden b/cli/testdata/coder_secret_update_--help.golden new file mode 100644 index 0000000000..6864ca22da --- /dev/null +++ b/cli/testdata/coder_secret_update_--help.golden @@ -0,0 +1,29 @@ +coder v0.0.0-devel + +USAGE: + coder secret update [flags] + + Update a secret + + At least one of --value, --description, --env, or --file must be specified. + Provide the secret value by at most one of --value or non-interactive stdin + (pipe or redirect). + +OPTIONS: + --description string + Update the secret description. Pass an empty string to clear it. + + --env string + Name of the workspace environment variable that this secret will set. + Pass an empty string to clear it. + + --file string + Workspace file path where this secret will be written. Must start with + ~/ or /. Pass an empty string to clear it. + + --value string + Update the secret value. For security reasons, prefer non-interactive + stdin (pipe or redirect). + +——— +Run `coder --help` for a list of global options. diff --git a/docs/admin/security/secrets.md b/docs/admin/security/secrets.md index 25ff1a6467..ed658b0475 100644 --- a/docs/admin/security/secrets.md +++ b/docs/admin/security/secrets.md @@ -42,6 +42,52 @@ Users can view their public key in their account settings: > SSH keys are never stored in Coder workspaces, and are fetched only when > SSH is invoked. The keys are held in-memory and never written to disk. +## User Secrets + +User secrets let each user store their own secret values in Coder and make +them available in workspaces without adding those values to template code. +They are a good fit for per-user credentials such as API keys, cloud +credentials, or other values that should follow a user across workspaces. + +Use the CLI to create and manage user secrets: + +```sh +# Create a secret from stdin and inject it into workspaces as an environment +# variable. +printf %s "$API_KEY" | coder secret create api-key \ + --description "API key for workspace tools" \ + --env API_KEY + +# Create a secret from stdin and inject it into a file in your workspace. +printf %s "$TOOL_CONFIG_CONTENTS" | coder secret create tool-config \ + --description "Tool configuration" \ + --file ~/.config/tool/config.json + +# List all of your secrets. +coder secret list + +# Show a single secret by name. +coder secret list api-key + +# Delete a secret you no longer need. +coder secret delete api-key +``` + +Use `--env` to inject a secret into your workspaces as an environment +variable. Use `--file` to inject it as a file in the workspace. File +paths must start with `~/` or `/`. Provide a secret value with `--value`, +or non-interactive stdin (pipe or redirect). Stdin is read verbatim. This +means `echo "$API_KEY" | ...` usually adds a trailing newline to the stored +value. Prefer `printf %s "$API_KEY" | ...` or `echo -n "$API_KEY" | ...` +when you do not want that newline. + +You can update a secret later with `coder secret update`, including rotating +the value or clearing an injection target by passing an empty string. Use +`coder secret delete` to remove a secret entirely. The secret value itself is +never returned by the API or CLI list output. For full command details, see +[`coder secret`](../../reference/cli/secret.md) and the +[Secrets API reference](../../reference/api/secrets.md). + ## Dynamic Secrets Dynamic secrets are attached to the workspace lifecycle and automatically diff --git a/docs/manifest.json b/docs/manifest.json index 30b4795a21..e7b20d231f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2016,6 +2016,31 @@ "description": "Edit workspace stop schedule", "path": "reference/cli/schedule_stop.md" }, + { + "title": "secret", + "description": "Manage secrets", + "path": "reference/cli/secret.md" + }, + { + "title": "secret create", + "description": "Create a secret", + "path": "reference/cli/secret_create.md" + }, + { + "title": "secret update", + "description": "Update a secret", + "path": "reference/cli/secret_update.md" + }, + { + "title": "secret list", + "description": "List secrets, or show one by name", + "path": "reference/cli/secret_list.md" + }, + { + "title": "secret delete", + "description": "Delete a secret", + "path": "reference/cli/secret_delete.md" + }, { "title": "server", "description": "Start a Coder server", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index de3a5c2cb8..fc242c85b6 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -35,6 +35,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | | [publickey](./publickey.md) | Output your Coder public key used for Git operations | | [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [secret](./secret.md) | Manage secrets | | [state](./state.md) | Manually manage Terraform state to fix broken workspaces | | [task](./task.md) | Manage tasks | | [templates](./templates.md) | Manage templates | diff --git a/docs/reference/cli/secret.md b/docs/reference/cli/secret.md new file mode 100644 index 0000000000..7d022f3dfd --- /dev/null +++ b/docs/reference/cli/secret.md @@ -0,0 +1,47 @@ + +# secret + +Manage secrets + +Aliases: + +* secrets + +## Usage + +```console +coder secret +``` + +## Description + +```console + - Create a secret: + + $ printf %s "$MYCLI_API_KEY" | coder secret create api-key --description "API key for workspace tools" --env API_KEY --file "~/.api-key" + + - Update a secret: + + $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + + - List your secrets: + + $ coder secret list + + - Show a specific secret: + + $ coder secret list api-key + + - Delete a secret: + + $ coder secret delete api-key +``` + +## Subcommands + +| Name | Purpose | +|-------------------------------------------|-----------------------------------| +| [create](./secret_create.md) | Create a secret | +| [update](./secret_update.md) | Update a secret | +| [list](./secret_list.md) | List secrets, or show one by name | +| [delete](./secret_delete.md) | Delete a secret | diff --git a/docs/reference/cli/secret_create.md b/docs/reference/cli/secret_create.md new file mode 100644 index 0000000000..df9086f693 --- /dev/null +++ b/docs/reference/cli/secret_create.md @@ -0,0 +1,50 @@ + +# secret create + +Create a secret + +## Usage + +```console +coder secret create [flags] +``` + +## Description + +```console +Provide the secret value with --value or non-interactive stdin (pipe or redirect). +``` + +## Options + +### --value + +| | | +|------|---------------------| +| Type | string | + +Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect). + +### --description + +| | | +|------|---------------------| +| Type | string | + +Set the secret description. + +### --env + +| | | +|------|---------------------| +| Type | string | + +Name of the workspace environment variable that this secret will set. + +### --file + +| | | +|------|---------------------| +| Type | string | + +Workspace file path where this secret will be written. Must start with ~/ or /. diff --git a/docs/reference/cli/secret_delete.md b/docs/reference/cli/secret_delete.md new file mode 100644 index 0000000000..bc493d907c --- /dev/null +++ b/docs/reference/cli/secret_delete.md @@ -0,0 +1,25 @@ + +# secret delete + +Delete a secret + +Aliases: + +* remove +* rm + +## Usage + +```console +coder secret delete [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts. diff --git a/docs/reference/cli/secret_list.md b/docs/reference/cli/secret_list.md new file mode 100644 index 0000000000..9bffcd6a49 --- /dev/null +++ b/docs/reference/cli/secret_list.md @@ -0,0 +1,40 @@ + +# secret list + +List secrets, or show one by name + +Aliases: + +* ls + +## Usage + +```console +coder secret list [flags] [name] +``` + +## Description + +```console +Secret values are omitted from the output. +``` + +## Options + +### -c, --column + +| | | +|---------|---------------------------------------------------------------| +| Type | [created\|name\|updated\|env\|file\|description] | +| Default | name,created,updated,env,file,description | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/secret_update.md b/docs/reference/cli/secret_update.md new file mode 100644 index 0000000000..83b03b2b3a --- /dev/null +++ b/docs/reference/cli/secret_update.md @@ -0,0 +1,50 @@ + +# secret update + +Update a secret + +## Usage + +```console +coder secret update [flags] +``` + +## Description + +```console +At least one of --value, --description, --env, or --file must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect). +``` + +## Options + +### --value + +| | | +|------|---------------------| +| Type | string | + +Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect). + +### --description + +| | | +|------|---------------------| +| Type | string | + +Update the secret description. Pass an empty string to clear it. + +### --env + +| | | +|------|---------------------| +| Type | string | + +Name of the workspace environment variable that this secret will set. Pass an empty string to clear it. + +### --file + +| | | +|------|---------------------| +| Type | string | + +Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.