Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions cli/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions cli/secret_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading