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