feat: add coder secret import for bulk secret files (#27534)

Adds `coder secret import <file>` to bulk-import dotenv, JSON, or YAML
secrets through the existing batch API. The command infers the format
from the extension or accepts `--input-format`, supports non-interactive
stdin, validates files locally before upload, and warns when imported
keys cannot be injected as environment variables.

Reviewed and updated by Coder Agents on behalf of @dylanhuff-at-coder.
This commit is contained in:
dylanhuff-at-coder
2026-07-28 14:37:29 -07:00
committed by GitHub
parent 1a6a8be96c
commit 0b2a6cac78
9 changed files with 533 additions and 0 deletions
+165
View File
@@ -3,11 +3,15 @@ package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/dustin/go-humanize"
"github.com/dustin/go-humanize/english"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
@@ -31,6 +35,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",
@@ -50,6 +58,7 @@ func (r *RootCmd) secrets() *serpent.Command {
Children: []*serpent.Command{
r.secretCreate(),
r.secretUpdate(),
r.secretImport(),
r.secretEnable(),
r.secretDisable(),
r.secretList(),
@@ -241,6 +250,162 @@ 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 <file>",
Short: "Import secrets from a file",
Long: strings.Join([]string{
"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).",
}, " "),
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 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,
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 == "-" && 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 {
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)
}
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.
func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) {
names := make([]string, 0, len(secrets))
for _, secret := range secrets {
if secret.EnvName == "" {
names = append(names, strconv.Quote(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 each with `coder secret update <name> --env <ENV_NAME>` to inject it into workspaces.",
)
}
func secretValue(inv *serpent.Invocation, value string) (string, bool, error) {
valueProvided := userSetOption(inv, "value")
stdinValue, stdinProvided, err := readInvocationStdin(inv)
+104
View File
@@ -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,107 @@ 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 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()
+187
View File
@@ -3,7 +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"
@@ -635,6 +640,188 @@ func TestSecretDelete(t *testing.T) {
})
}
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("RejectsLocalErrorsBeforeSending", func(t *testing.T) {
t.Parallel()
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()
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)
err := inv.Run()
require.ErrorContains(t, err, tt.wantErr)
require.Zero(t, requests.Load(), "expected no API request")
})
}
})
t.Run("RejectsTTYStdin", func(t *testing.T) {
t.Parallel()
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")
inv, root := clitest.New(t, "--force-tty", "secret", "import", "-", "--input-format", "env")
clitest.SetupConfig(t, client, root)
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) {
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)
})
}
func TestSecretEnableDisable(t *testing.T) {
t.Parallel()
+5
View File
@@ -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
@@ -34,6 +38,7 @@ SUBCOMMANDS:
delete Delete a secret
disable Disable a secret without removing it
enable Enable a secret so it is injected into workspaces
import Import secrets from a file
list List secrets, or show one by name
update Update a secret
+19
View File
@@ -0,0 +1,19 @@
coder v0.0.0-devel
USAGE:
coder secret import [flags] <file>
Import secrets from a file
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
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.