Skip to content

Commit d13bc21

Browse files
feat: promote envprov to a loadable provisioner type (#489)
Signed-off-by: Siddhartha Singh <siddharthagithub0007@gmail.com>
1 parent b1d74eb commit d13bc21

5 files changed

Lines changed: 183 additions & 12 deletions

File tree

internal/command/default.provisioners.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,3 +994,9 @@
994994
- database
995995
- username
996996
- password
997+
998+
# The default environment provisioner resolves environment resource references by looking up OS environment
999+
# variables at generate time. The accessed variables are tracked and can be written to a skeleton .env file.
1000+
- uri: local-env://default-provisioners/environment
1001+
type: environment
1002+
description: Pulls values out of the local environment as outputs to an environment resource

internal/command/generate.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,25 @@ arguments.
226226
slog.Info(fmt.Sprintf("Successfully loaded %d resource provisioners", len(loadedProvisioners)))
227227
}
228228

229-
// append the env var provisioner
230-
environmentProvisioner := new(envprov.Provisioner)
231-
loadedProvisioners = append(loadedProvisioners, environmentProvisioner)
229+
// Find the provisioner responsible for the "environment" resource type. A project may supply
230+
// its own (via any provisioner kind declared with type: environment), in which case it is used
231+
// as-is. Only when no provisioner handles the environment type do we fall back to the built-in
232+
// envprov.Provisioner, for backward compatibility with projects initialized before the default
233+
// environment provisioner entry existed.
234+
var environmentProvisioner *envprov.Provisioner
235+
hasEnvironmentProvisioner := false
236+
for _, p := range loadedProvisioners {
237+
if p.Type() == "environment" {
238+
hasEnvironmentProvisioner = true
239+
// Only the built-in implementation exposes Accessed(), used below to write the env file.
240+
environmentProvisioner, _ = p.(*envprov.Provisioner)
241+
break
242+
}
243+
}
244+
if !hasEnvironmentProvisioner {
245+
environmentProvisioner = new(envprov.Provisioner)
246+
loadedProvisioners = append(loadedProvisioners, environmentProvisioner)
247+
}
232248

233249
currentState, err = currentState.WithPrimedResources()
234250
if err != nil {
@@ -399,10 +415,14 @@ arguments.
399415

400416
if v, _ := cmd.Flags().GetString(generateCmdEnvFileFlag); v != "" {
401417
content := new(strings.Builder)
402-
for k := range environmentProvisioner.Accessed() {
403-
_, _ = content.WriteString(k)
404-
_, _ = content.WriteRune('=')
405-
_, _ = content.WriteRune('\n')
418+
// environmentProvisioner is nil when a custom provisioner handles the environment type;
419+
// only the built-in envprov.Provisioner tracks accessed variables for the env file.
420+
if environmentProvisioner != nil {
421+
for k := range environmentProvisioner.Accessed() {
422+
_, _ = content.WriteString(k)
423+
_, _ = content.WriteRune('=')
424+
_, _ = content.WriteRune('\n')
425+
}
406426
}
407427
slog.Info(fmt.Sprintf("Writing env var file to '%s'", v))
408428
if err := os.WriteFile(v, []byte(content.String()), 0644); err != nil {

internal/provisioners/envprov/envprov.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@
1515
package envprov
1616

1717
import (
18+
"bytes"
1819
"context"
1920
"fmt"
2021
"maps"
2122
"net/url"
2223
"os"
24+
"slices"
2325
"strings"
2426

2527
"github.com/score-spec/score-go/framework"
28+
"gopkg.in/yaml.v3"
2629

2730
"github.com/score-spec/score-compose/internal/provisioners"
2831
"github.com/score-spec/score-compose/internal/util"
@@ -31,19 +34,61 @@ import (
3134
// The Provisioner is an environment provision which returns a suitable expression for accessing an environment variable
3235
// within the compose project at deploy time. This provisioner also tracks what env vars are accessed so that they can
3336
// be added to the .env file later.
37+
//
38+
// It can be used in two modes:
39+
// - Legacy mode: created via new(Provisioner), all fields are zero-valued, methods use hardcoded defaults.
40+
// - YAML-loaded mode: created via Parse(), fields are populated from the provisioners YAML file.
3441
type Provisioner struct {
42+
ProvisionerUri string `yaml:"uri"`
43+
ResType string `yaml:"type"`
44+
ResClass *string `yaml:"class,omitempty"`
45+
ResDescription string `yaml:"description,omitempty"`
46+
SupportedParams []string `yaml:"supported_params,omitempty"`
47+
ExpectedOutputs []string `yaml:"expected_outputs,omitempty"`
3548
// LookupFunc is an environment variable LookupFunc function, if nil this will be defaulted to os.LookupEnv
36-
LookupFunc func(key string) (string, bool)
49+
LookupFunc func(key string) (string, bool) `yaml:"-"`
3750
// accessed is the map of accessed environment variables and the value they had at access time
3851
accessed map[string]string
3952
}
4053

54+
// Parse loads a Provisioner from raw YAML map data, following the same pattern as cmdprov.Parse and templateprov.Parse.
55+
func Parse(raw map[string]interface{}) (*Provisioner, error) {
56+
p := new(Provisioner)
57+
intermediate, _ := yaml.Marshal(raw)
58+
dec := yaml.NewDecoder(bytes.NewReader(intermediate))
59+
dec.KnownFields(true)
60+
if err := dec.Decode(&p); err != nil {
61+
return nil, err
62+
}
63+
if p.ProvisionerUri == "" {
64+
return nil, fmt.Errorf("uri not set")
65+
}
66+
if p.ResType == "" {
67+
p.ResType = "environment"
68+
}
69+
return p, nil
70+
}
71+
4172
func (e *Provisioner) Uri() string {
73+
if e.ProvisionerUri != "" {
74+
return e.ProvisionerUri
75+
}
4276
return "builtin://environment"
4377
}
4478

4579
func (e *Provisioner) Match(resUid framework.ResourceUid) bool {
46-
return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
80+
if e.ProvisionerUri == "" {
81+
// Legacy mode: preserve original matching behavior
82+
return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
83+
}
84+
// YAML-loaded mode: standard type/class matching (same as cmdprov/templateprov)
85+
if resUid.Type() != e.ResType {
86+
return false
87+
}
88+
if e.ResClass != nil && resUid.Class() != *e.ResClass {
89+
return false
90+
}
91+
return true
4792
}
4893

4994
func (e *Provisioner) Provision(ctx context.Context, input *provisioners.Input) (*provisioners.ProvisionOutput, error) {
@@ -154,19 +199,40 @@ func (e *envVarResourceTracker) Type() string {
154199
}
155200

156201
func (p *Provisioner) Class() string {
202+
if p.ProvisionerUri != "" && p.ResClass == nil {
203+
return "(any)"
204+
}
205+
if p.ResClass != nil {
206+
return *p.ResClass
207+
}
157208
return "default"
158209
}
159210

160211
func (p *Provisioner) Type() string {
212+
if p.ResType != "" {
213+
return p.ResType
214+
}
161215
return "environment"
162216
}
163217

164218
func (p *Provisioner) Outputs() []string {
165-
return nil
219+
if p.ExpectedOutputs == nil {
220+
return nil
221+
}
222+
outputs := make([]string, len(p.ExpectedOutputs))
223+
copy(outputs, p.ExpectedOutputs)
224+
slices.Sort(outputs)
225+
return outputs
166226
}
167227

168228
func (p *Provisioner) Params() []string {
169-
return nil
229+
if p.SupportedParams == nil {
230+
return nil
231+
}
232+
params := make([]string, len(p.SupportedParams))
233+
copy(params, p.SupportedParams)
234+
slices.Sort(params)
235+
return params
170236
}
171237

172238
func (e *envVarResourceTracker) Outputs() []string {
@@ -178,7 +244,7 @@ func (e *envVarResourceTracker) Params() []string {
178244
}
179245

180246
func (p *Provisioner) Description() string {
181-
return ""
247+
return p.ResDescription
182248
}
183249

184250
var _ provisioners.Provisioner = (*Provisioner)(nil)

internal/provisioners/envprov/envprov_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"testing"
2121

2222
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
2324

2425
"github.com/score-spec/score-compose/internal/provisioners"
2526
)
@@ -107,3 +108,73 @@ func TestProvisioner(t *testing.T) {
107108
})
108109

109110
}
111+
112+
func TestParse_success(t *testing.T) {
113+
t.Run("fully populated", func(t *testing.T) {
114+
p, err := Parse(map[string]interface{}{
115+
"uri": "local-env://example",
116+
"type": "environment",
117+
"class": "custom",
118+
"description": "pulls env vars",
119+
"supported_params": []string{"p1"},
120+
"expected_outputs": []string{"o2", "o1"},
121+
})
122+
require.NoError(t, err)
123+
assert.Equal(t, "local-env://example", p.Uri())
124+
assert.Equal(t, "environment", p.Type())
125+
assert.Equal(t, "custom", p.Class())
126+
assert.Equal(t, "pulls env vars", p.Description())
127+
assert.Equal(t, []string{"p1"}, p.Params())
128+
assert.Equal(t, []string{"o1", "o2"}, p.Outputs())
129+
})
130+
131+
t.Run("optional fields default", func(t *testing.T) {
132+
p, err := Parse(map[string]interface{}{"uri": "local-env://x"})
133+
require.NoError(t, err)
134+
assert.Equal(t, "environment", p.Type())
135+
assert.Equal(t, "(any)", p.Class())
136+
assert.Empty(t, p.Description())
137+
assert.Nil(t, p.Outputs())
138+
assert.Nil(t, p.Params())
139+
})
140+
141+
t.Run("non-environment type", func(t *testing.T) {
142+
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "secret"})
143+
require.NoError(t, err)
144+
assert.Equal(t, "secret", p.Type())
145+
})
146+
}
147+
148+
func TestParse_fail(t *testing.T) {
149+
for name, tc := range map[string]struct {
150+
in map[string]interface{}
151+
msg string
152+
}{
153+
"missing uri": {map[string]interface{}{"type": "environment"}, "uri not set"},
154+
"empty uri": {map[string]interface{}{"uri": "", "type": "environment"}, "uri not set"},
155+
"unknown field": {map[string]interface{}{"uri": "local-env://x", "bogus": true}, "field bogus not found"},
156+
} {
157+
t.Run(name, func(t *testing.T) {
158+
_, err := Parse(tc.in)
159+
require.Error(t, err)
160+
assert.Contains(t, err.Error(), tc.msg)
161+
})
162+
}
163+
}
164+
165+
func TestParsedProvisioner_match(t *testing.T) {
166+
t.Run("any class", func(t *testing.T) {
167+
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "environment"})
168+
require.NoError(t, err)
169+
assert.True(t, p.Match("environment.default#w.r"))
170+
assert.True(t, p.Match("environment.custom#w.r"))
171+
assert.False(t, p.Match("postgres.default#w.r"))
172+
})
173+
174+
t.Run("fixed class", func(t *testing.T) {
175+
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "environment", "class": "special"})
176+
require.NoError(t, err)
177+
assert.True(t, p.Match("environment.special#w.r"))
178+
assert.False(t, p.Match("environment.default#w.r"))
179+
})
180+
}

internal/provisioners/loader/load.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232

3333
"github.com/score-spec/score-compose/internal/provisioners"
3434
"github.com/score-spec/score-compose/internal/provisioners/cmdprov"
35+
"github.com/score-spec/score-compose/internal/provisioners/envprov"
3536
"github.com/score-spec/score-compose/internal/provisioners/templateprov"
3637
)
3738

@@ -67,6 +68,13 @@ func LoadProvisioners(raw []byte) ([]provisioners.Provisioner, error) {
6768
slog.Debug(fmt.Sprintf("Loaded provisioner %s", p.Uri()))
6869
out = append(out, p)
6970
}
71+
case "local-env":
72+
if p, err := envprov.Parse(m); err != nil {
73+
return nil, fmt.Errorf("%d: %s: failed to parse: %w", i, uri, err)
74+
} else {
75+
slog.Debug(fmt.Sprintf("Loaded provisioner %s", p.Uri()))
76+
out = append(out, p)
77+
}
7078
default:
7179
return nil, fmt.Errorf("%d: unsupported provisioner type '%s'", i, u.Scheme)
7280
}

0 commit comments

Comments
 (0)