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.