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.
This commit is contained in:
dylanhuff-at-coder
2026-04-16 09:44:34 -07:00
committed by GitHub
parent 383b10f71e
commit 7270e01390
18 changed files with 1569 additions and 7 deletions
+3 -7
View File
@@ -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")
}
})
+437
View File
@@ -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 <name>",
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 <name>",
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 <name>",
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
}
+125
View File
@@ -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
}
+589
View File
@@ -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())
})
}
+1
View File
@@ -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
+39
View File
@@ -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.
+27
View File
@@ -0,0 +1,27 @@
coder v0.0.0-devel
USAGE:
coder secret create [flags] <name>
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.
+15
View File
@@ -0,0 +1,15 @@
coder v0.0.0-devel
USAGE:
coder secret delete [flags] <name>
Delete a secret
Aliases: remove, rm
OPTIONS:
-y, --yes bool
Bypass confirmation prompts.
———
Run `coder --help` for a list of global options.
+20
View File
@@ -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.
+29
View File
@@ -0,0 +1,29 @@
coder v0.0.0-devel
USAGE:
coder secret update [flags] <name>
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.
+46
View File
@@ -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
+25
View File
@@ -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",
+1
View File
@@ -35,6 +35,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>secret</code>](./secret.md) | Manage secrets |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>task</code>](./task.md) | Manage tasks |
| [<code>templates</code>](./templates.md) | Manage templates |
+47
View File
@@ -0,0 +1,47 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# 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 |
|-------------------------------------------|-----------------------------------|
| [<code>create</code>](./secret_create.md) | Create a secret |
| [<code>update</code>](./secret_update.md) | Update a secret |
| [<code>list</code>](./secret_list.md) | List secrets, or show one by name |
| [<code>delete</code>](./secret_delete.md) | Delete a secret |
+50
View File
@@ -0,0 +1,50 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret create
Create a secret
## Usage
```console
coder secret create [flags] <name>
```
## Description
```console
Provide the secret value with --value or non-interactive stdin (pipe or redirect).
```
## Options
### --value
| | |
|------|---------------------|
| Type | <code>string</code> |
Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).
### --description
| | |
|------|---------------------|
| Type | <code>string</code> |
Set the secret description.
### --env
| | |
|------|---------------------|
| Type | <code>string</code> |
Name of the workspace environment variable that this secret will set.
### --file
| | |
|------|---------------------|
| Type | <code>string</code> |
Workspace file path where this secret will be written. Must start with ~/ or /.
+25
View File
@@ -0,0 +1,25 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret delete
Delete a secret
Aliases:
* remove
* rm
## Usage
```console
coder secret delete [flags] <name>
```
## Options
### -y, --yes
| | |
|------|-------------------|
| Type | <code>bool</code> |
Bypass confirmation prompts.
+40
View File
@@ -0,0 +1,40 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# 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 | <code>[created\|name\|updated\|env\|file\|description]</code> |
| Default | <code>name,created,updated,env,file,description</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.
+50
View File
@@ -0,0 +1,50 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# secret update
Update a secret
## Usage
```console
coder secret update [flags] <name>
```
## 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 | <code>string</code> |
Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).
### --description
| | |
|------|---------------------|
| Type | <code>string</code> |
Update the secret description. Pass an empty string to clear it.
### --env
| | |
|------|---------------------|
| Type | <code>string</code> |
Name of the workspace environment variable that this secret will set. Pass an empty string to clear it.
### --file
| | |
|------|---------------------|
| Type | <code>string</code> |
Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.