Compare commits
7 Commits
main
...
dylan/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a2c3644e | ||
|
|
cdb1499631 | ||
|
|
9c52b0b862 | ||
|
|
0f4a784b62 | ||
|
|
4d1b687865 | ||
|
|
3b9cf94b63 | ||
|
|
ee4dccb898 |
@@ -103,6 +103,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
|
||||
r.portForward(),
|
||||
r.publickey(),
|
||||
r.resetPassword(),
|
||||
r.secrets(),
|
||||
r.sharing(),
|
||||
r.state(),
|
||||
r.tasksCommand(),
|
||||
|
||||
340
cli/secret.go
Normal file
340
cli/secret.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 personal secrets",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Create a secret",
|
||||
Command: "coder secret create openai-key --value \"$SECRET_VALUE\" --description \"Personal OPENAI_API key\" --inject-env OPEN_AI_KEY --inject-file \"~/.openai-key\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Update a secret",
|
||||
Command: "coder secret update openai-key --value \"$NEW_SECRET_VALUE\" --description \"Updated description\" --inject-env NEW_ENV_NAME --inject-file \"~/.new-path\"",
|
||||
},
|
||||
Example{
|
||||
Description: "List your secrets",
|
||||
Command: "coder secret list",
|
||||
},
|
||||
Example{
|
||||
Description: "Show a specific secret",
|
||||
Command: "coder secret list openai-key",
|
||||
},
|
||||
Example{
|
||||
Description: "Delete a secret",
|
||||
Command: "coder secret delete openai-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
|
||||
injectEnv string
|
||||
injectFile string
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a secret",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "value",
|
||||
Flag: "value",
|
||||
Description: "Set the secret value. This flag is required.",
|
||||
Value: serpent.StringOf(&value),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "description",
|
||||
Flag: "description",
|
||||
Description: "Set the secret description.",
|
||||
Value: serpent.StringOf(&description),
|
||||
},
|
||||
{
|
||||
Name: "inject-env",
|
||||
Flag: "inject-env",
|
||||
Description: "Inject the secret into workspaces as an environment variable.",
|
||||
Value: serpent.StringOf(&injectEnv),
|
||||
},
|
||||
{
|
||||
Name: "inject-file",
|
||||
Flag: "inject-file",
|
||||
Description: "Inject the secret into workspaces as a file.",
|
||||
Value: serpent.StringOf(&injectFile),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: inv.Args[0],
|
||||
Value: value,
|
||||
Description: description,
|
||||
EnvName: injectEnv,
|
||||
FilePath: injectFile,
|
||||
})
|
||||
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
|
||||
injectEnv string
|
||||
injectFile string
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "update <name>",
|
||||
Short: "Update a secret",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "value",
|
||||
Flag: "value",
|
||||
Description: "Update the secret value.",
|
||||
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: "inject-env",
|
||||
Flag: "inject-env",
|
||||
Description: "Update the environment variable injection target. Pass an empty string to clear it.",
|
||||
Value: serpent.StringOf(&injectEnv),
|
||||
},
|
||||
{
|
||||
Name: "inject-file",
|
||||
Flag: "inject-file",
|
||||
Description: "Update the file injection target. Pass an empty string to clear it.",
|
||||
Value: serpent.StringOf(&injectFile),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := codersdk.UpdateUserSecretRequest{}
|
||||
if userSetOption(inv, "value") {
|
||||
req.Value = &value
|
||||
}
|
||||
if userSetOption(inv, "description") {
|
||||
req.Description = &description
|
||||
}
|
||||
if userSetOption(inv, "inject-env") {
|
||||
req.EnvName = &injectEnv
|
||||
}
|
||||
if userSetOption(inv, "inject-file") {
|
||||
req.FilePath = &injectFile
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type secretListRow struct {
|
||||
codersdk.UserSecret `table:"-"`
|
||||
|
||||
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,
|
||||
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", "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",
|
||||
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"},
|
||||
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
|
||||
}
|
||||
356
cli/secret_test.go
Normal file
356
cli/secret_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"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/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", "openai-key")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "Missing values for the required flags: value")
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(
|
||||
t,
|
||||
"secret",
|
||||
"create",
|
||||
"openai-key",
|
||||
"--value", "super-secret-value",
|
||||
"--description", "Personal OPENAI_API key",
|
||||
"--inject-env", "OPEN_AI_KEY",
|
||||
"--inject-file", "~/.openai-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(), "openai-key")
|
||||
|
||||
secret, err := client.UserSecretByName(ctx, codersdk.Me, "openai-key")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "openai-key", secret.Name)
|
||||
require.Equal(t, "Personal OPENAI_API key", secret.Description)
|
||||
require.Equal(t, "OPEN_AI_KEY", secret.EnvName)
|
||||
require.Equal(t, "~/.openai-key", secret.FilePath)
|
||||
})
|
||||
}
|
||||
|
||||
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", "",
|
||||
"--inject-env", "",
|
||||
"--inject-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)
|
||||
})
|
||||
}
|
||||
|
||||
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: "aws-creds",
|
||||
Value: "aws-value",
|
||||
Description: "AWS credentials",
|
||||
FilePath: "~/.aws/creds",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "github-token",
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
Description: "Personal GitHub access token",
|
||||
EnvName: "GITHUB_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, "UPDATED")
|
||||
assert.Contains(t, out, "ENV")
|
||||
assert.Contains(t, out, "FILE")
|
||||
assert.Contains(t, out, "DESCRIPTION")
|
||||
assert.Contains(t, out, "github-token")
|
||||
assert.Contains(t, out, "GITHUB_TOKEN")
|
||||
assert.Contains(t, out, "aws-creds")
|
||||
assert.Contains(t, out, "~/.aws/creds")
|
||||
})
|
||||
|
||||
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: "github-token",
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
Description: "Personal GitHub access token",
|
||||
EnvName: "GITHUB_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: "aws-creds",
|
||||
Value: "aws-value",
|
||||
Description: "AWS credentials",
|
||||
FilePath: "~/.aws/creds",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
||||
Name: "github-token",
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
Description: "Personal GitHub access token",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "list", "github-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, "UPDATED")
|
||||
assert.Contains(t, out, "ENV")
|
||||
assert.Contains(t, out, "FILE")
|
||||
assert.Contains(t, out, "DESCRIPTION")
|
||||
assert.Contains(t, out, "github-token")
|
||||
assert.Contains(t, out, "GITHUB_TOKEN")
|
||||
assert.NotContains(t, out, "aws-creds")
|
||||
assert.NotContains(t, out, "~/.aws/creds")
|
||||
})
|
||||
|
||||
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: "github-token",
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
Description: "Personal GitHub access token",
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "list", "github-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: "github-token",
|
||||
Value: "ghp_xxxxxxxxxxxx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "delete", "github-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, "github-token")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatchContext(ctx, "Deleted secret")
|
||||
|
||||
require.NoError(t, waiter.Wait())
|
||||
|
||||
_, err = client.UserSecretByName(setupCtx, codersdk.Me, "github-token")
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "secret", "delete", "missing-secret")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv = inv.WithContext(ctx)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
waiter := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatchContext(ctx, "Delete secret")
|
||||
pty.ExpectMatchContext(ctx, "missing-secret")
|
||||
pty.WriteLine("yes")
|
||||
|
||||
err := waiter.Wait()
|
||||
require.ErrorContains(t, err, `delete secret "missing-secret"`)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
1
cli/testdata/coder_--help.golden
vendored
1
cli/testdata/coder_--help.golden
vendored
@@ -43,6 +43,7 @@ SUBCOMMANDS:
|
||||
password
|
||||
restart Restart a workspace
|
||||
schedule Schedule automated start and stop times for workspaces
|
||||
secret Manage personal 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
|
||||
|
||||
41
cli/testdata/coder_secret_--help.golden
vendored
Normal file
41
cli/testdata/coder_secret_--help.golden
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret
|
||||
|
||||
Manage personal secrets
|
||||
|
||||
Aliases: secrets
|
||||
|
||||
- Create a secret:
|
||||
|
||||
$ coder secret create openai-key --value "$SECRET_VALUE" --description
|
||||
"Personal OPENAI_API key" --inject-env OPEN_AI_KEY --inject-file
|
||||
"~/.openai-key"
|
||||
|
||||
- Update a secret:
|
||||
|
||||
$ coder secret update openai-key --value "$NEW_SECRET_VALUE"
|
||||
--description "Updated description" --inject-env NEW_ENV_NAME --inject-file
|
||||
"~/.new-path"
|
||||
|
||||
- List your secrets:
|
||||
|
||||
$ coder secret list
|
||||
|
||||
- Show a specific secret:
|
||||
|
||||
$ coder secret list openai-key
|
||||
|
||||
- Delete a secret:
|
||||
|
||||
$ coder secret delete openai-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.
|
||||
22
cli/testdata/coder_secret_create_--help.golden
vendored
Normal file
22
cli/testdata/coder_secret_create_--help.golden
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret create [flags] <name>
|
||||
|
||||
Create a secret
|
||||
|
||||
OPTIONS:
|
||||
--description string
|
||||
Set the secret description.
|
||||
|
||||
--inject-env string
|
||||
Inject the secret into workspaces as an environment variable.
|
||||
|
||||
--inject-file string
|
||||
Inject the secret into workspaces as a file.
|
||||
|
||||
--value string
|
||||
Set the secret value. This flag is required.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
15
cli/testdata/coder_secret_delete_--help.golden
vendored
Normal file
15
cli/testdata/coder_secret_delete_--help.golden
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret delete [flags] <name>
|
||||
|
||||
Delete a secret
|
||||
|
||||
Aliases: remove, rm
|
||||
|
||||
OPTIONS:
|
||||
-y, --yes bool
|
||||
Bypass confirmation prompts.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
18
cli/testdata/coder_secret_list_--help.golden
vendored
Normal file
18
cli/testdata/coder_secret_list_--help.golden
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret list [flags] [name]
|
||||
|
||||
List secrets, or show one by name
|
||||
|
||||
Aliases: ls
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [name|updated|env|file|description] (default: name,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.
|
||||
23
cli/testdata/coder_secret_update_--help.golden
vendored
Normal file
23
cli/testdata/coder_secret_update_--help.golden
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder secret update [flags] <name>
|
||||
|
||||
Update a secret
|
||||
|
||||
OPTIONS:
|
||||
--description string
|
||||
Update the secret description. Pass an empty string to clear it.
|
||||
|
||||
--inject-env string
|
||||
Update the environment variable injection target. Pass an empty string
|
||||
to clear it.
|
||||
|
||||
--inject-file string
|
||||
Update the file injection target. Pass an empty string to clear it.
|
||||
|
||||
--value string
|
||||
Update the secret value.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -42,6 +42,49 @@ 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.
|
||||
|
||||
## Personal Secrets
|
||||
|
||||
Personal 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 personal secrets:
|
||||
|
||||
```sh
|
||||
# Create a secret and inject it into workspaces as an environment variable.
|
||||
coder secret create openai-key \
|
||||
--value "$OPENAI_API_KEY" \
|
||||
--description "Personal OpenAI API key" \
|
||||
--inject-env OPENAI_API_KEY
|
||||
|
||||
# Create a secret and inject it into a file in your workspace.
|
||||
coder secret create aws-credentials \
|
||||
--value "$AWS_CREDENTIALS_FILE_CONTENTS" \
|
||||
--description "Personal AWS credentials" \
|
||||
--inject-file ~/.aws/credentials
|
||||
|
||||
# List all of your secrets.
|
||||
coder secret list
|
||||
|
||||
# Show a single secret by name.
|
||||
coder secret list openai-key
|
||||
|
||||
# Delete a secret you no longer need.
|
||||
coder secret delete openai-key
|
||||
```
|
||||
|
||||
Use `--inject-env` to inject a secret into your workspaces as an environment
|
||||
variable. Use `--inject-file` to inject it as a file in the workspace. File
|
||||
paths must start with `~/` or `/`.
|
||||
|
||||
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 personal secrets",
|
||||
"path": "reference/cli/secret.md"
|
||||
},
|
||||
{
|
||||
"title": "secret create",
|
||||
"description": "Create a secret",
|
||||
"path": "reference/cli/secret_create.md"
|
||||
},
|
||||
{
|
||||
"title": "secret update",
|
||||
"description": "Update a secret",
|
||||
"path": "reference/cli/secret_update.md"
|
||||
},
|
||||
{
|
||||
"title": "secret list",
|
||||
"description": "List secrets, or show one by name",
|
||||
"path": "reference/cli/secret_list.md"
|
||||
},
|
||||
{
|
||||
"title": "secret delete",
|
||||
"description": "Delete a secret",
|
||||
"path": "reference/cli/secret_delete.md"
|
||||
},
|
||||
{
|
||||
"title": "server",
|
||||
"description": "Start a Coder server",
|
||||
|
||||
1
docs/reference/cli/index.md
generated
1
docs/reference/cli/index.md
generated
@@ -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 personal secrets |
|
||||
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
|
||||
| [<code>task</code>](./task.md) | Manage tasks |
|
||||
| [<code>templates</code>](./templates.md) | Manage templates |
|
||||
|
||||
47
docs/reference/cli/secret.md
generated
Normal file
47
docs/reference/cli/secret.md
generated
Normal file
@@ -0,0 +1,47 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret
|
||||
|
||||
Manage personal secrets
|
||||
|
||||
Aliases:
|
||||
|
||||
* secrets
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret
|
||||
```
|
||||
|
||||
## Description
|
||||
|
||||
```console
|
||||
- Create a secret:
|
||||
|
||||
$ coder secret create openai-key --value "$SECRET_VALUE" --description "Personal OPENAI_API key" --inject-env OPEN_AI_KEY --inject-file "~/.openai-key"
|
||||
|
||||
- Update a secret:
|
||||
|
||||
$ coder secret update openai-key --value "$NEW_SECRET_VALUE" --description "Updated description" --inject-env NEW_ENV_NAME --inject-file "~/.new-path"
|
||||
|
||||
- List your secrets:
|
||||
|
||||
$ coder secret list
|
||||
|
||||
- Show a specific secret:
|
||||
|
||||
$ coder secret list openai-key
|
||||
|
||||
- Delete a secret:
|
||||
|
||||
$ coder secret delete openai-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 |
|
||||
44
docs/reference/cli/secret_create.md
generated
Normal file
44
docs/reference/cli/secret_create.md
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret create
|
||||
|
||||
Create a secret
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret create [flags] <name>
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### --value
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Set the secret value. This flag is required.
|
||||
|
||||
### --description
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Set the secret description.
|
||||
|
||||
### --inject-env
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Inject the secret into workspaces as an environment variable.
|
||||
|
||||
### --inject-file
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Inject the secret into workspaces as a file.
|
||||
25
docs/reference/cli/secret_delete.md
generated
Normal file
25
docs/reference/cli/secret_delete.md
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret delete
|
||||
|
||||
Delete a secret
|
||||
|
||||
Aliases:
|
||||
|
||||
* remove
|
||||
* rm
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret delete [flags] <name>
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### -y, --yes
|
||||
|
||||
| | |
|
||||
|------|-------------------|
|
||||
| Type | <code>bool</code> |
|
||||
|
||||
Bypass confirmation prompts.
|
||||
34
docs/reference/cli/secret_list.md
generated
Normal file
34
docs/reference/cli/secret_list.md
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret list
|
||||
|
||||
List secrets, or show one by name
|
||||
|
||||
Aliases:
|
||||
|
||||
* ls
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret list [flags] [name]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### -c, --column
|
||||
|
||||
| | |
|
||||
|---------|------------------------------------------------------|
|
||||
| Type | <code>[name\|updated\|env\|file\|description]</code> |
|
||||
| Default | <code>name,updated,env,file,description</code> |
|
||||
|
||||
Columns to display in table output.
|
||||
|
||||
### -o, --output
|
||||
|
||||
| | |
|
||||
|---------|--------------------------|
|
||||
| Type | <code>table\|json</code> |
|
||||
| Default | <code>table</code> |
|
||||
|
||||
Output format.
|
||||
44
docs/reference/cli/secret_update.md
generated
Normal file
44
docs/reference/cli/secret_update.md
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# secret update
|
||||
|
||||
Update a secret
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder secret update [flags] <name>
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### --value
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Update the secret value.
|
||||
|
||||
### --description
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Update the secret description. Pass an empty string to clear it.
|
||||
|
||||
### --inject-env
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Update the environment variable injection target. Pass an empty string to clear it.
|
||||
|
||||
### --inject-file
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Update the file injection target. Pass an empty string to clear it.
|
||||
Reference in New Issue
Block a user