mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
committed by
GitHub
parent
383b10f71e
commit
7270e01390
+3
-7
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
Vendored
+1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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 |
|
||||
|
||||
Generated
+47
@@ -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 |
|
||||
Generated
+50
@@ -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 /.
|
||||
Generated
+25
@@ -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.
|
||||
Generated
+40
@@ -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.
|
||||
Generated
+50
@@ -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.
|
||||
Reference in New Issue
Block a user