mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08: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:
+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.
|
||||
Reference in New Issue
Block a user