Skip to content
Open
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
97 changes: 91 additions & 6 deletions cli/src/cmd/app/commands/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package commands

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
Expand All @@ -16,9 +18,10 @@ import (
)

const (
envFormatDotenv = "dotenv"
envFormatShell = "shell"
envFormatJSON = "json"
envFormatDotenv = "dotenv"
envFormatShell = "shell"
envFormatJSON = "json"
envFormatGitHubActions = "github-actions"
)

var (
Expand Down Expand Up @@ -50,7 +53,9 @@ for example when piping the output into another command.

Pass --all to print the resolved environment for every service in one run. The
dotenv and shell formats group each service under a "# <service>" header; the
json format emits an object keyed by service name.
json format emits an object keyed by service name; the github-actions format
emits plain KEY=value and heredoc lines with no headers so the output stays
valid for $GITHUB_ENV.

Examples:
# Resolved environment for the api service (KEY=value lines)
Expand All @@ -62,6 +67,9 @@ Examples:
# JSON object (also selected by the global --json flag)
azd app env api --format json

# GitHub Actions env lines for $GITHUB_ENV (multiline values use heredocs)
azd app env api --format github-actions --no-mask >> $GITHUB_ENV

# Raw values, no masking
azd app env api --no-mask

Expand All @@ -88,7 +96,7 @@ Examples:
ValidArgsFunction: completeServiceArgs,
}

cmd.Flags().StringVar(&envFormat, "format", envFormatDotenv, "Output format: dotenv, shell, or json")
cmd.Flags().StringVar(&envFormat, "format", envFormatDotenv, "Output format: dotenv, shell, json, or github-actions")
cmd.Flags().BoolVar(&envNoMask, "no-mask", false, "Print raw values instead of masking secret-shaped values")
cmd.Flags().StringVar(&envFile, "env-file", "", "Path to a .env file to merge, matching azd app run")
cmd.Flags().BoolVar(&envAll, "all", false, "Print the resolved environment for every service")
Expand Down Expand Up @@ -210,6 +218,15 @@ func runEnv(_ *cobra.Command, args []string) error {
return cliout.PrintJSON(maskEnv(resolved, mask))
}

if format == envFormatGitHubActions {
out, err := formatGitHubEnv(resolved, mask)
if err != nil {
return err
}
fmt.Print(out)
return nil
}

fmt.Print(formatEnv(resolved, format, mask))
return nil
}
Expand Down Expand Up @@ -318,6 +335,20 @@ func renderAllEnv(resolvedByService map[string]map[string]string, names []string
return nil
}

// GitHub Actions parses $GITHUB_ENV line by line and rejects lines that are
// not KEY=VALUE or heredoc blocks, so the "# <service>" headers used by the
// other formats are omitted here.
if format == envFormatGitHubActions {
for _, name := range names {
block, err := formatGitHubEnv(resolvedByService[name], mask)
if err != nil {
return err
}
fmt.Print(block)
}
return nil
}

for i, name := range names {
if i > 0 {
fmt.Println()
Expand Down Expand Up @@ -422,6 +453,9 @@ func envFileContent(resolved map[string]string, format string, mask bool) (strin
}
return string(b) + "\n", nil
}
if format == envFormatGitHubActions {
return formatGitHubEnv(resolved, mask)
}
return formatEnv(resolved, format, mask), nil
}

Expand Down Expand Up @@ -518,8 +552,10 @@ func resolveEnvFormat(format string) (string, error) {
return envFormatShell, nil
case envFormatJSON:
return envFormatJSON, nil
case envFormatGitHubActions:
return envFormatGitHubActions, nil
default:
return "", fmt.Errorf("invalid --format %q: expected dotenv, shell, or json", format)
return "", fmt.Errorf("invalid --format %q: expected dotenv, shell, json, or github-actions", format)
}
}

Expand Down Expand Up @@ -570,3 +606,52 @@ func shellQuoteDouble(v string) string {
)
return `"` + replacer.Replace(v) + `"`
}

// formatGitHubEnv renders the environment for appending to the GitHub Actions
// $GITHUB_ENV file. Single-line values use KEY=value; values that contain a
// newline use the KEY<<DELIM heredoc form with a random delimiter that does not
// appear in the value, matching how GitHub Actions expects multiline values.
// Keys are emitted in sorted order and secret-shaped values are masked when mask
// is true.
func formatGitHubEnv(env map[string]string, mask bool) (string, error) {
masked := maskEnv(env, mask)

keys := make([]string, 0, len(masked))
for k := range masked {
keys = append(keys, k)
}
sort.Strings(keys)

var b strings.Builder
for _, k := range keys {
v := masked[k]
if strings.Contains(v, "\n") {
delim, err := githubEnvDelimiter(v)
if err != nil {
return "", err
}
fmt.Fprintf(&b, "%s<<%s\n%s\n%s\n", k, delim, v, delim)
continue
}
fmt.Fprintf(&b, "%s=%s\n", k, v)
}
return b.String(), nil
}

// githubEnvDelimiter returns a random heredoc delimiter that does not appear in
// value, so a multiline value can be written to $GITHUB_ENV without the closing
// marker colliding with the content. The random suffix uses crypto/rand so the
// delimiter is not predictable from the value.
func githubEnvDelimiter(value string) (string, error) {
buf := make([]byte, 16)
for attempt := 0; attempt < 8; attempt++ {
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("failed to generate heredoc delimiter: %w", err)
}
delim := "ghadelimiter_" + hex.EncodeToString(buf)
if !strings.Contains(value, delim) {
return delim, nil
}
}
return "", fmt.Errorf("failed to generate a unique heredoc delimiter")
}
70 changes: 70 additions & 0 deletions cli/src/cmd/app/commands/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ func TestResolveEnvFormat(t *testing.T) {
{"shell", envFormatShell, false},
{"json", envFormatJSON, false},
{" json ", envFormatJSON, false},
{"github-actions", envFormatGitHubActions, false},
{"GitHub-Actions", envFormatGitHubActions, false},
{"yaml", "", true},
}
for _, tt := range tests {
Expand Down Expand Up @@ -103,6 +105,74 @@ func TestFormatEnv(t *testing.T) {
})
}

func TestFormatGitHubEnv(t *testing.T) {
t.Run("single-line values are sorted KEY=value", func(t *testing.T) {
env := map[string]string{
"B_KEY": "two",
"A_KEY": "one",
}
out, err := formatGitHubEnv(env, false)
require.NoError(t, err)
lines := splitNonEmpty(out)
require.Len(t, lines, 2)
assert.Equal(t, "A_KEY=one", lines[0])
assert.Equal(t, "B_KEY=two", lines[1])
})

t.Run("values with special shell characters are not quoted or escaped", func(t *testing.T) {
env := map[string]string{"URL": "postgres://user:pw@host:5432/db?x=1"}
out, err := formatGitHubEnv(env, false)
require.NoError(t, err)
assert.Equal(t, "URL=postgres://user:pw@host:5432/db?x=1\n", out)
})

t.Run("multiline values use a heredoc block with a matching delimiter", func(t *testing.T) {
value := "line one\nline two\nline three"
env := map[string]string{"CERT": value}
out, err := formatGitHubEnv(env, false)
require.NoError(t, err)

lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
require.GreaterOrEqual(t, len(lines), 5)

header := lines[0]
require.True(t, strings.HasPrefix(header, "CERT<<"), "header was %q", header)
delim := strings.TrimPrefix(header, "CERT<<")
assert.NotEmpty(t, delim)
assert.False(t, strings.Contains(value, delim), "delimiter must not appear in the value")

body := strings.Join(lines[1:len(lines)-1], "\n")
assert.Equal(t, value, body)
assert.Equal(t, delim, lines[len(lines)-1], "block must close with the delimiter")
})

t.Run("masking applies before formatting", func(t *testing.T) {
env := map[string]string{"DB_PASSWORD": "supersecret"}
out, err := formatGitHubEnv(env, true)
require.NoError(t, err)
assert.NotContains(t, out, "supersecret")
assert.Contains(t, out, "DB_PASSWORD=su***et")
})
}

func TestGithubEnvDelimiter(t *testing.T) {
t.Run("delimiter is never a substring of the value", func(t *testing.T) {
value := "some multiline\ncontent"
delim, err := githubEnvDelimiter(value)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(delim, "ghadelimiter_"))
assert.False(t, strings.Contains(value, delim))
})

t.Run("delimiters are randomized between calls", func(t *testing.T) {
a, err := githubEnvDelimiter("value")
require.NoError(t, err)
b, err := githubEnvDelimiter("value")
require.NoError(t, err)
assert.NotEqual(t, a, b)
})
}

func TestShellQuoteDouble(t *testing.T) {
tests := []struct {
in string
Expand Down
Loading