Add Compose-time variables: extension

Introduces a top-level `variables:` section plus `--var KEY=VALUE` and
`--var-file PATH` flags so users declare YAML-native, Compose-time
variables that drive interpolation. Variables are scoped to the root
file or to include entries, support cross-references between
declarations, and follow shell > CLI > YAML > included-file precedence.

Implemented entirely in the CLI plugin: a preprocessor strips the
extension keys, performs interpolation against the resolved scope, and
feeds rendered files to compose-go with its own interpolation disabled.
No upstream compose-go change is required.

Adds:
- pkg/variables/ (Scope, Coerce, LoadVarsFile, Render, Strip)
- `--var` / `--var-file` root-persistent flags
- `compose config --variables` extended with RESOLVED VALUE / SOURCE
  columns (existing JSON/YAML schema preserved via omitempty fields)
- E2E fixtures and tests under pkg/e2e/fixtures/variables/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Denys Dudko <warr.doge@gmail.com>
This commit is contained in:
Denys Dudko 2026-05-07 22:23:50 +03:00
parent 4f69a8c997
commit 33efbb4fca
No known key found for this signature in database
28 changed files with 2323 additions and 9 deletions

View file

@ -51,6 +51,7 @@ import (
"github.com/docker/compose/v5/pkg/compose"
"github.com/docker/compose/v5/pkg/remote"
"github.com/docker/compose/v5/pkg/utils"
"github.com/docker/compose/v5/pkg/variables"
)
const (
@ -144,6 +145,8 @@ type ProjectOptions struct {
WorkDir string
ProjectDir string
EnvFiles []string
Vars []string
VarFiles []string
Compatibility bool
Progress string
Offline bool
@ -227,6 +230,8 @@ func (o *ProjectOptions) addProjectFlags(f *pflag.FlagSet) {
f.StringArrayVar(&o.insecureRegistries, "insecure-registry", []string{}, "Use insecure registry to pull Compose OCI artifacts. Doesn't apply to images")
_ = f.MarkHidden("insecure-registry")
f.StringArrayVar(&o.EnvFiles, "env-file", defaultStringArrayVar(ComposeEnvFiles), "Specify an alternate environment file")
f.StringArrayVar(&o.Vars, "var", []string{}, "Set a Compose-time variable (KEY=VALUE), repeatable")
f.StringArrayVar(&o.VarFiles, "var-file", []string{}, "Load Compose-time variables from a YAML file (variables: map)")
f.StringVar(&o.ProjectDir, "project-directory", "", "Specify an alternate working directory\n(default: the path of the, first specified, Compose file)")
f.StringVar(&o.WorkDir, "workdir", "", "DEPRECATED! USE --project-directory INSTEAD.\nSpecify an alternate working directory\n(default: the path of the, first specified, Compose file)")
f.BoolVar(&o.Compatibility, "compatibility", false, "Run compose in backward compatibility mode")
@ -293,6 +298,21 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser
po = append(po, cli.WithResourceLoader(r))
}
// Strip Compose-time variable extension keys before compose-go
// schema-validates the model. Strip preserves `${VAR}` placeholders
// so callers like `--no-interpolate` and ExtractVariables behave as
// before.
if cleanup, configPaths, err := o.stripVariables(ctx, remotes); err == nil {
if cleanup != nil {
defer cleanup()
}
if configPaths != nil {
o = o.withConfigPaths(configPaths)
}
} else {
return nil, err
}
options, err := o.toProjectOptions(po...)
if err != nil {
return nil, err
@ -305,6 +325,35 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser
return options.LoadModel(ctx)
}
// withConfigPaths returns a copy of o with ConfigPaths replaced.
func (o *ProjectOptions) withConfigPaths(paths []string) *ProjectOptions {
cp := *o
cp.ConfigPaths = paths
return &cp
}
// stripVariables runs the strip-only preprocessor for code paths that
// load the raw Compose model (no body interpolation). Returns
// (cleanup, rendered config paths, err). Both rendered paths and
// cleanup are nil if no preprocessing happened.
func (o *ProjectOptions) stripVariables(ctx context.Context, remotes []loader.ResourceLoader) (func(), []string, error) {
discovered, err := o.toProjectOptions()
if err != nil {
// Discovery error is reported again by the actual load below.
return nil, nil, nil //nolint:nilerr
}
paths := append([]string{}, discovered.ConfigPaths...)
if len(paths) == 0 || anyRemoteConfigPath(remotes, paths) {
return nil, nil, nil
}
shell := buildShellLookup(o.EnvFiles, o.ProjectDir)
result, err := variables.Strip(ctx, paths, o.Vars, o.VarFiles, shell)
if err != nil {
return nil, nil, err
}
return result.Cleanup, result.ConfigPaths, nil
}
// ToProject loads a Compose project using the LoadProject API.
// Accepts optional cli.ProjectOptionsFn to control loader behavior.
func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) {
@ -335,10 +384,19 @@ func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, b
}
}
configPaths, workingDir, origConfigPaths, cleanup, err := o.preprocessVariables(ctx, remotes, &po)
if err != nil {
return nil, metrics, err
}
if cleanup != nil {
defer cleanup()
}
preprocessed := origConfigPaths != nil
loadOpts := api.ProjectLoadOptions{
ProjectName: o.ProjectName,
ConfigPaths: o.ConfigPaths,
WorkingDir: o.ProjectDir,
ConfigPaths: configPaths,
WorkingDir: workingDir,
EnvFiles: o.EnvFiles,
Profiles: o.Profiles,
Services: services,
@ -347,6 +405,8 @@ func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, b
Compatibility: o.Compatibility,
ProjectOptionsFns: po,
LoadListeners: []api.LoadListener{metricsListener},
Vars: o.Vars,
VarFiles: o.VarFiles,
OCI: api.OCIOptions{
InsecureRegistries: o.insecureRegistries,
},
@ -357,9 +417,99 @@ func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, b
return nil, metrics, err
}
// Replace tempdir-rooted ComposeFiles with the user's original
// paths so labels/output reflect what the user actually invoked.
if preprocessed && len(origConfigPaths) == len(project.ComposeFiles) {
for i := range project.ComposeFiles {
project.ComposeFiles[i] = origConfigPaths[i]
}
}
return project, metrics, nil
}
// preprocessVariables runs the Compose-time variables preprocessor.
// It returns (effectiveConfigPaths, effectiveWorkingDir,
// originalConfigPaths, cleanup, err). originalConfigPaths is non-nil
// only when preprocessing actually ran. The caller appends a loader
// option that skips compose-go's own interpolation in that case.
func (o *ProjectOptions) preprocessVariables(ctx context.Context, remotes []loader.ResourceLoader, po *[]cli.ProjectOptionsFn) ([]string, string, []string, func(), error) {
configPaths := o.ConfigPaths
workingDir := o.ProjectDir
discovered, err := o.toProjectOptions(*po...)
if err != nil {
return configPaths, workingDir, nil, nil, nil //nolint:nilerr // discovery failure is reported again by backend.LoadProject
}
discoveredPaths := append([]string{}, discovered.ConfigPaths...)
if len(discoveredPaths) == 0 {
return configPaths, workingDir, nil, nil, nil
}
if anyRemoteConfigPath(remotes, discoveredPaths) {
return configPaths, workingDir, nil, nil, nil
}
shell := buildShellLookup(o.EnvFiles, o.ProjectDir)
result, rerr := variables.Render(ctx, discoveredPaths, o.Vars, o.VarFiles, shell)
if rerr != nil {
return configPaths, workingDir, nil, nil, rerr
}
if workingDir == "" {
workingDir = filepath.Dir(discoveredPaths[0])
}
*po = append(*po, cli.WithLoadOptions(func(lo *loader.Options) {
lo.SkipInterpolation = true
}))
return result.ConfigPaths, workingDir, discoveredPaths, result.Cleanup, nil
}
func anyRemoteConfigPath(remotes []loader.ResourceLoader, paths []string) bool {
for _, p := range paths {
for _, r := range remotes {
if r.Accept(p) {
return true
}
}
}
return false
}
// buildShellLookup returns a function that mirrors compose-go's view
// of the environment: os.Environ overlaid with values loaded from
// .env files (os env wins per existing precedence).
func buildShellLookup(envFiles []string, projectDir string) func(string) (string, bool) {
env := composegoutils.GetAsEqualsMap(os.Environ())
files := append([]string{}, envFiles...)
if len(files) == 0 {
base := projectDir
if base == "" {
if pwd, err := os.Getwd(); err == nil {
base = pwd
}
}
if base != "" {
candidate := filepath.Join(base, ".env")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
files = append(files, candidate)
}
}
}
if len(files) > 0 {
fromFile, err := dotenv.GetEnvFromFile(env, files)
if err == nil {
for k, v := range fromFile {
if _, exists := env[k]; !exists {
env[k] = v
}
}
}
}
return func(name string) (string, bool) {
v, ok := env[name]
return v, ok
}
}
func (o *ProjectOptions) remoteLoaders(dockerCli command.Cli) []loader.ResourceLoader {
if o.Offline {
return nil

View file

@ -37,6 +37,7 @@ import (
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/compose"
"github.com/docker/compose/v5/pkg/variables"
)
type configOptions struct {
@ -524,22 +525,75 @@ func runVariables(ctx context.Context, dockerCli command.Cli, opts configOptions
return err
}
variables := template.ExtractVariables(model, template.DefaultPattern)
placeholders := template.ExtractVariables(model, template.DefaultPattern)
// Resolve Compose-time `variables:` so we can show their values
// next to placeholder metadata. Errors are non-fatal here — we
// fall back to placeholders-only output so this debug surface
// stays usable even on broken configs.
shell := buildShellLookup(opts.EnvFiles, opts.ProjectDir)
render, _ := variables.Render(ctx, opts.ConfigPaths, opts.Vars, opts.VarFiles, shell)
if render != nil {
defer render.Cleanup()
}
type variableInfo struct {
Name string `yaml:"name" json:"Name"`
DefaultValue string `yaml:"defaultvalue" json:"DefaultValue"`
PresenceValue string `yaml:"presencevalue" json:"PresenceValue"`
Required bool `yaml:"required" json:"Required"`
ResolvedValue string `yaml:"resolvedvalue,omitempty" json:"ResolvedValue,omitempty"`
Source string `yaml:"source,omitempty" json:"Source,omitempty"`
}
rows := map[string]*variableInfo{}
for name, v := range placeholders {
rows[name] = &variableInfo{
Name: name,
Required: v.Required,
DefaultValue: v.DefaultValue,
PresenceValue: v.PresenceValue,
}
}
if render != nil {
actives := map[string]variables.DebugEntry{}
for _, e := range render.Debug {
if e.Active {
actives[e.Name] = e
}
}
for name, e := range actives {
r, ok := rows[name]
if !ok {
r = &variableInfo{Name: name}
rows[name] = r
}
r.ResolvedValue = e.Resolved
r.Source = string(e.Source)
}
}
names := make([]string, 0, len(rows))
for n := range rows {
names = append(names, n)
}
sort.Strings(names)
if opts.Format == "yaml" {
result, err := yaml.Marshal(variables)
data, err := yaml.Marshal(rows)
if err != nil {
return err
}
fmt.Print(string(result))
fmt.Print(string(data))
return nil
}
return formatter.Print(variables, opts.Format, dockerCli.Out(), func(w io.Writer) {
for name, variable := range variables {
_, _ = fmt.Fprintf(w, "%s\t%t\t%s\t%s\n", name, variable.Required, variable.DefaultValue, variable.PresenceValue)
return formatter.Print(rows, opts.Format, dockerCli.Out(), func(w io.Writer) {
for _, n := range names {
r := rows[n]
_, _ = fmt.Fprintf(w, "%s\t%t\t%s\t%s\t%s\t%s\n",
r.Name, r.Required, r.DefaultValue, r.PresenceValue, r.ResolvedValue, r.Source)
}
}, "NAME", "REQUIRED", "DEFAULT VALUE", "ALTERNATE VALUE")
}, "NAME", "REQUIRED", "DEFAULT VALUE", "ALTERNATE VALUE", "RESOLVED VALUE", "SOURCE")
}
func runEnvironment(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {

View file

@ -71,6 +71,12 @@ type ProjectLoadOptions struct {
// This is optional - pass nil or empty slice if not needed.
LoadListeners []LoadListener
// Vars are CLI `--var KEY=VALUE` declarations applied during
// Compose-time variable preprocessing.
Vars []string
// VarFiles are CLI `--var-file PATH` declarations.
VarFiles []string
OCI OCIOptions
}

View file

@ -0,0 +1,6 @@
variables:
APP_VERSION: "1.4.2"
services:
api:
image: "ghcr.io/acme/api:${APP_VERSION}"

View file

@ -0,0 +1,6 @@
variables:
APP_VERSION: "from-yaml"
services:
api:
image: "ghcr.io/acme/api:${APP_VERSION}"

View file

@ -0,0 +1,2 @@
variables:
APP_VERSION: "from-cli-var-file"

View file

@ -0,0 +1,12 @@
variables:
PORT: 8080
ENABLED: true
TAG: "1.4.2"
services:
api:
image: "api:${TAG}"
ports:
- "${PORT}:80"
environment:
ENABLED: "${ENABLED}"

View file

@ -0,0 +1,9 @@
variables:
BASE: "acme"
IMAGE: "${BASE}/api"
TAG: "1.4.2"
FULL: "${IMAGE}:${TAG}"
services:
api:
image: "${FULL}"

View file

@ -0,0 +1,9 @@
variables_file:
- ./variables/base.yaml
- ./variables/dev.yaml
services:
api:
image: ghcr.io/acme/api:${APP_VERSION}
redis:
image: redis:${REDIS_VERSION}

View file

@ -0,0 +1,3 @@
variables:
APP_VERSION: "1.0.0"
REDIS_VERSION: "7.0"

View file

@ -0,0 +1,2 @@
variables:
APP_VERSION: "1.4.2"

View file

@ -0,0 +1,14 @@
variables:
REDIS_VERSION: "7.2"
include:
- path: ./redis/compose.yaml
variables:
REDIS_VERSION: "7.4"
- path: ./postgres/compose.yaml
variables_file:
- ./variables.yaml
services:
api:
image: api:latest

View file

@ -0,0 +1,3 @@
services:
postgres:
image: postgres:${POSTGRES_VERSION}

View file

@ -0,0 +1,2 @@
variables:
POSTGRES_VERSION: "16.2"

View file

@ -0,0 +1,3 @@
services:
redis:
image: redis:${REDIS_VERSION}

View file

@ -0,0 +1,9 @@
include:
- path: ./redis/compose.yaml
variables:
MODULE: "redis-only"
- path: ./postgres/compose.yaml
services:
api:
image: "api:latest"

View file

@ -0,0 +1,3 @@
services:
postgres:
image: "postgres:${MODULE:-empty}"

View file

@ -0,0 +1,3 @@
services:
redis:
image: redis:${MODULE}

View file

@ -0,0 +1,9 @@
variables:
APP_VERSION: "1.4.2"
REDIS_VERSION: "7.4"
services:
api:
image: ghcr.io/acme/api:${APP_VERSION}
redis:
image: redis:${REDIS_VERSION}

157
pkg/e2e/variables_test.go Normal file
View file

@ -0,0 +1,157 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e
import (
"strings"
"testing"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
)
const variablesProject = "compose-e2e-variables"
func TestComposeVariablesSimple(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/simple/compose.yaml",
"--project-name", variablesProject+"-simple",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:1.4.2"})
res.Assert(t, icmd.Expected{Out: "image: redis:7.4"})
}
func TestComposeVariablesFile(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/expanded/compose.yaml",
"--project-name", variablesProject+"-expanded",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:1.4.2"})
res.Assert(t, icmd.Expected{Out: "image: redis:7.0"})
}
func TestComposeVariablesCrossRef(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/cross-ref/compose.yaml",
"--project-name", variablesProject+"-crossref",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: acme/api:1.4.2"})
}
func TestComposeVariablesIncludeLocal(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/include-local/compose.yaml",
"--project-name", variablesProject+"-include",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: redis:7.4"})
res.Assert(t, icmd.Expected{Out: "image: postgres:16.2"})
}
func TestComposeVariablesNoLeakage(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/no-leakage/compose.yaml",
"--project-name", variablesProject+"-noleak",
"config",
)
// redis sees include-local MODULE
res.Assert(t, icmd.Expected{Out: "image: redis:redis-only"})
// postgres falls through to default
res.Assert(t, icmd.Expected{Out: "image: postgres:empty"})
}
func TestComposeVariablesCLIOverride(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/cli-override/compose.yaml",
"--project-name", variablesProject+"-cli",
"--var", "APP_VERSION=2.0.0",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:2.0.0"})
}
func TestComposeVariablesCLIVarFile(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/cli-var-file/compose.yaml",
"--project-name", variablesProject+"-clivarfile",
"--var-file", "./fixtures/variables/cli-var-file/overrides.yaml",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:from-cli-var-file"})
}
func TestComposeVariablesCLIVarBeatsCLIVarFile(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/cli-var-file/compose.yaml",
"--project-name", variablesProject+"-clibeatsfile",
"--var-file", "./fixtures/variables/cli-var-file/overrides.yaml",
"--var", "APP_VERSION=from-flag",
"config",
)
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:from-flag"})
}
func TestComposeVariablesShellEnvWins(t *testing.T) {
c := NewParallelCLI(t)
res := icmd.RunCmd(c.NewDockerComposeCmd(t,
"-f", "./fixtures/variables/cli-override/compose.yaml",
"--project-name", variablesProject+"-shell",
"--var", "APP_VERSION=from-cli",
"config",
), func(c *icmd.Cmd) { c.Env = append(c.Env, "APP_VERSION=from-shell") })
res.Assert(t, icmd.Expected{Out: "image: ghcr.io/acme/api:from-shell"})
}
func TestComposeVariablesCoercion(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/coercion/compose.yaml",
"--project-name", variablesProject+"-coerce",
"config",
)
out := res.Stdout()
// Compose normalizes the short port form into a long form.
assert.Assert(t, strings.Contains(out, "published: \"8080\""))
assert.Assert(t, strings.Contains(out, "target: 80"))
assert.Assert(t, strings.Contains(out, "ENABLED: \"true\""))
assert.Assert(t, strings.Contains(out, "image: api:1.4.2"))
}
func TestComposeVariablesConfigVariablesShowsResolved(t *testing.T) {
c := NewParallelCLI(t)
res := c.RunDockerComposeCmd(t,
"-f", "./fixtures/variables/simple/compose.yaml",
"--project-name", variablesProject+"-confvars",
"config", "--variables",
)
out := res.Stdout()
assert.Assert(t, strings.Contains(out, "RESOLVED VALUE"))
assert.Assert(t, strings.Contains(out, "SOURCE"))
assert.Assert(t, strings.Contains(out, "1.4.2"))
assert.Assert(t, strings.Contains(out, "root-inline"))
}

52
pkg/variables/coerce.go Normal file
View file

@ -0,0 +1,52 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"fmt"
"strconv"
)
// Coerce converts a YAML scalar value into the string form used during
// interpolation. Null values are rejected to force authors to declare
// an empty string explicitly when that is what they mean.
func Coerce(name string, v any) (string, error) {
switch x := v.(type) {
case nil:
return "", fmt.Errorf("variable %q has null value, declare a string (e.g. %q: \"\")", name, name)
case string:
return x, nil
case bool:
return strconv.FormatBool(x), nil
case int:
return strconv.FormatInt(int64(x), 10), nil
case int32:
return strconv.FormatInt(int64(x), 10), nil
case int64:
return strconv.FormatInt(x, 10), nil
case uint:
return strconv.FormatUint(uint64(x), 10), nil
case uint64:
return strconv.FormatUint(x, 10), nil
case float32:
return strconv.FormatFloat(float64(x), 'f', -1, 32), nil
case float64:
return strconv.FormatFloat(x, 'f', -1, 64), nil
default:
return "", fmt.Errorf("variable %q has unsupported value type %T (only scalars are allowed)", name, v)
}
}

View file

@ -0,0 +1,52 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"testing"
"gotest.tools/v3/assert"
)
func TestCoerce(t *testing.T) {
cases := []struct {
name string
in any
want string
wantErr string
}{
{name: "string", in: "hello", want: "hello"},
{name: "int", in: 8080, want: "8080"},
{name: "int64", in: int64(8080), want: "8080"},
{name: "true", in: true, want: "true"},
{name: "false", in: false, want: "false"},
{name: "float", in: 3.14, want: "3.14"},
{name: "null", in: nil, wantErr: "null value"},
{name: "list", in: []any{"a"}, wantErr: "unsupported value type"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Coerce(c.name, c.in)
if c.wantErr != "" {
assert.ErrorContains(t, err, c.wantErr)
return
}
assert.NilError(t, err)
assert.Equal(t, got, c.want)
})
}
}

153
pkg/variables/load.go Normal file
View file

@ -0,0 +1,153 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"fmt"
"os"
"go.yaml.in/yaml/v4"
)
// declaredBlock captures what was parsed out of a `variables:` mapping
// in a Compose file (or out of a standalone variables file). Only
// flat scalar entries are accepted; external files are loaded via
// the sibling `variables_file:` key, not from inside the block.
type declaredBlock struct {
// Inline holds key/value entries declared directly in the block,
// in declaration order.
Inline []rawEntry
}
// rawEntry is a name/value pair pre-coercion.
type rawEntry struct {
Name string
Value any
}
// parseDeclaredOrdered walks a yaml.Node directly to keep declaration
// order intact (essential for cross-variable resolution).
func parseDeclaredOrdered(node *yaml.Node) (*declaredBlock, error) {
if node == nil {
return &declaredBlock{}, nil
}
if node.Kind != yaml.MappingNode {
return nil, fmt.Errorf("expected mapping for `variables:`, got kind %d", node.Kind)
}
out := &declaredBlock{}
for i := 0; i+1 < len(node.Content); i += 2 {
k := node.Content[i]
v := node.Content[i+1]
if k.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("variable name must be a scalar, got kind %d", k.Kind)
}
var raw any
if err := v.Decode(&raw); err != nil {
return nil, fmt.Errorf("variable %q: %w", k.Value, err)
}
out.Inline = append(out.Inline, rawEntry{Name: k.Value, Value: raw})
}
return out, nil
}
// nodeToStringList accepts scalar string or sequence of scalar strings.
func nodeToStringList(node *yaml.Node) ([]string, error) {
if node == nil {
return nil, nil
}
switch node.Kind {
case yaml.ScalarNode:
return []string{node.Value}, nil
case yaml.SequenceNode:
out := make([]string, 0, len(node.Content))
for _, c := range node.Content {
if c.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("expected scalar in list, got kind %d", c.Kind)
}
out = append(out, c.Value)
}
return out, nil
}
return nil, fmt.Errorf("expected scalar or sequence, got kind %d", node.Kind)
}
// LoadVarsFile reads an external variables YAML file. The file must
// have a top-level `variables:` mapping (flat scalars only). The
// returned slice preserves declaration order. Coercion happens here.
func LoadVarsFile(path string, source Source) ([]Entry, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil, fmt.Errorf("parse %s: empty document", path)
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("parse %s: expected mapping at top level", path)
}
var varsNode *yaml.Node
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Kind == yaml.ScalarNode && root.Content[i].Value == "variables" {
varsNode = root.Content[i+1]
break
}
}
if varsNode == nil {
return nil, fmt.Errorf("parse %s: top-level `variables:` key missing", path)
}
block, err := parseDeclaredOrdered(varsNode)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
out := make([]Entry, 0, len(block.Inline))
for _, e := range block.Inline {
v, err := Coerce(e.Name, e.Value)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
out = append(out, Entry{Name: e.Name, Value: v, Source: source, From: path})
}
return out, nil
}
// ParseCLIVars converts repeated `KEY=VALUE` arguments into entries.
// Empty value is allowed; missing `=` is an error.
func ParseCLIVars(args []string) ([]Entry, error) {
out := make([]Entry, 0, len(args))
for _, a := range args {
k, v, ok := splitKV(a)
if !ok {
return nil, fmt.Errorf("--var expects KEY=VALUE, got %q", a)
}
out = append(out, Entry{Name: k, Value: v, Source: SourceCLIVar, From: "--var " + a})
}
return out, nil
}
func splitKV(s string) (string, string, bool) {
for i := 0; i < len(s); i++ {
if s[i] == '=' {
return s[:i], s[i+1:], true
}
}
return "", "", false
}

View file

@ -0,0 +1,96 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"os"
"path/filepath"
"testing"
"gotest.tools/v3/assert"
)
func TestLoadVarsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.yaml")
body := []byte(`variables:
APP_VERSION: "1.4.2"
PORT: 8080
ENABLED: true
`)
assert.NilError(t, os.WriteFile(path, body, 0o644))
entries, err := LoadVarsFile(path, SourceRootFile)
assert.NilError(t, err)
assert.Equal(t, len(entries), 3)
got := map[string]string{}
for _, e := range entries {
got[e.Name] = e.Value
}
assert.Equal(t, got["APP_VERSION"], "1.4.2")
assert.Equal(t, got["PORT"], "8080")
assert.Equal(t, got["ENABLED"], "true")
}
func TestLoadVarsFilePreservesOrder(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.yaml")
body := []byte(`variables:
Z: "1"
A: "2"
M: "3"
`)
assert.NilError(t, os.WriteFile(path, body, 0o644))
entries, err := LoadVarsFile(path, SourceRootFile)
assert.NilError(t, err)
names := []string{entries[0].Name, entries[1].Name, entries[2].Name}
assert.DeepEqual(t, names, []string{"Z", "A", "M"})
}
func TestLoadVarsFileMissingTopLevel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.yaml")
assert.NilError(t, os.WriteFile(path, []byte("services: {}\n"), 0o644))
_, err := LoadVarsFile(path, SourceRootFile)
assert.ErrorContains(t, err, "top-level `variables:` key missing")
}
func TestLoadVarsFileNullErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.yaml")
body := []byte("variables:\n FOO: ~\n")
assert.NilError(t, os.WriteFile(path, body, 0o644))
_, err := LoadVarsFile(path, SourceRootFile)
assert.ErrorContains(t, err, "null value")
}
func TestParseCLIVars(t *testing.T) {
entries, err := ParseCLIVars([]string{"FOO=bar", "EMPTY=", "WITH=eq=ual"})
assert.NilError(t, err)
assert.Equal(t, len(entries), 3)
assert.Equal(t, entries[0].Value, "bar")
assert.Equal(t, entries[1].Value, "")
assert.Equal(t, entries[2].Value, "eq=ual")
}
func TestParseCLIVarsRejectsMissingEquals(t *testing.T) {
_, err := ParseCLIVars([]string{"FOO"})
assert.ErrorContains(t, err, "KEY=VALUE")
}

709
pkg/variables/preprocess.go Normal file
View file

@ -0,0 +1,709 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/compose-spec/compose-go/v2/template"
"github.com/sirupsen/logrus"
"go.yaml.in/yaml/v4"
)
// Result is what a successful Render returns.
type Result struct {
// ConfigPaths are the rendered file paths (in tempdir) to feed
// to compose-go in place of the user's originals.
ConfigPaths []string
// Cleanup removes the tempdir. Always non-nil; safe to defer.
Cleanup func()
// Debug lists every variable declaration encountered, with
// provenance, in the order it was discovered.
Debug []DebugEntry
// Resolved is the per-name final value used during interpolation
// (root scope). Shell-overridden vars show the shell value.
Resolved map[string]string
}
// DebugEntry is a debug-mode dump row.
type DebugEntry struct {
Name string
Value string // raw declared value (before substitution)
Resolved string // post-substitution / shell-overridden value
Source Source
From string
Active bool // true if this entry is the winning source for its name
}
// Render preprocesses Compose YAML files: extracts `variables:` blocks
// and include-local variable declarations, performs interpolation with
// the resolved scope, strips the extension keys, and writes cleaned
// YAML files to a tempdir. The returned ConfigPaths can be fed to
// compose-go (which must be told to skip its own interpolation).
//
// shell may be nil; when nil, os.LookupEnv is used.
func Render(ctx context.Context, configPaths []string, cliVars []string, cliVarFiles []string, shell func(string) (string, bool)) (*Result, error) {
return renderInternal(ctx, configPaths, cliVars, cliVarFiles, shell, true)
}
// Strip behaves like Render but does NOT substitute string leaves; it
// only strips the extension keys (`variables:`, `variables_file:`,
// include-local `variables`/`variables_file`) so compose-go can load
// the file without rejecting the unknown top-level keys. Use this for
// callers that need the raw model (e.g. `--no-interpolate`,
// `ExtractVariables`).
func Strip(ctx context.Context, configPaths []string, cliVars []string, cliVarFiles []string, shell func(string) (string, bool)) (*Result, error) {
return renderInternal(ctx, configPaths, cliVars, cliVarFiles, shell, false)
}
func renderInternal(ctx context.Context, configPaths []string, cliVars []string, cliVarFiles []string, shell func(string) (string, bool), interpolate bool) (*Result, error) {
if shell == nil {
shell = os.LookupEnv
}
tempdir, err := os.MkdirTemp("", "compose-variables-")
if err != nil {
return nil, fmt.Errorf("create tempdir: %w", err)
}
cleanup := func() {
_ = os.RemoveAll(tempdir)
}
rt := &renderer{
ctx: ctx,
tempdir: tempdir,
visited: map[string]string{},
shell: shell,
interpolate: interpolate,
}
rootScope, err := buildCLIScope(cliVars, cliVarFiles, shell)
if err != nil {
cleanup()
return nil, err
}
roots, err := readRootFiles(configPaths)
if err != nil {
cleanup()
return nil, err
}
if err := mergeRootVariables(rootScope, roots); err != nil {
cleanup()
return nil, err
}
rendered := make([]string, 0, len(roots))
for _, r := range roots {
out, err := rt.processFile(r.absPath, r.node, rootScope)
if err != nil {
cleanup()
return nil, err
}
rendered = append(rendered, out)
}
resolved, err := rootScope.Resolve()
if err != nil {
cleanup()
return nil, err
}
debug := buildDebug(rootScope, resolved, shell)
return &Result{
ConfigPaths: rendered,
Cleanup: cleanup,
Debug: debug,
Resolved: resolved,
}, nil
}
type rootFile struct {
absPath string
node *yaml.Node
topVars *declaredBlock
varFiles []string // parsed list from a top-level `variables_file:` key
}
func buildCLIScope(cliVars, cliVarFiles []string, shell func(string) (string, bool)) (*Scope, error) {
cliEntries, err := ParseCLIVars(cliVars)
if err != nil {
return nil, err
}
var cliFileEntries []Entry
for _, p := range cliVarFiles {
abs, err := filepath.Abs(p)
if err != nil {
return nil, err
}
entries, err := LoadVarsFile(abs, SourceCLIVarFile)
if err != nil {
return nil, err
}
cliFileEntries = append(cliFileEntries, entries...)
}
scope := NewScope(shell)
scope.AddAll(cliEntries)
scope.AddAll(cliFileEntries)
return scope, nil
}
func readRootFiles(configPaths []string) ([]rootFile, error) {
roots := make([]rootFile, 0, len(configPaths))
for _, p := range configPaths {
abs, err := filepath.Abs(p)
if err != nil {
return nil, err
}
node, err := readYAMLNode(abs)
if err != nil {
return nil, err
}
var block *declaredBlock
if varsNode := stripTopLevelKey(node, "variables"); varsNode != nil {
block, err = parseDeclaredOrdered(varsNode)
if err != nil {
return nil, fmt.Errorf("%s: %w", abs, err)
}
}
var varFiles []string
if vfNode := stripTopLevelKey(node, "variables_file"); vfNode != nil {
varFiles, err = nodeToStringList(vfNode)
if err != nil {
return nil, fmt.Errorf("%s: variables_file: %w", abs, err)
}
}
roots = append(roots, rootFile{absPath: abs, node: node, topVars: block, varFiles: varFiles})
}
return roots, nil
}
func mergeRootVariables(scope *Scope, roots []rootFile) error {
// Inline first (higher precedence than variables_file entries).
for _, r := range roots {
if r.topVars == nil {
continue
}
for _, e := range r.topVars.Inline {
val, err := Coerce(e.Name, e.Value)
if err != nil {
return fmt.Errorf("%s: %w", r.absPath, err)
}
scope.Add(Entry{Name: e.Name, Value: val, Source: SourceRootInline, From: r.absPath})
}
}
// variables_file: later files win, so add later FIRST.
for _, r := range roots {
for i := len(r.varFiles) - 1; i >= 0; i-- {
fp := r.varFiles[i]
if !filepath.IsAbs(fp) {
fp = filepath.Join(filepath.Dir(r.absPath), fp)
}
entries, err := LoadVarsFile(fp, SourceRootFile)
if err != nil {
return err
}
scope.AddAll(entries)
}
}
return nil
}
// renderer carries shared state for a Render invocation.
type renderer struct {
ctx context.Context
tempdir string
// visited maps abs source path → rendered abs path. Prevents
// re-rendering and detects cycles.
visited map[string]string
shell func(string) (string, bool)
// interpolate controls whether string leaves get substituted.
// When false, the renderer only strips extension keys (used by
// `compose config --no-interpolate` / `--variables`).
interpolate bool
}
// processFile renders one Compose file (root or included) into the
// tempdir using parentScope. Returns the rendered absolute path.
func (rt *renderer) processFile(absPath string, node *yaml.Node, parentScope *Scope) (string, error) {
if existing, ok := rt.visited[absPath]; ok {
return existing, nil
}
rt.visited[absPath] = ""
scope := parentScope.Inherit()
if err := mergeOwnVariables(scope, absPath, node); err != nil {
return "", err
}
if includeNode := findTopLevelKey(node, "include"); includeNode != nil {
if err := rt.processIncludes(absPath, includeNode, scope); err != nil {
return "", err
}
}
// Decode (now-stripped) node into a generic model so we can
// substitute string leaves and re-marshal.
var model any
if err := node.Decode(&model); err != nil {
return "", fmt.Errorf("%s: decode: %w", absPath, err)
}
if rt.interpolate {
resolved, err := scope.Resolve()
if err != nil {
return "", fmt.Errorf("%s: %w", absPath, err)
}
mapping := scope.Mapping(resolved)
model = substituteAny(model, mapping)
}
// Write rendered file.
out := mirrorPath(rt.tempdir, absPath)
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return "", err
}
data, err := yaml.Marshal(model)
if err != nil {
return "", fmt.Errorf("%s: marshal: %w", absPath, err)
}
if err := os.WriteFile(out, data, 0o644); err != nil {
return "", err
}
rt.visited[absPath] = out
return out, nil
}
// processIncludes walks the include: sequence, mutating each entry
// to (a) strip variables/variables_file keys, (b) rewrite path to
// rendered tempdir paths, (c) preserve project_directory pointing at
// the original directory so relative paths inside included files
// still resolve.
func (rt *renderer) processIncludes(parentAbsPath string, includeNode *yaml.Node, parentScope *Scope) error {
if includeNode.Kind != yaml.SequenceNode {
return fmt.Errorf("%s: `include:` must be a sequence", parentAbsPath)
}
for _, entry := range includeNode.Content {
if err := rt.processIncludeEntry(parentAbsPath, entry, parentScope); err != nil {
return err
}
}
return nil
}
func (rt *renderer) processIncludeEntry(parentAbsPath string, entry *yaml.Node, parentScope *Scope) error {
normalizeIncludeEntry(entry)
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("%s: include entry must be a string or mapping", parentAbsPath)
}
localVarsNode := stripMappingKey(entry, "variables")
localVarsFileNode := stripMappingKey(entry, "variables_file")
pathNode := mappingValue(entry, "path")
if pathNode == nil {
return fmt.Errorf("%s: include entry missing `path:`", parentAbsPath)
}
paths, err := nodeToStringList(pathNode)
if err != nil {
return fmt.Errorf("%s: include.path: %w", parentAbsPath, err)
}
if len(paths) == 0 {
return fmt.Errorf("%s: include.path is empty", parentAbsPath)
}
parentDir := filepath.Dir(parentAbsPath)
absPaths := make([]string, len(paths))
for i, p := range paths {
if filepath.IsAbs(p) {
absPaths[i] = p
} else {
absPaths[i] = filepath.Join(parentDir, p)
}
}
origProjectDir := resolveIncludeProjectDir(entry, parentDir, absPaths[0])
includeScope, err := buildIncludeScope(parentScope, parentAbsPath, localVarsNode, localVarsFileNode, origProjectDir)
if err != nil {
return err
}
renderedAbs, err := rt.renderIncludedFiles(absPaths, includeScope)
if err != nil {
return err
}
rewriteIncludeEntry(entry, renderedAbs, origProjectDir)
return nil
}
// normalizeIncludeEntry converts string-form (`include: ./x.yaml`) into
// mapping form so we can attach project_directory consistently.
func normalizeIncludeEntry(entry *yaml.Node) {
if entry.Kind != yaml.ScalarNode {
return
}
pathStr := entry.Value
*entry = yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "path"},
{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: pathStr},
}},
},
}
}
// resolveIncludeProjectDir returns the original-FS project_directory
// for an include entry. Defaults to dir of first path.
func resolveIncludeProjectDir(entry *yaml.Node, parentDir, firstAbsPath string) string {
if pdNode := mappingValue(entry, "project_directory"); pdNode != nil && pdNode.Value != "" {
if filepath.IsAbs(pdNode.Value) {
return pdNode.Value
}
return filepath.Join(parentDir, pdNode.Value)
}
return filepath.Dir(firstAbsPath)
}
// buildIncludeScope builds the scope used for an include's contents.
func buildIncludeScope(parentScope *Scope, parentAbsPath string, localVarsNode, localVarsFileNode *yaml.Node, origProjectDir string) (*Scope, error) {
includeScope := parentScope.Inherit()
if localVarsNode != nil {
block, err := parseDeclaredOrdered(localVarsNode)
if err != nil {
return nil, fmt.Errorf("%s: include.variables: %w", parentAbsPath, err)
}
for _, e := range block.Inline {
val, err := Coerce(e.Name, e.Value)
if err != nil {
return nil, fmt.Errorf("%s: include.variables: %w", parentAbsPath, err)
}
includeScope.Add(Entry{Name: e.Name, Value: val, Source: SourceIncludeInline, From: parentAbsPath})
}
}
if localVarsFileNode != nil {
files, err := nodeToStringList(localVarsFileNode)
if err != nil {
return nil, fmt.Errorf("%s: include.variables_file: %w", parentAbsPath, err)
}
// Resolve against project_directory (or include entry dir if unset).
for i := len(files) - 1; i >= 0; i-- {
fp := files[i]
if !filepath.IsAbs(fp) {
fp = filepath.Join(origProjectDir, fp)
}
entries, err := LoadVarsFile(fp, SourceIncludeFile)
if err != nil {
return nil, err
}
includeScope.AddAll(entries)
}
}
return includeScope, nil
}
func (rt *renderer) renderIncludedFiles(absPaths []string, includeScope *Scope) ([]string, error) {
out := make([]string, len(absPaths))
for i, abs := range absPaths {
incNode, err := readYAMLNode(abs)
if err != nil {
return nil, err
}
if reserved, ok := rt.visited[abs]; ok && reserved == "" {
return nil, fmt.Errorf("include cycle detected at %s", abs)
}
rendered, err := rt.processFile(abs, incNode, includeScope)
if err != nil {
return nil, err
}
out[i] = rendered
}
return out, nil
}
func rewriteIncludeEntry(entry *yaml.Node, renderedAbs []string, origProjectDir string) {
newPathSeq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, p := range renderedAbs {
newPathSeq.Content = append(newPathSeq.Content, &yaml.Node{
Kind: yaml.ScalarNode, Tag: "!!str", Value: p,
})
}
setMappingValue(entry, "path", newPathSeq)
setMappingValue(entry, "project_directory", &yaml.Node{
Kind: yaml.ScalarNode, Tag: "!!str", Value: origProjectDir,
})
}
// mergeOwnVariables strips and merges a file's own top-level
// `variables:` and `variables_file:` blocks into scope.
func mergeOwnVariables(scope *Scope, absPath string, node *yaml.Node) error {
if varsNode := stripTopLevelKey(node, "variables"); varsNode != nil {
block, err := parseDeclaredOrdered(varsNode)
if err != nil {
return fmt.Errorf("%s: %w", absPath, err)
}
for _, e := range block.Inline {
val, err := Coerce(e.Name, e.Value)
if err != nil {
return fmt.Errorf("%s: %w", absPath, err)
}
scope.Add(Entry{Name: e.Name, Value: val, Source: SourceIncludedTopLevel, From: absPath})
}
}
if vfNode := stripTopLevelKey(node, "variables_file"); vfNode != nil {
files, err := nodeToStringList(vfNode)
if err != nil {
return fmt.Errorf("%s: variables_file: %w", absPath, err)
}
for i := len(files) - 1; i >= 0; i-- {
fp := files[i]
if !filepath.IsAbs(fp) {
fp = filepath.Join(filepath.Dir(absPath), fp)
}
entries, err := LoadVarsFile(fp, SourceIncludedTopLevel)
if err != nil {
return err
}
scope.AddAll(entries)
}
}
return nil
}
// readYAMLNode reads a YAML file and returns its document node.
func readYAMLNode(path string) (*yaml.Node, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if doc.Kind == 0 {
// empty file
doc.Kind = yaml.DocumentNode
doc.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
}
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil, fmt.Errorf("parse %s: not a YAML document", path)
}
return &doc, nil
}
// stripTopLevelKey removes (key, value) from the document's root
// mapping and returns the value node (or nil).
func stripTopLevelKey(doc *yaml.Node, key string) *yaml.Node {
if doc == nil || len(doc.Content) == 0 {
return nil
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Kind == yaml.ScalarNode && root.Content[i].Value == key {
val := root.Content[i+1]
root.Content = append(root.Content[:i], root.Content[i+2:]...)
return val
}
}
return nil
}
// findTopLevelKey returns the value node for a top-level key, or nil.
func findTopLevelKey(doc *yaml.Node, key string) *yaml.Node {
if doc == nil || len(doc.Content) == 0 {
return nil
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Kind == yaml.ScalarNode && root.Content[i].Value == key {
return root.Content[i+1]
}
}
return nil
}
// stripMappingKey removes (key, value) from a mapping node and
// returns the removed value (or nil).
func stripMappingKey(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Kind == yaml.ScalarNode && m.Content[i].Value == key {
val := m.Content[i+1]
m.Content = append(m.Content[:i], m.Content[i+2:]...)
return val
}
}
return nil
}
// mappingValue returns the value node for a key (or nil).
func mappingValue(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Kind == yaml.ScalarNode && m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// setMappingValue replaces or appends key=val on a mapping node.
func setMappingValue(m *yaml.Node, key string, val *yaml.Node) {
if m == nil || m.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Kind == yaml.ScalarNode && m.Content[i].Value == key {
m.Content[i+1] = val
return
}
}
m.Content = append(m.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
val,
)
}
// substituteAny recursively walks a generic decoded YAML model and
// runs template substitution on every string leaf using mapping.
func substituteAny(v any, mapping template.Mapping) any {
switch x := v.(type) {
case map[string]any:
for k, item := range x {
x[k] = substituteAny(item, mapping)
}
return x
case map[any]any:
out := make(map[string]any, len(x))
for k, item := range x {
ks, ok := k.(string)
if !ok {
ks = fmt.Sprint(k)
}
out[ks] = substituteAny(item, mapping)
}
return out
case []any:
for i, item := range x {
x[i] = substituteAny(item, mapping)
}
return x
case string:
out, err := template.SubstituteWithOptions(x, missWarnMapping(mapping), template.WithoutLogging)
if err != nil {
// Required-error or syntax error: keep original; let
// compose-go surface it later if it cares. Still log.
logrus.Warnf("interpolation error on %q: %v", x, err)
return x
}
return out
}
return v
}
// missWarnMapping wraps a mapping so unresolved names log a warning
// before returning empty/false. Mirrors compose-go's interpolation
// default behavior.
func missWarnMapping(inner template.Mapping) template.Mapping {
return func(name string) (string, bool) {
v, ok := inner(name)
if !ok {
logrus.Warnf("variable %q is not declared and not set in shell environment", name)
}
return v, ok
}
}
// mirrorPath returns the tempdir path that mirrors absPath.
func mirrorPath(tempdir, absPath string) string {
clean := absPath
if filepath.IsAbs(clean) {
// Strip any drive letter / leading separator portably.
vol := filepath.VolumeName(clean)
clean = strings.TrimPrefix(clean, vol)
clean = strings.TrimPrefix(clean, string(filepath.Separator))
}
return filepath.Join(tempdir, clean)
}
// buildDebug produces a per-name debug entry list. Active=true marks
// the winning entry. Shell-overridden vars get an extra synthetic
// entry tagged SourceShell.
func buildDebug(scope *Scope, resolved map[string]string, shell func(string) (string, bool)) []DebugEntry {
winners := map[string]Entry{}
for _, e := range scope.Winners() {
winners[e.Name] = e
}
out := make([]DebugEntry, 0, len(scope.All()))
for _, e := range scope.All() {
w := winners[e.Name]
active := w.Source == e.Source && w.From == e.From && w.Value == e.Value
var resolvedVal string
if v, ok := shell(e.Name); ok {
resolvedVal = v
} else if v, ok := resolved[e.Name]; ok {
resolvedVal = v
}
out = append(out, DebugEntry{
Name: e.Name,
Value: e.Value,
Resolved: resolvedVal,
Source: e.Source,
From: e.From,
Active: active && !shellHas(shell, e.Name),
})
}
// If shell overrides a declared name, add a SourceShell entry on
// top so debug output reflects who actually won.
for name := range winners {
if v, ok := shell(name); ok {
out = append(out, DebugEntry{
Name: name,
Value: v,
Resolved: v,
Source: SourceShell,
From: "shell environment",
Active: true,
})
}
}
return out
}
func shellHas(shell func(string) (string, bool), name string) bool {
_, ok := shell(name)
return ok
}

View file

@ -0,0 +1,429 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"os"
"path/filepath"
"testing"
"gotest.tools/v3/assert"
)
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
full := filepath.Join(dir, name)
assert.NilError(t, os.MkdirAll(filepath.Dir(full), 0o755))
assert.NilError(t, os.WriteFile(full, []byte(body), 0o644))
return full
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
assert.NilError(t, err)
return string(b)
}
func noShell(string) (string, bool) { return "", false }
func TestRenderInlineVariables(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
APP_VERSION: "1.4.2"
services:
api:
image: ghcr.io/acme/api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, !contains(rendered, "variables:"))
assert.Assert(t, contains(rendered, "ghcr.io/acme/api:1.4.2"))
}
func TestRenderVariablesFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "vars/base.yaml", `variables:
APP_VERSION: "1.0.0"
REDIS_VERSION: "7.0"
`)
writeFile(t, dir, "vars/dev.yaml", `variables:
APP_VERSION: "1.4.2"
`)
root := writeFile(t, dir, "compose.yaml", `variables_file:
- ./vars/base.yaml
- ./vars/dev.yaml
services:
api:
image: api:${APP_VERSION}
environment:
REDIS: redis:${REDIS_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:1.4.2"))
assert.Assert(t, contains(rendered, "redis:7.0"))
}
func TestRenderInlineOverridesVariablesFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "vars.yaml", `variables:
APP_VERSION: "from-file"
`)
root := writeFile(t, dir, "compose.yaml", `variables:
APP_VERSION: "from-inline"
variables_file:
- ./vars.yaml
services:
api:
image: api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:from-inline"))
}
func TestRenderVariablesFileScalarForm(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "vars.yaml", `variables:
APP_VERSION: "1.4.2"
`)
root := writeFile(t, dir, "compose.yaml", `variables_file: ./vars.yaml
services:
api:
image: api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:1.4.2"))
}
func TestRenderStripsTopLevelVariablesFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "vars.yaml", `variables:
APP_VERSION: "1.4.2"
`)
root := writeFile(t, dir, "compose.yaml", `variables_file:
- ./vars.yaml
services:
api:
image: api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
// compose-go would reject unknown top-level keys; verify
// `variables_file:` is stripped from the rendered output.
assert.Assert(t, !contains(rendered, "variables_file"))
}
func TestRenderCLIVarOverridesYAML(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
APP_VERSION: "yaml"
services:
api:
image: api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, []string{"APP_VERSION=cli"}, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:cli"))
}
func TestRenderShellOverridesCLI(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
APP_VERSION: "yaml"
services:
api:
image: api:${APP_VERSION}
`)
shell := func(name string) (string, bool) {
if name == "APP_VERSION" {
return "shellv", true
}
return "", false
}
r, err := Render(t.Context(), []string{root}, []string{"APP_VERSION=cli"}, nil, shell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:shellv"))
}
func TestRenderVarFile(t *testing.T) {
dir := t.TempDir()
varFile := writeFile(t, dir, "override.yaml", `variables:
APP_VERSION: "from-var-file"
`)
root := writeFile(t, dir, "compose.yaml", `variables:
APP_VERSION: "yaml"
services:
api:
image: api:${APP_VERSION}
`)
r, err := Render(t.Context(), []string{root}, nil, []string{varFile}, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "api:from-var-file"))
}
func TestRenderCrossRef(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
BASE: "acme"
IMAGE: "${BASE}/api"
TAG: "1.4.2"
FULL: "${IMAGE}:${TAG}"
services:
api:
image: ${FULL}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(rendered, "image: acme/api:1.4.2"))
}
func TestRenderCrossRefCycleErrors(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
A: "${B}"
B: "${A}"
services:
api:
image: ${A}
`)
_, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.ErrorContains(t, err, "cyclic")
}
func TestRenderIncludeLocalOverridesRoot(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "redis/compose.yaml", `services:
redis:
image: redis:${REDIS_VERSION}
`)
root := writeFile(t, dir, "compose.yaml", `variables:
REDIS_VERSION: "7.2"
include:
- path: ./redis/compose.yaml
variables:
REDIS_VERSION: "7.4"
services: {}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
// Find the rendered redis file by walking the tempdir.
var redisRendered string
_ = filepath.Walk(filepath.Dir(r.ConfigPaths[0]), func(path string, _ os.FileInfo, _ error) error {
if filepath.Base(path) == "compose.yaml" && contains(path, "redis") {
redisRendered = path
}
return nil
})
assert.Assert(t, redisRendered != "")
body := readFile(t, redisRendered)
assert.Assert(t, contains(body, "redis:7.4"))
}
func TestRenderNoLeakageBetweenSiblings(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "redis/compose.yaml", `services:
redis:
image: redis:${MODULE}
`)
writeFile(t, dir, "postgres/compose.yaml", `services:
postgres:
image: postgres:${MODULE}
`)
root := writeFile(t, dir, "compose.yaml", `include:
- path: ./redis/compose.yaml
variables:
MODULE: "redis-only"
- path: ./postgres/compose.yaml
services: {}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
// Postgres's MODULE must NOT pick up redis's include-local var.
var redisFile, pgFile string
_ = filepath.Walk(filepath.Dir(r.ConfigPaths[0]), func(path string, _ os.FileInfo, _ error) error {
switch {
case contains(path, "/redis/") && filepath.Base(path) == "compose.yaml":
redisFile = path
case contains(path, "/postgres/") && filepath.Base(path) == "compose.yaml":
pgFile = path
}
return nil
})
assert.Assert(t, redisFile != "" && pgFile != "")
assert.Assert(t, contains(readFile(t, redisFile), "redis-only"))
assert.Assert(t, !contains(readFile(t, pgFile), "redis-only"))
}
func TestRenderIncludedFileTopLevelScopedToItself(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "redis/compose.yaml", `variables:
REDIS_LOCAL: "internal"
services:
redis:
image: redis:${REDIS_LOCAL}
`)
root := writeFile(t, dir, "compose.yaml", `include:
- path: ./redis/compose.yaml
services:
api:
image: api:${REDIS_LOCAL}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rootBody := readFile(t, r.ConfigPaths[0])
// REDIS_LOCAL undeclared at root → empty.
assert.Assert(t, !contains(rootBody, "internal"))
assert.Assert(t, !contains(rootBody, "REDIS_LOCAL"))
// Redis include sees its own top-level variable.
var redisFile string
_ = filepath.Walk(filepath.Dir(r.ConfigPaths[0]), func(path string, _ os.FileInfo, _ error) error {
if contains(path, "/redis/") && filepath.Base(path) == "compose.yaml" {
redisFile = path
}
return nil
})
assert.Assert(t, redisFile != "")
assert.Assert(t, contains(readFile(t, redisFile), "redis:internal"))
}
func TestRenderCoerceTypes(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `variables:
PORT: 8080
ENABLED: true
services:
api:
image: api
ports:
- "${PORT}:80"
environment:
ENABLED: "${ENABLED}"
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
body := readFile(t, r.ConfigPaths[0])
assert.Assert(t, contains(body, "8080:80"))
assert.Assert(t, contains(body, "ENABLED: \"true\"") || contains(body, "ENABLED: 'true'") || contains(body, "ENABLED: true"))
}
func TestRenderStripsExtensionKeysFromIncludes(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "redis/compose.yaml", `services:
redis:
image: redis:${REDIS_VERSION}
`)
root := writeFile(t, dir, "compose.yaml", `include:
- path: ./redis/compose.yaml
variables:
REDIS_VERSION: "7.4"
services: {}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
// `variables:` must be stripped from the include entry so
// compose-go's strict schema doesn't reject it.
assert.Assert(t, !contains(rendered, "REDIS_VERSION"))
}
func TestRenderUndeclaredWarnsAndEmpties(t *testing.T) {
dir := t.TempDir()
root := writeFile(t, dir, "compose.yaml", `services:
api:
image: api:${NEVER_DECLARED}
`)
r, err := Render(t.Context(), []string{root}, nil, nil, noShell)
assert.NilError(t, err)
defer r.Cleanup()
rendered := readFile(t, r.ConfigPaths[0])
assert.Assert(t, !contains(rendered, "NEVER_DECLARED"))
assert.Assert(t, !contains(rendered, "${"))
}
func contains(s, sub string) bool {
return indexOf(s, sub) >= 0
}
func indexOf(s, sub string) int {
if sub == "" {
return 0
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

227
pkg/variables/scope.go Normal file
View file

@ -0,0 +1,227 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package variables implements the Compose-time `variables:` extension.
// It preprocesses Compose YAML files: extracts variable declarations,
// resolves cross-references, performs interpolation, strips extension
// keys, and emits cleaned YAML for compose-go to consume.
package variables
import (
"fmt"
"strings"
"github.com/compose-spec/compose-go/v2/template"
)
// Source identifies where a variable's value originated.
type Source string
const (
SourceShell Source = "shell"
SourceCLIVar Source = "cli-var"
SourceCLIVarFile Source = "cli-var-file"
SourceIncludeInline Source = "include-inline"
SourceIncludeFile Source = "include-file"
SourceRootInline Source = "root-inline"
SourceRootFile Source = "root-file"
SourceIncludedTopLevel Source = "included-top-level"
)
// Priority orders Source from highest (lowest int) to lowest (highest int).
// Used to pick the winner when the same name is declared in multiple
// sources.
func priority(s Source) int {
switch s {
case SourceShell:
return 0
case SourceCLIVar:
return 1
case SourceCLIVarFile:
return 2
case SourceIncludeInline:
return 3
case SourceIncludeFile:
return 4
case SourceRootInline:
return 5
case SourceRootFile:
return 6
case SourceIncludedTopLevel:
return 7
}
return 99
}
// Entry is a single declared variable with its raw (un-substituted)
// value and provenance.
type Entry struct {
Name string
Value string
Source Source
From string // file path or CLI arg
}
// Scope groups all variable declarations visible while interpolating a
// single Compose file. Multiple sources may declare the same name; the
// one with the highest precedence (lowest priority value) wins.
type Scope struct {
// raw holds the winning (un-resolved) Entry per variable name.
raw map[string]Entry
// order is declaration order for stable iteration.
order []string
// all keeps every declaration for debug output.
all []Entry
// shell looks up shell environment values.
shell func(string) (string, bool)
}
// NewScope builds an empty scope. shell may be nil (treated as miss).
func NewScope(shell func(string) (string, bool)) *Scope {
if shell == nil {
shell = func(string) (string, bool) { return "", false }
}
return &Scope{
raw: map[string]Entry{},
shell: shell,
}
}
// Add records an Entry. If a higher-priority Source already wrote
// this name, the new entry is dropped (but still kept in the debug
// log). Within the same Source, the FIRST writer wins (callers feed
// per-source entries in declaration order).
func (s *Scope) Add(e Entry) {
s.all = append(s.all, e)
cur, present := s.raw[e.Name]
if !present {
s.raw[e.Name] = e
s.order = append(s.order, e.Name)
return
}
// Replace only if new entry's source has higher priority (lower number).
if priority(e.Source) < priority(cur.Source) {
s.raw[e.Name] = e
}
}
// AddAll appends a slice of entries.
func (s *Scope) AddAll(entries []Entry) {
for _, e := range entries {
s.Add(e)
}
}
// All returns every declaration that was added, in input order
// (regardless of which one is "winning"). Used for debug output.
func (s *Scope) All() []Entry {
out := make([]Entry, len(s.all))
copy(out, s.all)
return out
}
// Winners returns each name's winning entry, in declaration order.
func (s *Scope) Winners() []Entry {
out := make([]Entry, 0, len(s.order))
for _, n := range s.order {
out = append(out, s.raw[n])
}
return out
}
// Resolve substitutes cross-variable references and returns a mapping
// of name → final value. Shell environment values override declared
// values. Cycles produce an error.
func (s *Scope) Resolve() (map[string]string, error) {
out := map[string]string{}
resolving := []string{}
var resolve func(name string) (string, bool, error)
resolve = func(name string) (string, bool, error) {
if v, ok := s.shell(name); ok {
return v, true, nil
}
e, ok := s.raw[name]
if !ok {
return "", false, nil
}
if v, done := out[name]; done {
return v, true, nil
}
for _, r := range resolving {
if r == name {
chain := append(append([]string{}, resolving...), name)
return "", false, fmt.Errorf("cyclic variable reference: %s", strings.Join(chain, " -> "))
}
}
resolving = append(resolving, name)
defer func() { resolving = resolving[:len(resolving)-1] }()
var inner error
substituted, err := template.Substitute(e.Value, func(n string) (string, bool) {
if inner != nil {
return "", false
}
v, ok, rerr := resolve(n)
if rerr != nil {
inner = rerr
return "", false
}
return v, ok
})
if inner != nil {
return "", false, inner
}
if err != nil {
return "", false, fmt.Errorf("variable %q: %w", name, err)
}
out[name] = substituted
return substituted, true, nil
}
for _, n := range s.order {
if _, _, err := resolve(n); err != nil {
return nil, err
}
}
return out, nil
}
// Mapping builds the template.Mapping used to substitute Compose
// body fields. Shell wins over declared values; undeclared+unset
// names are reported as missing (caller decides warn/empty).
func (s *Scope) Mapping(resolved map[string]string) template.Mapping {
return func(name string) (string, bool) {
if v, ok := s.shell(name); ok {
return v, true
}
if v, ok := resolved[name]; ok {
return v, true
}
return "", false
}
}
// Inherit returns a child Scope that has all of parent's declarations
// already merged in (lower priority than child's own additions). The
// child does NOT mutate the parent.
func (s *Scope) Inherit() *Scope {
c := NewScope(s.shell)
c.all = append(c.all, s.all...)
for n, e := range s.raw {
c.raw[n] = e
}
c.order = append(c.order, s.order...)
return c
}

134
pkg/variables/scope_test.go Normal file
View file

@ -0,0 +1,134 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package variables
import (
"strings"
"testing"
"gotest.tools/v3/assert"
)
func TestScopeFirstWriterWinsWithinSource(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "FOO", Value: "first", Source: SourceRootInline})
s.Add(Entry{Name: "FOO", Value: "second", Source: SourceRootInline})
resolved, err := s.Resolve()
assert.NilError(t, err)
assert.Equal(t, resolved["FOO"], "first")
}
func TestScopeHigherSourceOverridesLower(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "FOO", Value: "from-root-file", Source: SourceRootFile})
s.Add(Entry{Name: "FOO", Value: "from-cli", Source: SourceCLIVar})
resolved, err := s.Resolve()
assert.NilError(t, err)
assert.Equal(t, resolved["FOO"], "from-cli")
}
func TestScopeShellWinsOverDeclared(t *testing.T) {
shell := func(name string) (string, bool) {
if name == "FOO" {
return "from-shell", true
}
return "", false
}
s := NewScope(shell)
s.Add(Entry{Name: "FOO", Value: "from-yaml", Source: SourceRootInline})
resolved, _ := s.Resolve()
mapping := s.Mapping(resolved)
v, ok := mapping("FOO")
assert.Assert(t, ok)
assert.Equal(t, v, "from-shell")
}
func TestScopeCrossRefForward(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "BASE", Value: "acme", Source: SourceRootInline})
s.Add(Entry{Name: "IMAGE", Value: "${BASE}/api", Source: SourceRootInline})
s.Add(Entry{Name: "TAG", Value: "1.4.2", Source: SourceRootInline})
s.Add(Entry{Name: "FULL", Value: "${IMAGE}:${TAG}", Source: SourceRootInline})
resolved, err := s.Resolve()
assert.NilError(t, err)
assert.Equal(t, resolved["FULL"], "acme/api:1.4.2")
}
func TestScopeCrossRefCycle(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "A", Value: "${B}", Source: SourceRootInline})
s.Add(Entry{Name: "B", Value: "${A}", Source: SourceRootInline})
_, err := s.Resolve()
assert.ErrorContains(t, err, "cyclic")
}
func TestScopeMissingVariableLeavesEmpty(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "FOO", Value: "${UNDEFINED}", Source: SourceRootInline})
resolved, err := s.Resolve()
assert.NilError(t, err)
assert.Equal(t, resolved["FOO"], "")
}
func TestScopeShellFillsCrossRef(t *testing.T) {
shell := func(name string) (string, bool) {
if name == "BASE" {
return "shellbase", true
}
return "", false
}
s := NewScope(shell)
s.Add(Entry{Name: "FULL", Value: "${BASE}/x", Source: SourceRootInline})
resolved, err := s.Resolve()
assert.NilError(t, err)
assert.Equal(t, resolved["FULL"], "shellbase/x")
}
func TestScopeInheritDoesNotMutateParent(t *testing.T) {
parent := NewScope(nil)
parent.Add(Entry{Name: "FOO", Value: "parent", Source: SourceRootInline})
child := parent.Inherit()
child.Add(Entry{Name: "FOO", Value: "child", Source: SourceIncludeInline})
pr, _ := parent.Resolve()
cr, _ := child.Resolve()
assert.Equal(t, pr["FOO"], "parent")
assert.Equal(t, cr["FOO"], "child")
}
func TestScopeWinnersListedInDeclarationOrder(t *testing.T) {
s := NewScope(nil)
s.Add(Entry{Name: "B", Value: "2", Source: SourceRootInline})
s.Add(Entry{Name: "A", Value: "1", Source: SourceRootInline})
s.Add(Entry{Name: "C", Value: "3", Source: SourceRootInline})
winners := s.Winners()
got := make([]string, len(winners))
for i, w := range winners {
got[i] = w.Name
}
assert.Equal(t, strings.Join(got, ","), "B,A,C")
}