This document consolidates the full refactoring story for
compose-spec/compose-go's switch from a map[string]any loader to a
yaml.Node based pipeline (v3 module path). It supersedes the
incremental notes (plan.md at commit
60e91d3
and the subsequent "postMergeLegacy / mapstructure removal" addendum):
the design choices that emerged in those two passes are folded in from
the start.
It has three sections:
- Motivation — why the legacy
map[string]anyloader is unfit and what the new pipeline guarantees. - Target architecture & implementation phases — a single ordered plan: types, pipeline, public API, file layout, and the work to do on each phase.
- Validation strategy — the docker/compose#13799 draft-PR loop that gates every iteration on the real downstream consumer's CI.
The legacy loader parses each Compose file into a map[string]any,
merges the resulting maps, runs interpolation on the merged
dictionary, and finally decodes it into typed Go structs through
mapstructure. The pipeline discards per-node provenance early:
-
Lost context after merge. Once two files are merged into a single
map[string]any, there is no way to tell which leaf came from which file. The information needed to interpret that leaf correctly — the working directory of its source file, and the environment variables that were in scope when it was parsed — is no longer reachable. -
include:cannot honor its own context. Aninclude:directive may redefine the working directory and provide additional environment variables viainclude.env_file. With the legacy loader the included file's content is folded into the global map and the include-specific working directory and environment are dropped. Any later operation that should consume them (path resolution, environment resolution of a service declared in the included file) operates on the wrong context. -
WithServicesEnvironmentResolvedis incomplete. It iterates the typedservice.EnvFilesbut its lookup only seesproject.Environmentandservice.Environment. If a service comes from a file included withinclude.env_file: …, the variables the include provided are not reachable.env_file: ${VAR}declared in an included service cannot be resolved whenVARwas provided by the include itself. -
Poor diagnostics. Errors reported during decoding or validation reference only the merged document, not the original file. A user inspecting a loaded
Projectcannot ask "which file does this value come from, and on which line?". -
mapstructuredependency. Map-based decoding requires a third-party reflection library and a parallel set ofDecodeMapstructuremethods on every custom type, duplicating the logic that already exists for YAML decoding. The four global hooks (decoderHook,cast,nameServices,secretConfigDecoderHook) sit between the merged tree and the typed project and behave subtly differently from a realUnmarshalYAML. -
Marshal / unmarshal round-trips. Schema validation goes through
map[string]any, which means the merged tree has to be marshaled and re-parsed for validation purposes — wasteful and a source of subtle differences between the validated representation and the decoded one.
- A single in-memory representation of the merged project as a
*yaml.Nodetree. Interpolation, path resolution, canonicalisation, default-value injection, schema and semantic validation, and typed decoding all operate on that tree. - A
NodeContextattached to every node (via amap[*yaml.Node]*NodeContextcarried byComposeModel) that records the source file, working directory, scoped environment, and parent context. Merge is leaf-preserving so the context survives every pass. - Typed decoding via
Decode(project)plusUnmarshalYAML(*yaml.Node)on every custom type. Nomapstructure, noDecodeMapstructure, noTransformhelper, no global decoder hooks. - Lazy
env_file/label_filereading:LoadWithContextreturns a project whoseEnvFile.Contextcarries enough information to resolve the file later, but does not open it. Consumers that only manipulate a subset of services never pay for env files they do not use. - A diagnostic surface (
project.OriginOf(path)→{Source, Line, Column}) that letscompose config, IDEs, and language servers explain where each value comes from.
// types/node_context.go
type NodeContext struct {
Source string // yaml file path the node was parsed from
WorkingDir string // base directory to resolve relative paths
Env Mapping // environment variables in scope for interpolation
Parent *NodeContext
}
// Origin pairs a node context with a position inside its source file.
type Origin struct {
Source string
Line int
Column int
}// loader/model.go
type Layer struct {
Root *yaml.Node
Context *types.NodeContext
}
type ComposeModel struct {
layers []*Layer
contexts map[*yaml.Node]*types.NodeContext
merged *yaml.Node // set once mergeLayers has run
dict map[string]any // lazy, computed by Dict()
opts *Options
configDetails types.ConfigDetails
loadedFiles []string
}
func (m *ComposeModel) Merged() *yaml.Node // the merged tree
func (m *ComposeModel) Dict() map[string]any // lazy via schema.NodeToInterface(m.merged)Invariants:
- Once a node is registered in
contexts, the entry survives every later operation (merge, interpolation, path resolution, canonicalisation, defaults injection, schema validation). MergeNodesnever clones leaf scalar nodes. Only mappings and sequences may be cloned, and only when actually modified.- Included trees are never mutated by pre-processing passes. No path
rewriting, no bare-variable expansion. Whatever the loader needs at
the end, it derives by walking the merged tree and consulting
contexts[node]. dictis only ever computed on demand and is never used as the source of truth inside the loader.
LoadWithContext(ctx, configDetails, opts...)
│
└── load(ctx, configDetails, opts) -> *ComposeModel
├── parseLayers // []*Layer with per-file NodeContext
│ // multi-document YAMLs split into N layers
├── expandIncludes // adds extra Layers, parent context preserved
│ // emits "include" Listener events
├── resolveSecretsConfigsEnvLayer
│ // canonical content: nodes, per layer
├── inferExtends // resolves extends in-tree
├── mergeLayers // single *yaml.Node, contexts intact
├── stripResetOverride // !reset / !override semantics
├── interpolate // per-scalar, NodeContext.Env via contexts[node]
├── resolvePaths // per-path scalar, NodeContext.WorkingDir
│ // env_file.path EXCLUDED — resolved lazily
├── canonicalizeNode // ports, volumes, secrets/configs, dns, gpus,
│ // devices, ssh, extra_hosts, depends_on,
│ // env_file, extends, ulimits, legacy external
├── setDefaultValuesNode
├── omitEmptyNode
├── stripVersion
├── enforceUnicityNode
├── validateNode // semantic checks (yaml.Node)
└── schema.ValidateNode // JSON Schema on the yaml.Node tree
└── ModelToProject(model) -> *types.Project
├── model.Merged().Decode(project) // UnmarshalYAML on every type
├── nameServices/Networks/Volumes/... // post-decode key-naming pass
├── processProjectExtensions // yaml round-trip for x-extensions
├── attachEnvFileContexts
├── attachLabelFileContexts
├── normalizeProject // operates on *Project
└── checkConsistency
WithServicesEnvironmentResolved and WithServicesLabelsResolved
are not called by LoadWithContext. The consumer calls them
explicitly when (and only when) it actually needs the resolved
environments.
| Symbol | Shape |
|---|---|
loader.load (private) |
(ctx, configDetails, opts) -> (*ComposeModel, error) |
loader.LoadWithContext |
unchanged signature, body = load + ModelToProject |
loader.LoadModel |
(ctx, configDetails, opts) -> (*ComposeModel, error) — replaces LoadModelWithContext |
loader.ModelToProject |
(model *ComposeModel) -> (*types.Project, error) |
loader.LoadAnnotatedYaml |
rebuilt on top of load + the diagnostic API |
cli.ProjectOptions.LoadModel |
returns *ComposeModel |
loader.Transform |
not introduced — no mapstructure detour |
mapstructure hooks (decoderHook, cast, nameServices, secretConfigDecoderHook) |
not introduced |
go.mod |
github.com/go-viper/mapstructure/v2 never added to v3 |
loader/
model.go # Layer, ComposeModel, accessors
loader.go # LoadWithContext, LoadModel, load
resolve.go # parseLayers, expandIncludes, mergeLayers
include.go # contextual graft + "include" listener event
extends.go # pre-merge extends resolution
interpolate.go # per-scalar interpolation
paths.go # single-pass path resolution
canonical_node.go # canonicalisation on yaml.Node
defaults_node.go # default-value injection on yaml.Node
omit_empty_node.go # omit-empty pass on yaml.Node
normalize_project.go # post-decode normalize on *Project
annotate.go # diagnostic / provenance output
validation/
validate_node.go # semantic validation on yaml.Node
schema/
validate_node.go # JSON Schema on yaml.Node
node_to_interface.go # bridge used for Dict() and error formatting only
types/
node_context.go # NodeContext, Origin
yaml.go # NodeError, NodeErrorf, WrapNodeError,
# WithSource, resolveYAMLNode, hasKey, findYAMLKey
*.go # every custom type implements UnmarshalYAML(*yaml.Node)
override/
merge_node.go # yaml.Node based merger
unicity_node.go # EnforceUnicity on yaml.Node
reset_override.go # !reset / !override on yaml.Node
Phases are sequenced so that each one ends on a green test suite and a green docker/compose#13799 CI run (see §4). One commit per phase or per sub-step.
types/node_context.go:NodeContext,Origin.loader/model.go:Layer,ComposeModel,Merged(),Dict()(lazy viaschema.NodeToInterface).types/yaml.go: shared helpers —NodeError,NodeErrorf,WrapNodeError,WithSource,resolveYAMLNode,hasKey,findYAMLKey.
parseLayers: one Layer per parsed file. Multi-document YAML files expand into one Layer per document from the start, so OCI round-trips (publish/reload) round-trip cleanly.override.MergeNodes: leaf-preserving merge into a single*yaml.Node. Mappings and sequences may be cloned; scalars never are.mergeLayerspopulatesComposeModel.mergedand never drops entries fromcontexts.
- Each
include:entry is parsed as its own Layer with its ownNodeContext(Source,WorkingDir,Envfrominclude.env_file,Parentset). - The graft is appended to
layers— the included tree is not mutated, no path rewriting, no bare-variable expansion. opts.ProcessEvent("include", …)is emitted at graft time so downstream listeners (publish, OCI checks) see the include.
- Resolved pre-merge, in-tree, using
NodeContextrather than path string surgery. Targets reachable through includes use the include's context.
stripResetOverrideruns on the merged tree, after merge, before interpolation. Tags are consumed from the YAML.
- One pass that walks the merged tree. Each scalar consults
contexts[node].Env. Compose-style escape sequences and defaults are handled per scalar. No marshal/unmarshal.
- Single pass driven by
NodeContext.WorkingDir. Coversbuild,volumes(bind sources),env_file.pathis excluded — resolved lazily, see Phase 13.
loader/canonical_node.go, walker keyed bytree.Path, mirroring every entry intransform/canonical.go: ports, volumes, secrets/configs, dns / StringOrList, gpus, devices, ssh, extra_hosts, depends_on, env_file, extends, ulimits, legacyexternal: {name}.
loader/defaults_node.go, ported fromtransform/defaults.go.
loader/omit_empty_node.go. Empty scalars and empty mappings/sequences are removed pre-decode; empty slices that the schema demands keep their representation (see commitc9079e9).
validation/validate_node.go, ported fromvalidation/validation.go. IncludesEnforceUnicityNodecovering every indexer ofoverride/uncity.go.
schema.ValidateNode(merged)runs JSON Schema directly on the merged tree.schema.NodeToInterfaceexists only as a bridge for the lazyDict()accessor and error formatting; the validation itself does not go through it.
Clusters, in order, harvested and adapted from prior art on
compose-spec/compose-go#854
(commit e074115 ships the bulk of the implementations and the
types/yaml.go helpers — these are imported, the loader-pipeline
choices of that PR are not):
- Cluster 1 — primitives:
bytes.go,cpus.go,duration.go,command.go,options.go. - Cluster 2 — collections:
mapping.go,labels.go,stringOrList.go,hostList.go. - Cluster 3 — service fields:
device.go,ssh.go,healthcheck.go,models.go. - Cluster 4 —
types/types.go: ports, volumes, secrets, configs, extends, env_file, ulimits.
Each type's single UnmarshalYAML covers what mapstructure's
decoderHook + cast + per-type DecodeMapstructure used to do:
- string ↔ bool/int/float coercion: per-type tag handling.
- string-or-list / string-or-mapping shapes: per-type.
EnvFile.Context: written here fromcontexts[node].secret.contentis already in canonical form (Phase 8 / stepresolveSecretsConfigsEnvLayer), so the legacysecretConfigDecoderHookshape never appears.
func ModelToProject(model *ComposeModel) (*types.Project, error) {
project := newProject(model)
if err := model.Merged().Decode(project); err != nil { return nil, err }
nameServices(project); nameNetworks(project); ... // ex-mapstructure hook
processProjectExtensions(project, model.Merged()) // yaml round-trip
attachEnvFileContexts(project, model)
attachLabelFileContexts(project, model)
normalizeProject(project) // ex-loader/normalize
checkConsistency(project)
return project, nil
}No Transform. No mapstructure hooks. processProjectExtensions
is the yaml round-trip from PR #854 (one Marshal/Unmarshal per
x- extension, isolated to this single helper).
EnvFile.Contextcarries theNodeContextresolved at decode time.WithServicesEnvironmentResolved(ctx, project, opts)usesEnvFile.Context.WorkingDirfor base directory andEnvFile.Context.Env(which already includesinclude.env_file-provided variables) for lookups.- Same shape for
WithServicesLabelsResolved.
origin, ok := project.OriginOf("services.api.image")
// origin == {Source: "/path/to/override.yaml", Line: 14, Column: 12}- Backed by
ComposeModel.contexts+ the merged tree'sLine/Column. - Powers
cmd --debug(annotated yaml output, already wired on2609dbc),compose configprovenance comments, and IDE jump-to-definition.
- Delete
loader/post_merge.go, the legacyload/loadYamlModel/loadYamlFile/processRawYaml/convertToStringKeysRecursive/fixEmptyNotNull/loadIncludeConfigpaths inloader/loader.go,loader/include.go,loader/extends.go,loader/environment.go,loader/omitEmpty.go,loader/normalize.go. go mod tidy— confirmmapstructurenever entersgo.modon v3.- Update
CHANGELOGand README examples to the new public API (LoadModel,ModelToProject(*ComposeModel), noTransform).
A library-only test suite is not enough: compose-go's contract is
defined by what docker/compose actually does with a loaded project
(compose up, compose config, compose publish, OCI round-trips).
A draft PR kept open on docker/compose runs the full
docker/compose CI against our in-flight compose-go branch.
docker/compose#13799
(branch test-compose-go-v3, marked DNM) adds a replace directive
in go.mod:
// Test compose-spec/compose-go#874 (v3 yaml.Node-based loader)
replace github.com/compose-spec/compose-go/v3 => github.com/ndeloof/compose-go/v3 v3.0.0-<timestamp>-<hash>
Every compose-go commit on ndeloof/v3-yaml-node-context is followed
by a one-line bump of that pseudo-version on
ndeloof/test-compose-go-v3, signed-off and pushed. GitHub Actions
re-runs the docker/compose CI matrix:
validate (lint),validate (docs)— fast.binary buildandbin-image-test buildper OS/arch — compile parity.e2e (plugin, oldstable | stable)/e2e (standalone, oldstable | stable)— 10–12 min each, the real signal: every interesting compose-go behaviour ends up exercised throughpkg/e2e(publish, OCI round-trip, secrets from include, env interpolation, profiles, …).
- The CI runs against
docker:dindand a fresh registry container, reproducing things impossible to reproduce locally without Docker-on-Docker. - Each push gets a stable artefact link in the PR for triage.
- Reviewers (and future contributors) can see exactly which compose-go pseudo-version a given docker/compose run was tested against.
- PR commenting integrates with the change history — useful when a regression appears after a specific compose-go commit.
- Focused commit on
ndeloof/v3-yaml-node-context. go test ./...,golangci-lint run,make deepcopy(no diff) locally.- Push to
ndeloof/v3-yaml-node-context. Wait forcompose-spec/compose-go#874CI green. - In
docker/compose@test-compose-go-v3, bump the replace pseudo-version to point at the new compose-go commit,go mod tidy, commit (signed-off), push. - Wait for docker/compose#13799 CI. If any
e2ejob fails, inspect the log (gh api .../jobs/<id>/logs), reproduce locally if possible, fix on compose-go, repeat. - Once docker/compose#13799 is green, the compose-go commit is confirmed safe.
The loop has already caught three regressions invisible to the compose-go unit-test suite. Each design choice above is set so the regression cannot reappear:
TestSecretFromInclude—secret.environment: VARnot resolved when VAR came frominclude.env_file. Guard: Phase 3 keeps the includedNodeContext, Phase 8'sresolveSecretsConfigsEnvLayerresolves per layer (40c3140).TestPublishChecks/refuse_to_publish_with_local_include—"include"Listener event missing. Guard: Phase 3 emitsopts.ProcessEvent("include", …)fromloadIncludeEntry(45cca9c).TestPublish(OCI extends round-trip) —loadYamlFileNodesilently kept only the last document of a multi-document YAML. Guard: Phase 2'sparseLayersreturns one Layer per document (be24303).
Each fix also landed as a compose-go-side regression test
(TestInclude_EnvFile_ResolvesEnvironmentBackedSecret,
TestInclude_EmitsIncludeListenerEvent,
TestLoadMultiDocumentYaml, …) so the failure stays caught even if
the docker/compose draft PR were eventually closed.
- After every Phase ending — bump and run docker/compose#13799.
- Phase 13 (UnmarshalYAML per cluster): the e2e suite is the only reliable way to catch UnmarshalYAML bugs that mapstructure's weaker coercion used to mask. Each cluster ends on a green docker/compose#13799 run.
- Phase 17 cannot be declared complete until a full docker/compose#13799 run is green on the cleanup commit.
The replace directive in docker/compose@test-compose-go-v3 is the
single dial; everything else follows automatically.