Documentation
¶
Overview ¶
Package compose handles the Docker Compose source kind for devcontainer.json: parsing the user's compose project via compose-spec/compose-go and synthesizing override files that the engine layers on top during `docker compose up`.
Compose runtime orchestration (`up`, `down`, `ps`) is delegated to runtime.ComposeRuntime — this package is parser + override generator only. See design/compose.md for the full design and the §13 "future Go-native compose runtime" analysis.
Package compose's runtime-agnostic orchestrator.
The orchestrator drives any runtime.Runtime implementation through a Plan (Up) or DownPlan (Down). It owns compose semantics: topological ordering, idempotent reuse via ConfigHash, health gating, partial-failure handling, label-scoped teardown. The runtime implementation owns the backend specifics.
See design/compose-native.md §5 for the algorithm.
Scope of this initial commit (C6):
- Up: validate -> infrastructure (network + named volumes) -> level-by-level service start -> reuse-or-recreate via ConfigHash -> service_started gating.
- Down: list by project label -> stop + remove containers -> remove network -> optionally remove volumes / images.
- service_healthy / service_completed_successfully gating: the polling loop is in place but only reads InspectContainer fields the runtime already exposes; once Apple gains health and exit-code surfacing the orchestrator code does not change.
Out of scope here, picked up in later PRs:
- Port bindings (RunSpec doesn't carry them yet).
- --rmi local execution (the primitive exists; orchestrator wiring is a one-line addition in C8 if we want it).
- Per-service health-timeout overrides (single global today).
Index ¶
- Constants
- Variables
- func ApplyBuildOverride(project *composetypes.Project, primaryService, imageRef string) error
- func ApplyRunOverride(project *composetypes.Project, primaryService string, ov Override) error
- func ConfigHash(imageID string, svc composetypes.ServiceConfig) string
- func Load(ctx context.Context, opts LoadOptions) (*composetypes.Project, error)
- func PrimaryService(project *composetypes.Project, name string) (*composetypes.ServiceConfig, error)
- func WriteBuildOverride(dst string, ov Override) error
- func WriteRunOverride(dst string, project *composetypes.Project, ov Override) error
- type BindMount
- type CycleError
- type DownPlan
- type HealthTimeoutError
- type Level
- type LoadOptions
- type Orchestrator
- type Override
- type PartialUpError
- type Plan
- type UnsupportedFeatureOnBackendError
- type UnsupportedField
- type UnsupportedFieldError
- type UpResult
- type VolumeSharedAcrossServicesError
Constants ¶
const ( LabelComposeProject = "com.docker.compose.project" LabelComposeService = "com.docker.compose.service" LabelComposeOneoff = "com.docker.compose.oneoff" LabelEngine = "dev.containers.engine" LabelConfigHash = "dev.containers.config-hash" LabelImageDigest = "dev.containers.image-digest" )
Labels stamped on every container the orchestrator creates. Compose-CLI interop labels coexist with our own engine labels so the user's `docker compose ps` keeps working against our containers, while convergence policy stays internal to our hash.
See design/compose-native.md §3.1 for full provenance.
const DefaultHealthTimeout = 60 * time.Second
DefaultHealthTimeout bounds how long Orchestrator.Up will poll for a depends_on health/completion condition before giving up. Configurable per call via Orchestrator.HealthTimeout.
const EngineDisplayName = "devcontainer-go/compose"
EngineDisplayName identifies our orchestrator in stamped labels. Aligned with the constant at attach.go scope, but kept local to avoid a package-import cycle on the engine.
Variables ¶
var ErrComposeUnsupportedOnBackend = errors.New("compose: backend does not support compose source")
Sentinel for compose-source projects against backends that don't satisfy the runtime.Runtime compose primitives. Engine.Up checks this and surfaces it to the user with a clear "this backend doesn't support compose source" message.
Functions ¶
func ApplyBuildOverride ¶ added in v0.2.0
func ApplyBuildOverride(project *composetypes.Project, primaryService, imageRef string) error
ApplyBuildOverride mutates project so the primary service's image is pinned to imageRef and any build: directive is cleared. Mirrors WriteBuildOverride's behavior; safe to call on a freshly loaded project.
Returns an error if the primary service is missing.
func ApplyRunOverride ¶ added in v0.2.0
func ApplyRunOverride(project *composetypes.Project, primaryService string, ov Override) error
ApplyRunOverride mutates project so the primary service has the workspace bind mount, container env, and labels merged in. Existing user-declared volumes / environment / labels are preserved (we append, we don't replace) — unlike the YAML write path, which re-emits everything to dodge compose's sequence-replace merge, mutating in memory is naturally additive.
Returns an error if the primary service is missing.
func ConfigHash ¶ added in v0.2.0
func ConfigHash(imageID string, svc composetypes.ServiceConfig) string
ConfigHash returns a stable hash of an image identifier plus a compose service config. The orchestrator stamps this on every container it creates as the dev.containers.config-hash label; subsequent Up calls compare the stored hash against a freshly computed one to decide whether the service must be recreated.
Inputs that DO affect the hash (semantic differences):
- imageID (caller passes the resolved image digest)
- service.Command / Entrypoint
- service.Environment / Labels / Volumes / Ports order
- any other runtime-shaped field of ServiceConfig
Inputs that DO NOT affect the hash (compose-spec concerns, not runtime config — stripped before hashing, matching docker/compose's hash.go behavior):
- Build (build context drift; runtime sees the same image)
- PullPolicy (read by `up`, not by the running container)
- Scale / Deploy.Replicas (we only run a single replica)
- DependsOn (graph ordering, not container identity)
- Profiles (filter, not config)
Inputs that DO NOT affect the hash (incidental differences):
- map iteration order of Environment / Labels / Networks (encoding/json sorts map keys by spec)
- distinct *string pointers with equal string values (json.Marshal dereferences them before serializing)
Slice order IS semantic — Volumes order, Ports order, Command order all affect the hash. Compose treats these as ordered.
Implementation is intentionally minimal: encoding/json does the heavy lifting. Determinism was verified across 1000 iterations + 500 shuffle trials.
func Load ¶
func Load(ctx context.Context, opts LoadOptions) (*composetypes.Project, error)
Load parses the compose project described by opts. The returned *types.Project carries fully resolved interpolation, extends, and profile selection — caller doesn't need to repeat those.
compose-go's Project is intentionally not modified by callers directly (immutability invariant in v2); use Override to produce override YAML rather than mutating in place.
func PrimaryService ¶
func PrimaryService(project *composetypes.Project, name string) (*composetypes.ServiceConfig, error)
PrimaryService returns the service named `name` from the project. Returns nil + error if the service doesn't exist (typical cause: devcontainer.json's "service" field doesn't match any service in the loaded compose project).
func WriteBuildOverride ¶
WriteBuildOverride writes dst with the build override. Pins the primary service's image to ov.Image and clears any inherited build: directive via the YAML !reset tag (compose v2 idiom).
Returns an error if ov.Image or ov.Service is empty.
func WriteRunOverride ¶
func WriteRunOverride(dst string, project *composetypes.Project, ov Override) error
WriteRunOverride writes dst with the run-time override: workspace bind mount, container env, labels. Reads the primary service's existing volumes from `project` and emits the full merged list so compose v2's "sequence-replace" merge doesn't drop the user's declarations.
`project` may be nil; in that case the override emits only our declarations (caller asserts no user-declared volumes exist).
Types ¶
type CycleError ¶ added in v0.2.0
type CycleError struct {
Cycle []string
}
CycleError is returned by topological sort when the depends_on graph contains a cycle. Cycle lists the services on the cycle, in the order they appear.
func (*CycleError) Error ¶ added in v0.2.0
func (e *CycleError) Error() string
type DownPlan ¶ added in v0.2.0
type DownPlan struct {
ProjectName string
// RemoveVolumes removes named volumes labelled with the project
// after container removal. Mirrors compose's `--volumes` flag.
RemoveVolumes bool
// RemoveImages removes locally-built images labelled with the
// project after container removal. Mirrors compose's `--rmi local`.
RemoveImages bool
// Project is optional. When non-nil, the orchestrator uses its
// depends_on graph for reverse-topological teardown order; when
// nil it falls back to parallel teardown (Down is idempotent
// either way).
Project *composetypes.Project
}
DownPlan describes a teardown request. Used by Orchestrator.Down (and indirectly Engine.Down). Unlike Plan, this does not require the project file — if the user has destroyed their compose file since Up, we can still tear down via the project label scan.
type HealthTimeoutError ¶ added in v0.2.0
type HealthTimeoutError struct {
Service string
Condition string // "service_healthy" or "service_completed_successfully"
Waited string // human-readable duration ("60s")
}
HealthTimeoutError is returned by Up when a depends_on condition (service_healthy or service_completed_successfully) does not resolve within the configured timeout. The dependents of the timed-out service are not started; the started prerequisites are left running.
func (*HealthTimeoutError) Error ¶ added in v0.2.0
func (e *HealthTimeoutError) Error() string
type Level ¶ added in v0.2.0
type Level []string
Level is a set of service names that can start in parallel — they have no edges to each other within the level, and all their dependencies are satisfied by previous levels. The orchestrator processes one level at a time, in order.
func TopoSort ¶ added in v0.2.0
func TopoSort(project *composetypes.Project) ([]Level, error)
TopoSort returns the project's services arranged as levels. Services within a level have no mutual dependencies and may be started in parallel; level[i+1] depends only on services in level[<=i]. Returns *CycleError if the depends_on graph contains a cycle.
Sorting is deterministic: services within each level are returned in lexicographic order so tests and logs are stable.
Edges come from depends_on (long + short form, both already normalized by compose-go to types.ServiceDependency entries) plus network_mode: service:<x>, which is treated as an implicit dependency edge for ordering even though compose-go does not put it in DependsOn.
type LoadOptions ¶
type LoadOptions struct {
// Files lists compose files in declaration order — earlier files
// are overridden by later ones. Required.
Files []string
// WorkingDir is the directory compose-go uses as the base for
// relative-path resolution inside the project (build contexts,
// env_file paths, etc.). Required.
WorkingDir string
// ProjectName is set on the resulting types.Project. Engine derives
// it from the workspace id; required.
ProjectName string
// Profiles activates compose profiles. Empty = no profiles
// activated (which keeps services without `profiles:` running and
// drops services that opt into a profile).
Profiles []string
// Env contains environment variables exposed to compose's $VAR
// interpolation. Empty falls back to compose-go's default
// behavior of reading os.Environ().
Env []string
}
LoadOptions configures Load.
type Orchestrator ¶ added in v0.2.0
type Orchestrator struct {
// BackendName identifies the backend in error messages. Empty
// is allowed but reduces error-message clarity.
BackendName string
// HealthTimeout overrides DefaultHealthTimeout. Applied per
// depends_on edge, not for the whole Up.
HealthTimeout time.Duration
// PollInterval is the cadence for health polling. Tests
// override; production default below.
PollInterval time.Duration
// contains filtered or unexported fields
}
Orchestrator implements compose Up / Down against a runtime. Construct with NewOrchestrator. Methods are safe for sequential use; concurrent invocations against the same project are caller's responsibility.
func NewOrchestrator ¶ added in v0.2.0
func NewOrchestrator(rt runtime.Runtime, backendName string) *Orchestrator
NewOrchestrator constructs an Orchestrator with sane defaults.
func (*Orchestrator) Down ¶ added in v0.2.0
func (o *Orchestrator) Down(ctx context.Context, plan *DownPlan) error
Down tears down a project. Idempotent: missing resources are no-ops; missing project leaves no observable state change.
func (*Orchestrator) Up ¶ added in v0.2.0
Up applies the plan: validate, create infrastructure, level-by- level start with reuse-or-recreate semantics. Returns a *PartialUpError if a service fails after one or more services already started; the already-running services are NOT torn down (debuggability matters more than tidiness — see design §5.3).
type Override ¶
type Override struct {
// Service is the name of the primary service to override; matches
// devcontainer.json's "service" field.
Service string
// Image, when set, replaces the primary service's image and
// clears its build directive (mutually exclusive in compose).
// Engine sets this to the feature-extended image tag built
// upstream.
Image string
// ExtraBindMounts are added to the primary service's volumes.
// Engine adds the workspace folder bind here, plus any mounts
// declared in devcontainer.json (cfg.Mounts).
//
// IMPORTANT: compose v2's default merge strategy for sequence
// fields is REPLACEMENT, not concatenation. WriteRunOverride
// reads the existing service's volumes from the loaded project
// and emits the full merged list so we don't drop the user's
// declarations.
ExtraBindMounts []BindMount
// ExtraEnvironment is merged into the primary service's
// environment map. Compose merges maps natively, so we only
// need to emit our additions.
ExtraEnvironment map[string]string
// Labels written on the primary service so Engine.Attach's
// label-based lookup works (`dev.containers.id`, etc.).
Labels map[string]string
}
Override describes the engine-side additions we layer onto the user's compose project for the primary service. Each field is optional (zero value is "no change for that aspect").
type PartialUpError ¶ added in v0.2.0
type PartialUpError struct {
Started []string // service names whose containers are running
Failed string // service name that hit the error
Err error // underlying failure
}
PartialUpError signals that Up brought some services online and then failed before completing. Returned with the names of the services that did and didn't start so the caller (Engine.Up) can decide whether to retry or call Down.
Per design §5.3, the orchestrator does NOT auto-rollback: the running services stay running so the user can exec into them and read logs.
func (*PartialUpError) Error ¶ added in v0.2.0
func (e *PartialUpError) Error() string
func (*PartialUpError) Unwrap ¶ added in v0.2.0
func (e *PartialUpError) Unwrap() error
type Plan ¶ added in v0.2.0
type Plan struct {
// Project is the fully-loaded, interpolation-resolved compose
// project from compose.Load. The orchestrator reads from it but
// does not mutate it; mutation is the override functions' job.
Project *composetypes.Project
// ProjectName scopes all backend resources (network, volumes,
// container labels) for the project. Required.
ProjectName string
// Services optionally restricts which services to bring up.
// Empty = all services in the loaded project (after profile
// selection performed by compose.Load).
Services []string
// Labels are stamped on every container the orchestrator
// creates, in addition to the project's own labels. Engine
// fills these in with the devcontainer ID label set so
// Engine.Attach can find the primary container.
Labels map[string]string
}
Plan describes a compose-project Up request in a runtime-neutral shape. The orchestrator constructs one from a loaded project and drives the runtime through it; the caller (Engine.Up) builds the Plan, optionally calls ApplyBuildOverride / ApplyRunOverride to inject the feature-extended image + workspace mount, then calls Validate + Orchestrator.Up.
func (*Plan) Validate ¶ added in v0.2.0
func (p *Plan) Validate(backendName string, caps runtime.Capabilities) error
Validate inspects the Plan against the active backend's Capabilities and the refused-feature list, returning a typed error on the first kind of refusal encountered. Calls are side-effect-free; safe to invoke before any backend interaction.
Validation order:
- Hard refusals (§2.2 fields we never implement): one UnsupportedFieldError listing every offending site.
- Backend-gated features (depends_on conditions, namespace sharing, restart policies, shared volumes): one UnsupportedFeatureOnBackendError per offending feature, or a typed VolumeSharedAcrossServicesError for the volume case.
Each kind returns the FIRST error of that kind found; if no refusals trigger, Validate returns nil.
type UnsupportedFeatureOnBackendError ¶ added in v0.2.0
type UnsupportedFeatureOnBackendError struct {
Backend string // backend display name (e.g. "applecontainer")
Capability string // Capabilities struct field name (e.g. "Healthchecks")
Service string // service that triggered the refusal
Detail string // one-sentence explanation
}
UnsupportedFeatureOnBackendError is returned by Plan.Validate when the project uses a compose feature the active backend cannot satisfy — e.g. depends_on.condition: service_healthy against a backend whose Capabilities().Healthchecks is false.
Distinct from UnsupportedFieldError (which lists fields we never implement) because the gating is backend-specific and may flip if the backend gains the capability later.
func (*UnsupportedFeatureOnBackendError) Error ¶ added in v0.2.0
func (e *UnsupportedFeatureOnBackendError) Error() string
type UnsupportedField ¶ added in v0.2.0
type UnsupportedField struct {
Service string // "" for project-level (top-level secrets:, configs:, …)
Field string // canonical name, e.g. "secrets", "services.<x>.deploy"
Reason string // human-readable explanation (one short sentence)
}
UnsupportedField names one field usage that the orchestrator rejects. Service may be empty for project-level fields.
type UnsupportedFieldError ¶ added in v0.2.0
type UnsupportedFieldError struct {
// Fields lists the unsupported usage sites. Sorted for stable
// error output.
Fields []UnsupportedField
}
UnsupportedFieldError is returned by Plan.Validate when the user's compose project uses fields the orchestrator does not implement. Lists every offending (service, field) pair so the user can fix them in one pass rather than discovering them one at a time.
See design/compose-native.md §2.2 for the refused-field list.
func (*UnsupportedFieldError) Error ¶ added in v0.2.0
func (e *UnsupportedFieldError) Error() string
type UpResult ¶ added in v0.2.0
type UpResult struct {
// ContainerIDs maps service name -> backend container ID for
// every service that ended Up running. Includes reused
// containers (config-hash hit). Failed services are absent.
ContainerIDs map[string]string
// Network is the project's default network's backend ID, or ""
// if creation failed.
Network string
}
UpResult reports the per-service outcome of Up.
type VolumeSharedAcrossServicesError ¶ added in v0.2.0
type VolumeSharedAcrossServicesError struct {
}
VolumeSharedAcrossServicesError is returned by Plan.Validate when the project mounts a single named volume into 2+ services and the active backend's Capabilities().SharedVolumes is false (today: applecontainer, due to ext4-on-disk-image multi-attach restrictions per design probe 4).
func (*VolumeSharedAcrossServicesError) Error ¶ added in v0.2.0
func (e *VolumeSharedAcrossServicesError) Error() string