Skip to content

Commit b3299e9

Browse files
authored
compose: add experimental envsubst support (#4477)
Add primitive support for envsubst expressions configuration. Contributes to #4397, Azure/azure-dev-pr#1682
1 parent 21213c0 commit b3299e9

File tree

5 files changed

+266
-5
lines changed

5 files changed

+266
-5
lines changed

cli/azd/.vscode/cspell-azd-dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ endregion
9595
entraid
9696
envlist
9797
envname
98+
envsubst
9899
errcheck
99100
errorinfo
100101
errorlint
@@ -200,6 +201,7 @@ stdouttrace
200201
STRINGSLICE
201202
struct
202203
structs
204+
subst
203205
substr
204206
swacli
205207
Syncer

cli/azd/pkg/project/scaffold_gen.go

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) {
154154
Port: -1,
155155
}
156156

157-
err := mapContainerApp(res, &svcSpec)
157+
err := mapContainerApp(res, &svcSpec, &infraSpec)
158158
if err != nil {
159159
return nil, err
160160
}
@@ -203,17 +203,36 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) {
203203
return &infraSpec, nil
204204
}
205205

206-
func mapContainerApp(res *ResourceConfig, svcSpec *scaffold.ServiceSpec) error {
206+
func mapContainerApp(res *ResourceConfig, svcSpec *scaffold.ServiceSpec, infraSpec *scaffold.InfraSpec) error {
207207
props := res.Props.(ContainerAppProps)
208208
for _, envVar := range props.Env {
209+
if len(envVar.Value) == 0 && len(envVar.Secret) == 0 {
210+
return fmt.Errorf(
211+
"environment variable %s for host %s is invalid: both value and secret are empty",
212+
envVar.Name,
213+
res.Name)
214+
}
215+
216+
if len(envVar.Value) > 0 && len(envVar.Secret) > 0 {
217+
return fmt.Errorf(
218+
"environment variable %s for host %s is invalid: both value and secret are set",
219+
envVar.Name,
220+
res.Name)
221+
}
222+
209223
isSecret := len(envVar.Secret) > 0
210224
value := envVar.Value
211225
if isSecret {
212-
// TODO: handle secrets
213-
continue
226+
value = envVar.Secret
214227
}
215228

216-
svcSpec.Env[envVar.Name] = value
229+
// Notice that we derive isSecret from its usage.
230+
// This is generally correct, except for the case where:
231+
// - CONNECTION_STRING: ${DB_HOST}:${DB_SECRET}
232+
// Here, DB_HOST is not a secret, but DB_SECRET is. And yet, DB_HOST will be marked as a secret.
233+
// This is a limitation of the current implementation, but it's safer to mark both as secrets above.
234+
evaluatedValue := genBicepParamsFromEnvSubst(value, isSecret, infraSpec)
235+
svcSpec.Env[envVar.Name] = evaluatedValue
217236
}
218237

219238
port := props.Port
@@ -256,3 +275,73 @@ func mapHostUses(
256275

257276
return nil
258277
}
278+
279+
func setParameter(spec *scaffold.InfraSpec, name string, value string, isSecret bool) {
280+
for _, parameters := range spec.Parameters {
281+
if parameters.Name == name { // handle existing parameter
282+
if isSecret && !parameters.Secret {
283+
// escalate the parameter to a secret
284+
parameters.Secret = true
285+
}
286+
287+
// prevent auto-generated parameters from being overwritten with different values
288+
if valStr, ok := parameters.Value.(string); !ok || ok && valStr != value {
289+
// if you are a maintainer and run into this error, consider using a different, unique name
290+
panic(fmt.Sprintf(
291+
"parameter collision: parameter %s already set to %s, cannot set to %s", name, parameters.Value, value))
292+
}
293+
294+
return
295+
}
296+
}
297+
298+
spec.Parameters = append(spec.Parameters, scaffold.Parameter{
299+
Name: name,
300+
Value: value,
301+
Type: "string",
302+
Secret: isSecret,
303+
})
304+
}
305+
306+
// genBicepParamsFromEnvSubst generates Bicep input parameters from a string containing envsubst expression(s),
307+
// returning the substituted string that references these parameters.
308+
//
309+
// If the string is a literal, it is returned as is.
310+
// If isSecret is true, the parameter is marked as a secret.
311+
func genBicepParamsFromEnvSubst(
312+
s string,
313+
isSecret bool,
314+
infraSpec *scaffold.InfraSpec) string {
315+
names, locations := parseEnvSubstVariables(s)
316+
317+
// add all expressions as parameters
318+
for i, name := range names {
319+
expression := s[locations[i].start : locations[i].stop+1]
320+
setParameter(infraSpec, scaffold.BicepName(name), expression, isSecret)
321+
}
322+
323+
var result string
324+
if len(names) == 0 {
325+
// literal string with no expressions, quote the value as a Bicep string
326+
result = "'" + s + "'"
327+
} else if len(names) == 1 {
328+
// single expression, return the bicep parameter name to reference the expression
329+
result = scaffold.BicepName(names[0])
330+
} else {
331+
// multiple expressions
332+
// construct the string with all expressions replaced by parameter references as a Bicep interpolated string
333+
previous := 0
334+
result = "'"
335+
for i, loc := range locations {
336+
// replace each expression with references by variable name
337+
result += s[previous:loc.start]
338+
result += "${"
339+
result += scaffold.BicepName(names[i])
340+
result += "}"
341+
previous = loc.stop + 1
342+
}
343+
result += "'"
344+
}
345+
346+
return result
347+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package project
5+
6+
import (
7+
"testing"
8+
9+
"github.com/azure/azure-dev/cli/azd/internal/scaffold"
10+
)
11+
12+
func Test_genBicepParamsFromEnvSubst(t *testing.T) {
13+
tests := []struct {
14+
// input
15+
value string
16+
valueIsSecret bool
17+
// output
18+
want string
19+
wantParams []scaffold.Parameter
20+
}{
21+
{"foo", false, "'foo'", nil},
22+
{"${MY_VAR}", false, "myVar", []scaffold.Parameter{{Name: "myVar", Value: "${MY_VAR}", Type: "string"}}},
23+
24+
{"${MY_SECRET}", true, "mySecret",
25+
[]scaffold.Parameter{
26+
{Name: "mySecret", Value: "${MY_SECRET}", Type: "string", Secret: true}}},
27+
28+
{"Hello, ${world:=okay}!", false, "world",
29+
[]scaffold.Parameter{
30+
{Name: "world", Value: "${world:=okay}", Type: "string"}}},
31+
32+
{"${CAT} and ${DOG}", false, "'${cat} and ${dog}'",
33+
[]scaffold.Parameter{
34+
{Name: "cat", Value: "${CAT}", Type: "string"},
35+
{Name: "dog", Value: "${DOG}", Type: "string"}}},
36+
37+
{"${DB_HOST:='local'}:${DB_USERNAME:='okay'}", true, "'${dbHost}:${dbUsername}'",
38+
[]scaffold.Parameter{
39+
{Name: "dbHost", Value: "${DB_HOST:='local'}", Type: "string", Secret: true},
40+
{Name: "dbUsername", Value: "${DB_USERNAME:='okay'}", Type: "string", Secret: true}}},
41+
}
42+
for _, tt := range tests {
43+
t.Run(tt.value, func(t *testing.T) {
44+
spec := &scaffold.InfraSpec{}
45+
evaluated := genBicepParamsFromEnvSubst(tt.value, tt.valueIsSecret, spec)
46+
if tt.want != evaluated {
47+
t.Errorf("evalEnvValue() evaluatedValue = %v, want %v", evaluated, tt.want)
48+
}
49+
50+
for i, param := range tt.wantParams {
51+
found := false
52+
for _, generated := range spec.Parameters {
53+
if generated.Name == param.Name {
54+
if generated.Secret != param.Secret {
55+
t.Errorf("evalEnvValue() secret = %v, want %v", generated.Secret, param.Secret)
56+
}
57+
58+
if generated.Value != param.Value {
59+
t.Errorf("evalEnvValue() value = %v, want %v", generated.Value, param.Value)
60+
}
61+
62+
if generated.Type != param.Type {
63+
t.Errorf("evalEnvValue() type = %v, want %v", generated.Type, param.Type)
64+
}
65+
found = true
66+
break
67+
}
68+
}
69+
70+
if !found {
71+
t.Errorf("evalEnvValue() parameter = %v not found", spec.Parameters[i].Name)
72+
}
73+
}
74+
})
75+
}
76+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package project
5+
6+
import (
7+
"strings"
8+
"unicode"
9+
)
10+
11+
type location struct {
12+
start int
13+
stop int
14+
}
15+
16+
// parseEnvSubstVariables parses the envsubst expression(s) present in a string.
17+
// substitutions, returning the locations of the expressions and the names of the variables.
18+
//
19+
// It works with both:
20+
// - ${var} and
21+
// - ${var:=default} syntaxes
22+
func parseEnvSubstVariables(s string) (names []string, expressions []location) {
23+
inVar := false
24+
inVarName := false
25+
name := strings.Builder{}
26+
27+
i := 0
28+
start := 0 // start of the variable expression
29+
for i < len(s) {
30+
if s[i] == '$' && i+1 < len(s) && s[i+1] == '{' { // detect ${ sequence
31+
inVar = true
32+
inVarName = true
33+
start = i
34+
i += len("${")
35+
continue
36+
}
37+
38+
if inVar {
39+
if inVarName { // detect the end of the variable name
40+
// a variable name can contain letters, digits, and underscores, and nothing else.
41+
if unicode.IsLetter(rune(s[i])) || unicode.IsDigit(rune(s[i])) || s[i] == '_' {
42+
_ = name.WriteByte(s[i])
43+
} else { // a non-matching character means we've reached the end of the name
44+
inVarName = false
45+
}
46+
}
47+
48+
if s[i] == '}' { // detect the end of the variable expression
49+
inVar = false
50+
names = append(names, name.String())
51+
name.Reset()
52+
expressions = append(expressions, location{start, i})
53+
}
54+
}
55+
56+
i++
57+
}
58+
return
59+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package project
5+
6+
import (
7+
"reflect"
8+
"testing"
9+
)
10+
11+
func Test_parseEnvSubtVariables(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input string
15+
wantNames []string
16+
wantExpressions []location
17+
}{
18+
{"empty", "", nil, nil},
19+
{"no variables", "foo", nil, nil},
20+
{"one variable", "${foo}", []string{"foo"}, []location{{0, 5}}},
21+
{"two variables", "${foo} ${bar}", []string{"foo", "bar"}, []location{{0, 5}, {7, 12}}},
22+
{"two variables with text", "${foo:=value} ${bar#subs}", []string{"foo", "bar"}, []location{{0, 12}, {14, 24}}},
23+
}
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
gotNames, gotExpressions := parseEnvSubstVariables(tt.input)
27+
if !reflect.DeepEqual(gotNames, tt.wantNames) {
28+
t.Errorf("parseEnvSubtVariables() gotNames = %v, want %v", gotNames, tt.wantNames)
29+
}
30+
if !reflect.DeepEqual(gotExpressions, tt.wantExpressions) {
31+
t.Errorf("parseEnvSubtVariables() gotExpressions = %v, want %v", gotExpressions, tt.wantExpressions)
32+
}
33+
})
34+
}
35+
}

0 commit comments

Comments
 (0)