mirror of
https://github.com/docker/compose.git
synced 2026-05-13 13:58:02 +00:00
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:
parent
4f69a8c997
commit
33efbb4fca
28 changed files with 2323 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
6
pkg/e2e/fixtures/variables/cli-override/compose.yaml
Normal file
6
pkg/e2e/fixtures/variables/cli-override/compose.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
variables:
|
||||
APP_VERSION: "1.4.2"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: "ghcr.io/acme/api:${APP_VERSION}"
|
||||
6
pkg/e2e/fixtures/variables/cli-var-file/compose.yaml
Normal file
6
pkg/e2e/fixtures/variables/cli-var-file/compose.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
variables:
|
||||
APP_VERSION: "from-yaml"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: "ghcr.io/acme/api:${APP_VERSION}"
|
||||
2
pkg/e2e/fixtures/variables/cli-var-file/overrides.yaml
Normal file
2
pkg/e2e/fixtures/variables/cli-var-file/overrides.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
variables:
|
||||
APP_VERSION: "from-cli-var-file"
|
||||
12
pkg/e2e/fixtures/variables/coercion/compose.yaml
Normal file
12
pkg/e2e/fixtures/variables/coercion/compose.yaml
Normal 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}"
|
||||
9
pkg/e2e/fixtures/variables/cross-ref/compose.yaml
Normal file
9
pkg/e2e/fixtures/variables/cross-ref/compose.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
variables:
|
||||
BASE: "acme"
|
||||
IMAGE: "${BASE}/api"
|
||||
TAG: "1.4.2"
|
||||
FULL: "${IMAGE}:${TAG}"
|
||||
|
||||
services:
|
||||
api:
|
||||
image: "${FULL}"
|
||||
9
pkg/e2e/fixtures/variables/expanded/compose.yaml
Normal file
9
pkg/e2e/fixtures/variables/expanded/compose.yaml
Normal 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}
|
||||
3
pkg/e2e/fixtures/variables/expanded/variables/base.yaml
Normal file
3
pkg/e2e/fixtures/variables/expanded/variables/base.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
variables:
|
||||
APP_VERSION: "1.0.0"
|
||||
REDIS_VERSION: "7.0"
|
||||
2
pkg/e2e/fixtures/variables/expanded/variables/dev.yaml
Normal file
2
pkg/e2e/fixtures/variables/expanded/variables/dev.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
variables:
|
||||
APP_VERSION: "1.4.2"
|
||||
14
pkg/e2e/fixtures/variables/include-local/compose.yaml
Normal file
14
pkg/e2e/fixtures/variables/include-local/compose.yaml
Normal 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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_VERSION}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
variables:
|
||||
POSTGRES_VERSION: "16.2"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
services:
|
||||
redis:
|
||||
image: redis:${REDIS_VERSION}
|
||||
9
pkg/e2e/fixtures/variables/no-leakage/compose.yaml
Normal file
9
pkg/e2e/fixtures/variables/no-leakage/compose.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
include:
|
||||
- path: ./redis/compose.yaml
|
||||
variables:
|
||||
MODULE: "redis-only"
|
||||
- path: ./postgres/compose.yaml
|
||||
|
||||
services:
|
||||
api:
|
||||
image: "api:latest"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
services:
|
||||
postgres:
|
||||
image: "postgres:${MODULE:-empty}"
|
||||
3
pkg/e2e/fixtures/variables/no-leakage/redis/compose.yaml
Normal file
3
pkg/e2e/fixtures/variables/no-leakage/redis/compose.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
services:
|
||||
redis:
|
||||
image: redis:${MODULE}
|
||||
9
pkg/e2e/fixtures/variables/simple/compose.yaml
Normal file
9
pkg/e2e/fixtures/variables/simple/compose.yaml
Normal 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
157
pkg/e2e/variables_test.go
Normal 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
52
pkg/variables/coerce.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
pkg/variables/coerce_test.go
Normal file
52
pkg/variables/coerce_test.go
Normal 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
153
pkg/variables/load.go
Normal 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
|
||||
}
|
||||
96
pkg/variables/load_test.go
Normal file
96
pkg/variables/load_test.go
Normal 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
709
pkg/variables/preprocess.go
Normal 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
|
||||
}
|
||||
429
pkg/variables/preprocess_test.go
Normal file
429
pkg/variables/preprocess_test.go
Normal 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
227
pkg/variables/scope.go
Normal 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
134
pkg/variables/scope_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue