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
6 changes: 6 additions & 0 deletions internal/command/default.provisioners.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -994,3 +994,9 @@
- database
- username
- password

# The default environment provisioner resolves environment resource references by looking up OS environment
# variables at generate time. The accessed variables are tracked and can be written to a skeleton .env file.
- uri: local-env://default-provisioners/environment
type: environment
description: Pulls values out of the local environment as outputs to an environment resource
34 changes: 27 additions & 7 deletions internal/command/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,25 @@ arguments.
slog.Info(fmt.Sprintf("Successfully loaded %d resource provisioners", len(loadedProvisioners)))
}

// append the env var provisioner
environmentProvisioner := new(envprov.Provisioner)
loadedProvisioners = append(loadedProvisioners, environmentProvisioner)
// Find the provisioner responsible for the "environment" resource type. A project may supply
// its own (via any provisioner kind declared with type: environment), in which case it is used
// as-is. Only when no provisioner handles the environment type do we fall back to the built-in
// envprov.Provisioner, for backward compatibility with projects initialized before the default
// environment provisioner entry existed.
var environmentProvisioner *envprov.Provisioner
hasEnvironmentProvisioner := false
for _, p := range loadedProvisioners {
if p.Type() == "environment" {
hasEnvironmentProvisioner = true
// Only the built-in implementation exposes Accessed(), used below to write the env file.
environmentProvisioner, _ = p.(*envprov.Provisioner)
break
}
}
if !hasEnvironmentProvisioner {
environmentProvisioner = new(envprov.Provisioner)
loadedProvisioners = append(loadedProvisioners, environmentProvisioner)
}

currentState, err = currentState.WithPrimedResources()
if err != nil {
Expand Down Expand Up @@ -399,10 +415,14 @@ arguments.

if v, _ := cmd.Flags().GetString(generateCmdEnvFileFlag); v != "" {
content := new(strings.Builder)
for k := range environmentProvisioner.Accessed() {
_, _ = content.WriteString(k)
_, _ = content.WriteRune('=')
_, _ = content.WriteRune('\n')
// environmentProvisioner is nil when a custom provisioner handles the environment type;
// only the built-in envprov.Provisioner tracks accessed variables for the env file.
if environmentProvisioner != nil {
for k := range environmentProvisioner.Accessed() {
_, _ = content.WriteString(k)
_, _ = content.WriteRune('=')
_, _ = content.WriteRune('\n')
}
}
slog.Info(fmt.Sprintf("Writing env var file to '%s'", v))
if err := os.WriteFile(v, []byte(content.String()), 0644); err != nil {
Expand Down
76 changes: 71 additions & 5 deletions internal/provisioners/envprov/envprov.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
package envprov

import (
"bytes"
"context"
"fmt"
"maps"
"net/url"
"os"
"slices"
"strings"

"github.com/score-spec/score-go/framework"
"gopkg.in/yaml.v3"

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

// Parse loads a Provisioner from raw YAML map data, following the same pattern as cmdprov.Parse and templateprov.Parse.
func Parse(raw map[string]interface{}) (*Provisioner, error) {
p := new(Provisioner)
intermediate, _ := yaml.Marshal(raw)
dec := yaml.NewDecoder(bytes.NewReader(intermediate))
dec.KnownFields(true)
if err := dec.Decode(&p); err != nil {
return nil, err
}
if p.ProvisionerUri == "" {
return nil, fmt.Errorf("uri not set")
}
if p.ResType == "" {
p.ResType = "environment"
}
return p, nil
}

func (e *Provisioner) Uri() string {
if e.ProvisionerUri != "" {
return e.ProvisionerUri
}
return "builtin://environment"
}

func (e *Provisioner) Match(resUid framework.ResourceUid) bool {
return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
if e.ProvisionerUri == "" {
// Legacy mode: preserve original matching behavior
return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
}
// YAML-loaded mode: standard type/class matching (same as cmdprov/templateprov)
if resUid.Type() != e.ResType {
return false
}
if e.ResClass != nil && resUid.Class() != *e.ResClass {
return false
}
return true
}

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

func (p *Provisioner) Class() string {
if p.ProvisionerUri != "" && p.ResClass == nil {
return "(any)"
}
if p.ResClass != nil {
return *p.ResClass
}
return "default"
}

func (p *Provisioner) Type() string {
if p.ResType != "" {
return p.ResType
}
return "environment"
}

func (p *Provisioner) Outputs() []string {
return nil
if p.ExpectedOutputs == nil {
return nil
}
outputs := make([]string, len(p.ExpectedOutputs))
copy(outputs, p.ExpectedOutputs)
slices.Sort(outputs)
return outputs
}

func (p *Provisioner) Params() []string {
return nil
if p.SupportedParams == nil {
return nil
}
params := make([]string, len(p.SupportedParams))
copy(params, p.SupportedParams)
slices.Sort(params)
return params
}

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

func (p *Provisioner) Description() string {
return ""
return p.ResDescription
}

var _ provisioners.Provisioner = (*Provisioner)(nil)
Expand Down
71 changes: 71 additions & 0 deletions internal/provisioners/envprov/envprov_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

}

func TestParse_success(t *testing.T) {
t.Run("fully populated", func(t *testing.T) {
p, err := Parse(map[string]interface{}{
"uri": "local-env://example",
"type": "environment",
"class": "custom",
"description": "pulls env vars",
"supported_params": []string{"p1"},
"expected_outputs": []string{"o2", "o1"},
})
require.NoError(t, err)
assert.Equal(t, "local-env://example", p.Uri())
assert.Equal(t, "environment", p.Type())
assert.Equal(t, "custom", p.Class())
assert.Equal(t, "pulls env vars", p.Description())
assert.Equal(t, []string{"p1"}, p.Params())
assert.Equal(t, []string{"o1", "o2"}, p.Outputs())
})

t.Run("optional fields default", func(t *testing.T) {
p, err := Parse(map[string]interface{}{"uri": "local-env://x"})
require.NoError(t, err)
assert.Equal(t, "environment", p.Type())
assert.Equal(t, "(any)", p.Class())
assert.Empty(t, p.Description())
assert.Nil(t, p.Outputs())
assert.Nil(t, p.Params())
})

t.Run("non-environment type", func(t *testing.T) {
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "secret"})
require.NoError(t, err)
assert.Equal(t, "secret", p.Type())
})
}

func TestParse_fail(t *testing.T) {
for name, tc := range map[string]struct {
in map[string]interface{}
msg string
}{
"missing uri": {map[string]interface{}{"type": "environment"}, "uri not set"},
"empty uri": {map[string]interface{}{"uri": "", "type": "environment"}, "uri not set"},
"unknown field": {map[string]interface{}{"uri": "local-env://x", "bogus": true}, "field bogus not found"},
} {
t.Run(name, func(t *testing.T) {
_, err := Parse(tc.in)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.msg)
})
}
}

func TestParsedProvisioner_match(t *testing.T) {
t.Run("any class", func(t *testing.T) {
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "environment"})
require.NoError(t, err)
assert.True(t, p.Match("environment.default#w.r"))
assert.True(t, p.Match("environment.custom#w.r"))
assert.False(t, p.Match("postgres.default#w.r"))
})

t.Run("fixed class", func(t *testing.T) {
p, err := Parse(map[string]interface{}{"uri": "local-env://x", "type": "environment", "class": "special"})
require.NoError(t, err)
assert.True(t, p.Match("environment.special#w.r"))
assert.False(t, p.Match("environment.default#w.r"))
})
}
8 changes: 8 additions & 0 deletions internal/provisioners/loader/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (

"github.com/score-spec/score-compose/internal/provisioners"
"github.com/score-spec/score-compose/internal/provisioners/cmdprov"
"github.com/score-spec/score-compose/internal/provisioners/envprov"
"github.com/score-spec/score-compose/internal/provisioners/templateprov"
)

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