diff --git a/clicommand/env.go b/clicommand/env.go index b9ab5ab5bf..5acba94898 100644 --- a/clicommand/env.go +++ b/clicommand/env.go @@ -1,9 +1,12 @@ package clicommand import ( + "bufio" "encoding/json" + "errors" "fmt" "os" + "strconv" "strings" "github.com/urfave/cli" @@ -32,20 +35,41 @@ var EnvCommand = cli.Command{ Usage: "Pretty print the JSON output", EnvVar: "BUILDKITE_AGENT_ENV_PRETTY", }, + cli.BoolFlag{ + Name: "from-env-file", + Usage: "Source environment from file described by $BUILDKITE_ENV_FILE", + }, + cli.StringFlag{ + Name: "print", + Usage: "Print a single environment variable by `NAME` as raw text followed by a newline", + }, }, Action: func(c *cli.Context) error { - env := os.Environ() - envMap := make(map[string]string, len(env)) + var envMap map[string]string + var err error - for _, e := range env { - k, v, _ := strings.Cut(e, "=") - envMap[k] = v + if c.Bool("from-env-file") { + envMap, err = loadEnvFile(os.Getenv("BUILDKITE_ENV_FILE")) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading BUILDKITE_ENV_FILE: %v\n", err) + os.Exit(1) + } + } else { + env := os.Environ() + envMap = make(map[string]string, len(env)) + + for _, e := range env { + k, v, _ := strings.Cut(e, "=") + envMap[k] = v + } } - var ( - envJSON []byte - err error - ) + if name := c.String("print"); name != "" { + fmt.Println(envMap[name]) + return nil + } + + var envJSON []byte if c.Bool("pretty") { envJSON, err = json.MarshalIndent(envMap, "", " ") @@ -69,3 +93,42 @@ var EnvCommand = cli.Command{ return nil }, } + +func loadEnvFile(path string) (map[string]string, error) { + envMap := make(map[string]string) + + if path == "" { + return nil, errors.New("no path specified") + } + + f, err := os.Open(path) + if err != nil { + return nil, err + } + + scanner := bufio.NewScanner(f) + + lineNo := 0 + for scanner.Scan() { + lineNo++ + + line := scanner.Text() + if line == "" { + continue + } + + name, quotedValue, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("Unexpected format in %s:%d", path, lineNo) + } + + value, err := strconv.Unquote(quotedValue) + if err != nil { + return nil, fmt.Errorf("unquoting value in %s:%d: %w", path, lineNo, err) + } + + envMap[name] = value + } + + return envMap, nil +} diff --git a/clicommand/env_test.go b/clicommand/env_test.go new file mode 100644 index 0000000000..d2e6c347aa --- /dev/null +++ b/clicommand/env_test.go @@ -0,0 +1,42 @@ +package clicommand + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadEnvFile(t *testing.T) { + f, err := os.CreateTemp("", t.Name()) + if err != nil { + t.Error(err) + } + data := map[string]string{ + "HELLO": "world", + "FOO": "bar\n\"bar\"\n`black hat`\r\n$(have you any root)", + } + for name, value := range data { + fmt.Fprintf(f, "%s=%q\n", name, value) + } + + result, err := loadEnvFile(f.Name()) + require.NoError(t, err) + + assert.Equal(t, data, result, "data should round-trip via env file") +} + +func TestLoadEnvFileQuotingError(t *testing.T) { + f, err := os.CreateTemp("", t.Name()) + require.NoError(t, err) + + fmt.Fprintf(f, "%s=%q\n", "ONE", "ok") + fmt.Fprintln(f, "TWO=missing quotes") + + result, err := loadEnvFile(f.Name()) + assert.Nil(t, result) + + assert.Equal(t, `unquoting value in `+f.Name()+`:2: invalid syntax`, err.Error()) +}