From 6193556883218e67966bc1af2a1d4d8455e012b8 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Mon, 27 Jul 2026 16:02:29 +0000 Subject: [PATCH 1/3] feat: add coder secret import for bulk secret files Adds `coder secret import ` on top of the existing POST /users/{user}/secrets/batch endpoint. The format is inferred from the file extension or set with --input-format, and - reads stdin. Content is parsed locally before upload so a file chosen by mistake is never transmitted. --- cli/secret.go | 140 +++++++++++++++ cli/secret_internal_test.go | 94 ++++++++++ cli/secret_test.go | 165 ++++++++++++++++++ cli/testdata/coder_secret_--help.golden | 5 + .../coder_secret_import_--help.golden | 18 ++ docs/manifest.json | 5 + docs/reference/cli/secret.md | 5 + docs/reference/cli/secret_import.md | 26 +++ 8 files changed, 458 insertions(+) create mode 100644 cli/testdata/coder_secret_import_--help.golden create mode 100644 docs/reference/cli/secret_import.md diff --git a/cli/secret.go b/cli/secret.go index 2fb6d75c4fc5e..6d56fd014d3a0 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -3,10 +3,13 @@ package cli import ( "fmt" "io" + "os" + "path/filepath" "strings" "time" "github.com/dustin/go-humanize" + "github.com/dustin/go-humanize/english" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" @@ -29,6 +32,10 @@ func (r *RootCmd) secrets() *serpent.Command { 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: "Import secrets from a file", + Command: "coder secret import ./secrets.env", + }, Example{ Description: "List your secrets", Command: "coder secret list", @@ -48,6 +55,7 @@ func (r *RootCmd) secrets() *serpent.Command { Children: []*serpent.Command{ r.secretCreate(), r.secretUpdate(), + r.secretImport(), r.secretList(), r.secretDelete(), }, @@ -214,6 +222,138 @@ func (r *RootCmd) secretUpdate() *serpent.Command { return cmd } +var secretsFileFormats = []string{ + string(codersdk.SecretsFileFormatEnv), + string(codersdk.SecretsFileFormatJSON), + string(codersdk.SecretsFileFormatYAML), +} + +func (r *RootCmd) secretImport() *serpent.Command { + var inputFormat string + + cmd := &serpent.Command{ + Use: "import ", + Short: "Import secrets from a file", + Long: strings.Join([]string{ + "Every key in the file becomes a secret that is injected as an environment variable of the same name.", + "The import is all or nothing, and existing secrets are never overwritten.", + "Pass - to read the file from stdin.", + }, " "), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "input-format", + Flag: "input-format", + Description: "Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin.", + Value: serpent.EnumOf(&inputFormat, secretsFileFormats...), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + path := inv.Args[0] + // serpent.EnumOf matches case-insensitively but keeps the input + // verbatim, and the parser only accepts lowercase formats. + format := codersdk.SecretsFileFormat(strings.ToLower(inputFormat)) + if format == "" { + format, err = secretsFileFormatFromPath(path) + if err != nil { + return err + } + } + + content, err := readSecretsFile(inv, path) + if err != nil { + return err + } + // Parse before sending so that a file picked by mistake, such as a + // private key, never leaves the machine. + if _, err := codersdk.ParseSecretsFile(format, string(content)); err != nil { + return xerrors.Errorf("parse %q: %w", path, err) + } + + secrets, err := client.ImportUserSecrets(inv.Context(), codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: format, + Content: string(content), + }) + if err != nil { + return xerrors.Errorf("import secrets from %q: %w", path, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Imported %s.\n", english.Plural(len(secrets), "secret", "")) + warnSecretsWithoutEnvName(inv.Stderr, secrets) + return nil + }, + } + + return cmd +} + +// secretsFileFormatFromPath infers the format from the file extension. +// Extensions that do not map to a format, such as ".env.local", require +// --input-format. +func secretsFileFormatFromPath(path string) (codersdk.SecretsFileFormat, error) { + switch strings.ToLower(filepath.Ext(path)) { + case ".env": + return codersdk.SecretsFileFormatEnv, nil + case ".json": + return codersdk.SecretsFileFormatJSON, nil + case ".yaml", ".yml": + return codersdk.SecretsFileFormatYAML, nil + default: + return "", xerrors.Errorf("cannot infer the secrets file format from %q, set --input-format to one of: %s", path, strings.Join(secretsFileFormats, ", ")) + } +} + +// readSecretsFile reads the file at path, or stdin when path is "-". It never +// reads more than one byte past the limit the server accepts. +func readSecretsFile(inv *serpent.Invocation, path string) ([]byte, error) { + reader := inv.Stdin + if path != "-" { + file, err := os.Open(path) + if err != nil { + return nil, xerrors.Errorf("open secrets file: %w", err) + } + defer file.Close() + reader = file + } + + content, err := io.ReadAll(io.LimitReader(reader, codersdk.MaxSecretsFileBytes+1)) + if err != nil { + return nil, xerrors.Errorf("read secrets file: %w", err) + } + if len(content) > codersdk.MaxSecretsFileBytes { + return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", codersdk.MaxSecretsFileBytes) + } + return content, nil +} + +// warnSecretsWithoutEnvName reports imported secrets whose key is not a valid +// environment variable name. They are stored with an empty env name and are +// never injected into workspaces until one is set. +func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) { + names := make([]string, 0, len(secrets)) + for _, secret := range secrets { + if secret.EnvName == "" { + names = append(names, secret.Name) + } + } + if len(names) == 0 { + return + } + + cliui.Warn(w, + fmt.Sprintf("%s imported without an environment variable name: %s", + english.Plural(len(names), "secret", ""), strings.Join(names, ", ")), + "Set one with `coder secret update --env ` to inject them into workspaces.", + ) +} + func secretValue(inv *serpent.Invocation, value string) (string, bool, error) { valueProvided := userSetOption(inv, "value") stdinValue, stdinProvided, err := readInvocationStdin(inv) diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go index 70b4597feb1fe..2c29dd0c9df0d 100644 --- a/cli/secret_internal_test.go +++ b/cli/secret_internal_test.go @@ -3,12 +3,15 @@ package cli import ( "bytes" "io" + "os" + "path/filepath" "strings" "testing" "github.com/spf13/pflag" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -108,6 +111,97 @@ func TestTrailingNewlineWarnings(t *testing.T) { }) } +func TestSecretsFileFormatFromPath(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + want codersdk.SecretsFileFormat + }{ + {path: ".env", want: codersdk.SecretsFileFormatEnv}, + {path: "/tmp/prod.env", want: codersdk.SecretsFileFormatEnv}, + {path: "secrets.ENV", want: codersdk.SecretsFileFormatEnv}, + {path: "config.json", want: codersdk.SecretsFileFormatJSON}, + {path: "values.yaml", want: codersdk.SecretsFileFormatYAML}, + {path: "values.yml", want: codersdk.SecretsFileFormatYAML}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + + got, err := secretsFileFormatFromPath(tt.path) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + + // filepath.Ext(".env.local") is ".local", so it does not map to a format. + for _, path := range []string{"secrets.txt", "noextension", ".env.local", "-", ""} { + t.Run("Unsupported/"+path, func(t *testing.T) { + t.Parallel() + + _, err := secretsFileFormatFromPath(path) + require.ErrorContains(t, err, "set --input-format to one of: env, json, yaml") + }) + } +} + +func TestReadSecretsFile(t *testing.T) { + t.Parallel() + + t.Run("Stdin", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader("A=1"), nil) + + got, err := readSecretsFile(inv, "-") + require.NoError(t, err) + require.Equal(t, "A=1", string(got)) + }) + + t.Run("File", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "secrets.env") + require.NoError(t, os.WriteFile(path, []byte("A=1"), 0o600)) + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + got, err := readSecretsFile(inv, path) + require.NoError(t, err) + require.Equal(t, "A=1", string(got)) + }) + + t.Run("MissingFile", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + _, err := readSecretsFile(inv, filepath.Join(t.TempDir(), "absent.env")) + require.ErrorContains(t, err, "open secrets file") + }) + + t.Run("AtMaxSize", func(t *testing.T) { + t.Parallel() + + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes) + inv := newSecretTestInvocation(t, strings.NewReader(content), nil) + + got, err := readSecretsFile(inv, "-") + require.NoError(t, err) + require.Len(t, got, codersdk.MaxSecretsFileBytes) + }) + + t.Run("OverMaxSize", func(t *testing.T) { + t.Parallel() + + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1) + inv := newSecretTestInvocation(t, strings.NewReader(content), nil) + + _, err := readSecretsFile(inv, "-") + require.ErrorContains(t, err, "exceeds the maximum allowed size") + }) +} + func newSecretTestInvocation(t *testing.T, stdin io.Reader, stderr io.Writer) *serpent.Invocation { t.Helper() diff --git a/cli/secret_test.go b/cli/secret_test.go index be3d993db5fc5..5c4bc37a64d0a 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -3,6 +3,8 @@ package cli_test import ( "encoding/json" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -591,3 +593,166 @@ func TestSecretDelete(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +func TestSecretImport(t *testing.T) { + t.Parallel() + + writeSecretsFile := func(t *testing.T, name, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + + t.Run("InfersFormatFromExtension", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + content string + }{ + {name: "Env", file: "secrets.env", content: "ALPHA=a\nBETA=b\n"}, + {name: "JSON", file: "secrets.json", content: `{"ALPHA":"a","BETA":"b"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "import", writeSecretsFile(t, tt.file, tt.content)) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 2 secrets.") + require.NotContains(t, output.Stderr(), "without an environment variable name") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + require.Equal(t, "ALPHA", secret.EnvName) + }) + } + }) + + // The flag wins over the extension, and its value is matched + // case-insensitively. + t.Run("InputFormatOverridesExtension", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + path := writeSecretsFile(t, "secrets.json", "ALPHA=a\n") + inv, root := clitest.New(t, "secret", "import", path, "--input-format", "ENV") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 1 secret.") + + _, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + }) + + t.Run("Stdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "import", "-", "--input-format", "env") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("ALPHA=a\n") + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 1 secret.") + + _, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + }) + + t.Run("UnknownExtension", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + path := writeSecretsFile(t, "secrets.txt", "ALPHA=a\n") + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "set --input-format to one of: env, json, yaml") + }) + + t.Run("RejectsMalformedFileBeforeSending", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nNOEQUALS\n") + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "line 2: expected KEY=VALUE") + // The request wrapper is absent, so the file was never uploaded. + require.NotContains(t, err.Error(), "import secrets from") + }) + + t.Run("CreatesNothingWhenAnEntryIsInvalid", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // An empty value parses but fails server-side validation, which + // rejects the whole batch. + path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nBETA=\n") + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "import secrets from") + + secrets, err := client.UserSecrets(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, secrets) + }) + + t.Run("WarnsAboutKeysWithoutEnvNames", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // PATH is reserved and MY-TOKEN is not a valid identifier, so both are + // imported without an env name. + require.Error(t, codersdk.UserSecretEnvNameValid("PATH")) + path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nPATH=b\nMY-TOKEN=c\n") + inv, root := clitest.New(t, "secret", "import", path) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 3 secrets.") + require.Contains(t, output.Stderr(), "2 secrets imported without an environment variable name: PATH, MY-TOKEN") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "PATH") + require.NoError(t, err) + require.Empty(t, secret.EnvName) + }) +} diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden index 45447c96e39e4..d49131b0f8cf7 100644 --- a/cli/testdata/coder_secret_--help.golden +++ b/cli/testdata/coder_secret_--help.golden @@ -17,6 +17,10 @@ USAGE: $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + - Import secrets from a file: + + $ coder secret import ./secrets.env + - List your secrets: $ coder secret list @@ -32,6 +36,7 @@ USAGE: SUBCOMMANDS: create Create a secret delete Delete a secret + import Import secrets from a file list List secrets, or show one by name update Update a secret diff --git a/cli/testdata/coder_secret_import_--help.golden b/cli/testdata/coder_secret_import_--help.golden new file mode 100644 index 0000000000000..060f06297b2c4 --- /dev/null +++ b/cli/testdata/coder_secret_import_--help.golden @@ -0,0 +1,18 @@ +coder v0.0.0-devel + +USAGE: + coder secret import [flags] + + Import secrets from a file + + Every key in the file becomes a secret that is injected as an environment + variable of the same name. The import is all or nothing, and existing secrets + are never overwritten. Pass - to read the file from stdin. + +OPTIONS: + --input-format env|json|yaml + Format of the secrets file. Inferred from the file extension when + unset, and required when reading from stdin. + +——— +Run `coder --help` for a list of global options. diff --git a/docs/manifest.json b/docs/manifest.json index 74731854918de..be5fd795df5ed 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2147,6 +2147,11 @@ "description": "Update a secret", "path": "reference/cli/secret_update.md" }, + { + "title": "secret import", + "description": "Import secrets from a file", + "path": "reference/cli/secret_import.md" + }, { "title": "secret list", "description": "List secrets, or show one by name", diff --git a/docs/reference/cli/secret.md b/docs/reference/cli/secret.md index 7d022f3dfd693..eec0786d73776 100644 --- a/docs/reference/cli/secret.md +++ b/docs/reference/cli/secret.md @@ -24,6 +24,10 @@ coder secret $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + - Import secrets from a file: + + $ coder secret import ./secrets.env + - List your secrets: $ coder secret list @@ -43,5 +47,6 @@ coder secret |-------------------------------------------|-----------------------------------| | [create](./secret_create.md) | Create a secret | | [update](./secret_update.md) | Update a secret | +| [import](./secret_import.md) | Import secrets from a file | | [list](./secret_list.md) | List secrets, or show one by name | | [delete](./secret_delete.md) | Delete a secret | diff --git a/docs/reference/cli/secret_import.md b/docs/reference/cli/secret_import.md new file mode 100644 index 0000000000000..366f1ac4a79da --- /dev/null +++ b/docs/reference/cli/secret_import.md @@ -0,0 +1,26 @@ + +# secret import + +Import secrets from a file + +## Usage + +```console +coder secret import [flags] +``` + +## Description + +```console +Every key in the file becomes a secret that is injected as an environment variable of the same name. The import is all or nothing, and existing secrets are never overwritten. Pass - to read the file from stdin. +``` + +## Options + +### --input-format + +| | | +|------|------------------------------| +| Type | env\|json\|yaml | + +Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin. From 4d39fa2092ba7ba86cc5ed68bd00cb231112bb99 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Mon, 27 Jul 2026 17:39:52 +0000 Subject: [PATCH 2/3] fix: validate secret imports before upload --- cli/secret.go | 40 ++++++++-- cli/secret_internal_test.go | 10 +++ cli/secret_test.go | 74 ++++++++++++------- .../coder_secret_import_--help.golden | 7 +- docs/reference/cli/secret_import.md | 2 +- 5 files changed, 96 insertions(+), 37 deletions(-) diff --git a/cli/secret.go b/cli/secret.go index 6d56fd014d3a0..717453e0061e9 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -5,8 +5,10 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" + "unicode/utf8" "github.com/dustin/go-humanize" "github.com/dustin/go-humanize/english" @@ -235,9 +237,10 @@ func (r *RootCmd) secretImport() *serpent.Command { Use: "import ", Short: "Import secrets from a file", Long: strings.Join([]string{ - "Every key in the file becomes a secret that is injected as an environment variable of the same name.", + "Every key in the file becomes a secret.", + "Keys allowed as environment variable names are injected into workspaces under the same name.", "The import is all or nothing, and existing secrets are never overwritten.", - "Pass - to read the file from stdin.", + "Pass - to read the file from non-interactive stdin (pipe or redirect).", }, " "), Middleware: serpent.Chain( serpent.RequireNArgs(1), @@ -271,11 +274,15 @@ func (r *RootCmd) secretImport() *serpent.Command { if err != nil { return err } - // Parse before sending so that a file picked by mistake, such as a - // private key, never leaves the machine. - if _, err := codersdk.ParseSecretsFile(format, string(content)); err != nil { + // Parse and validate before sending so that a file picked by mistake, + // such as a private key, never leaves the machine. + requests, err := codersdk.ParseSecretsFile(format, string(content)) + if err != nil { return xerrors.Errorf("parse %q: %w", path, err) } + if err := validateImportedSecrets(requests); err != nil { + return xerrors.Errorf("validate %q: %w", path, err) + } secrets, err := client.ImportUserSecrets(inv.Context(), codersdk.Me, codersdk.ImportUserSecretsRequest{ Format: format, @@ -314,6 +321,9 @@ func secretsFileFormatFromPath(path string) (codersdk.SecretsFileFormat, error) // reads more than one byte past the limit the server accepts. func readSecretsFile(inv *serpent.Invocation, path string) ([]byte, error) { reader := inv.Stdin + if path == "-" && isTTYIn(inv) { + return nil, xerrors.New("secrets file must be provided via non-interactive stdin (pipe or redirect)") + } if path != "-" { file, err := os.Open(path) if err != nil { @@ -330,9 +340,25 @@ func readSecretsFile(inv *serpent.Invocation, path string) ([]byte, error) { if len(content) > codersdk.MaxSecretsFileBytes { return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", codersdk.MaxSecretsFileBytes) } + if !utf8.Valid(content) { + return nil, xerrors.New("secrets file must contain valid UTF-8") + } return content, nil } +func validateImportedSecrets(requests []codersdk.CreateUserSecretRequest) error { + var validationErrors []string + for i, request := range requests { + for _, validation := range codersdk.ValidateCreateUserSecretRequest(request) { + validationErrors = append(validationErrors, fmt.Sprintf("secret %d (%q) %s: %s", i+1, request.Name, validation.Field, validation.Detail)) + } + } + if len(validationErrors) > 0 { + return xerrors.New(strings.Join(validationErrors, "; ")) + } + return nil +} + // warnSecretsWithoutEnvName reports imported secrets whose key is not a valid // environment variable name. They are stored with an empty env name and are // never injected into workspaces until one is set. @@ -340,7 +366,7 @@ func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) { names := make([]string, 0, len(secrets)) for _, secret := range secrets { if secret.EnvName == "" { - names = append(names, secret.Name) + names = append(names, strconv.Quote(secret.Name)) } } if len(names) == 0 { @@ -350,7 +376,7 @@ func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) { cliui.Warn(w, fmt.Sprintf("%s imported without an environment variable name: %s", english.Plural(len(names), "secret", ""), strings.Join(names, ", ")), - "Set one with `coder secret update --env ` to inject them into workspaces.", + "Set each with `coder secret update --env ` to inject it into workspaces.", ) } diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go index 2c29dd0c9df0d..1e5e6be1b7145 100644 --- a/cli/secret_internal_test.go +++ b/cli/secret_internal_test.go @@ -202,6 +202,16 @@ func TestReadSecretsFile(t *testing.T) { }) } +func TestWarnSecretsWithoutEnvNameEscapesNames(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSecretsWithoutEnvName(&stderr, []codersdk.UserSecret{{Name: "\x1b[31mBAD"}}) + + require.Contains(t, stderr.String(), `"\x1b[31mBAD"`) + require.NotContains(t, stderr.String(), "\x1b[31mBAD") +} + func newSecretTestInvocation(t *testing.T, stdin io.Reader, stderr io.Writer) *serpent.Invocation { t.Helper() diff --git a/cli/secret_test.go b/cli/secret_test.go index 5c4bc37a64d0a..8477e2c2ffea7 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -3,9 +3,12 @@ package cli_test import ( "encoding/json" "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "strings" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -694,42 +697,61 @@ func TestSecretImport(t *testing.T) { require.ErrorContains(t, err, "set --input-format to one of: env, json, yaml") }) - t.Run("RejectsMalformedFileBeforeSending", func(t *testing.T) { + t.Run("RejectsLocalErrorsBeforeSending", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) + tests := []struct { + name string + content []byte + wantErr string + }{ + {name: "Malformed", content: []byte("ALPHA=a\nNOEQUALS\n"), wantErr: "line 2: expected KEY=VALUE"}, + {name: "InvalidUTF8", content: []byte{'A', 'L', 'P', 'H', 'A', '=', 0xff}, wantErr: "must contain valid UTF-8"}, + {name: "InvalidEntry", content: []byte("ALPHA=a\nBETA=\n"), wantErr: `secret 2 ("BETA") value: Value is required.`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nNOEQUALS\n") - inv, root := clitest.New(t, "secret", "import", path) - clitest.SetupConfig(t, client, root) + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + client.SetSessionToken("test-token") + path := filepath.Join(t.TempDir(), "secrets.env") + require.NoError(t, os.WriteFile(path, tt.content, 0o600)) + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitMedium) - err := inv.WithContext(ctx).Run() - require.ErrorContains(t, err, "line 2: expected KEY=VALUE") - // The request wrapper is absent, so the file was never uploaded. - require.NotContains(t, err.Error(), "import secrets from") + err := inv.Run() + require.ErrorContains(t, err, tt.wantErr) + require.Zero(t, requests.Load(), "expected no API request") + }) + } }) - t.Run("CreatesNothingWhenAnEntryIsInvalid", func(t *testing.T) { + t.Run("RejectsTTYStdin", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() - // An empty value parses but fails server-side validation, which - // rejects the whole batch. - path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nBETA=\n") - inv, root := clitest.New(t, "secret", "import", path) + client := codersdk.New(must(url.Parse(server.URL))) + client.SetSessionToken("test-token") + inv, root := clitest.New(t, "--force-tty", "secret", "import", "-", "--input-format", "env") clitest.SetupConfig(t, client, root) - ctx := testutil.Context(t, testutil.WaitMedium) - err := inv.WithContext(ctx).Run() - require.ErrorContains(t, err, "import secrets from") - - secrets, err := client.UserSecrets(ctx, codersdk.Me) - require.NoError(t, err) - require.Empty(t, secrets) + err := inv.Run() + require.ErrorContains(t, err, "secrets file must be provided via non-interactive stdin (pipe or redirect)") + require.Zero(t, requests.Load(), "expected no API request") }) t.Run("WarnsAboutKeysWithoutEnvNames", func(t *testing.T) { @@ -749,7 +771,7 @@ func TestSecretImport(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) require.NoError(t, inv.WithContext(ctx).Run()) require.Contains(t, output.Stdout(), "Imported 3 secrets.") - require.Contains(t, output.Stderr(), "2 secrets imported without an environment variable name: PATH, MY-TOKEN") + require.Contains(t, output.Stderr(), `2 secrets imported without an environment variable name: "PATH", "MY-TOKEN"`) secret, err := client.UserSecretByName(ctx, codersdk.Me, "PATH") require.NoError(t, err) diff --git a/cli/testdata/coder_secret_import_--help.golden b/cli/testdata/coder_secret_import_--help.golden index 060f06297b2c4..217e614405475 100644 --- a/cli/testdata/coder_secret_import_--help.golden +++ b/cli/testdata/coder_secret_import_--help.golden @@ -5,9 +5,10 @@ USAGE: Import secrets from a file - Every key in the file becomes a secret that is injected as an environment - variable of the same name. The import is all or nothing, and existing secrets - are never overwritten. Pass - to read the file from stdin. + Every key in the file becomes a secret. Keys allowed as environment variable + names are injected into workspaces under the same name. The import is all or + nothing, and existing secrets are never overwritten. Pass - to read the file + from non-interactive stdin (pipe or redirect). OPTIONS: --input-format env|json|yaml diff --git a/docs/reference/cli/secret_import.md b/docs/reference/cli/secret_import.md index 366f1ac4a79da..0c94b04d22697 100644 --- a/docs/reference/cli/secret_import.md +++ b/docs/reference/cli/secret_import.md @@ -12,7 +12,7 @@ coder secret import [flags] ## Description ```console -Every key in the file becomes a secret that is injected as an environment variable of the same name. The import is all or nothing, and existing secrets are never overwritten. Pass - to read the file from stdin. +Every key in the file becomes a secret. Keys allowed as environment variable names are injected into workspaces under the same name. The import is all or nothing, and existing secrets are never overwritten. Pass - to read the file from non-interactive stdin (pipe or redirect). ``` ## Options From 44eb9f47d196acfce66aa52a74374400accd5085 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Mon, 27 Jul 2026 15:27:41 -0400 Subject: [PATCH 3/3] docs: mention coder secret import in the user secrets guide --- docs/user-guides/user-secrets.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 7dd3d8f4d0a0c..0fb01bc6f244e 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -194,6 +194,23 @@ want to store a trailing newline: echo -n "$API_KEY" | coder secret create api-key --env API_KEY ``` +### Import multiple secrets from a file + +Use `coder secret import ` to create a secret for every key in a dotenv, +JSON, or YAML file. The format is inferred from the file extension. Pass `-` +to read from non-interactive stdin, which requires `--input-format`: + +```sh +coder secret import ./secrets.env + +coder secret import - --input-format yaml < ./secrets.yaml +``` + +The import is all or nothing and never overwrites existing secrets. Keys that +are valid environment variable names are injected under the same name; other +keys are imported without an environment variable target. For details, see +[`coder secret import`](../reference/cli/secret_import.md). + ## Update a secret Use `coder secret update` to update a secret value, description, environment