Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v7
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- uses: actions/setup-go@v7
with:
go-version: "1.25"
cache: true
Expand All @@ -35,8 +35,8 @@ jobs:
matrix:
go: ["1.25", "1.26"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
cache: true
Expand All @@ -52,8 +52,8 @@ jobs:
go: ["1.25", "1.26"]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
cache: true
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/devcontainer-cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand All @@ -67,10 +67,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/devcontainer-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
pull-requests: write
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Publish Features
uses: devcontainers/action@v1
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **compose (native)** — the compose lifecycle hooks `pre_start`, `post_start` and
`pre_stop` are now refused with `*compose.UnsupportedFieldError` instead of being
parsed and dropped. The native orchestrator creates the service container directly
and has no ephemeral init-container step to run a hook in, so accepting one lost
the hook's work — and the hooks are part of the `ServiceConfig` that
`compose.ConfigHash` is computed over, so editing a hook that never ran read as a
config change and stopped and removed the running container, destroying its
writable layer. `Plan.Validate` refuses before any infrastructure side effect, and
`stripForHash` also drops the three fields so unexecuted hook metadata cannot drive
a recreation even if a caller reaches `ConfigHash` directly — the one place the hash
deliberately strips more than docker/compose does. The shell-out compose backend is
unaffected: it delegates to `docker compose`, which implements the hooks itself.
`pre_start` only became reachable with the `compose-go` 2.14 bump in this release
(2.11 rejected it at schema validation); `post_start` and `pre_stop` parsed cleanly
before it and had the same defect.
- **compose (native)** — §2.2 unsupported-field refusals now land immediately after
the project loads, via `Engine.refuseUnsupportedComposeProject`, instead of only
inside `Orchestrator.Up`. `Plan.Validate` was already documented as
side-effect-free and safe to call before any backend interaction, but the only
caller ran after primary-image preparation, feature layering and
`buildComposeSidecarImages`, so a project the engine was never going to start
still paid for those builds and left the tagged images behind. The orchestrator's
own validation stays as the authoritative call. Native backend only — the
shell-out path hands the project to `docker compose`, which implements fields the
native orchestrator refuses.
- **compose (native)** — the `service_healthy` gate no longer passes when the backend
reports no health status for a service that declares an explicit healthcheck test.
`runtime.HealthStatus` documents `HealthNone` as ambiguous — the image declared no
Expand All @@ -103,6 +128,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
compose's inline `test: ["NONE"]`, and an empty test (where the image's own
`HEALTHCHECK` applies) all keep the permissive fallback, so no valid project blocks
on its own gate.
- **deps** — bump `github.com/compose-spec/compose-go/v2` 2.11.0 → 2.14.0;
`github.com/google/go-containerregistry` 0.21.7 → 0.22.0;
`github.com/moby/moby/api` 1.54.2 → 1.55.0 and `github.com/moby/moby/client`
0.4.1 → 0.5.1; `golang.org/x/term` 0.43.0 → 0.45.0;
`google.golang.org/protobuf` 1.36.11 → 1.36.12. (#129)
- **ci** — bump `actions/checkout` 4/6 → 7, `actions/setup-go` 6 → 7,
`docker/login-action` 3 → 4, `docker/setup-buildx-action` 3 → 4. (#129)

## [0.4.2] - 2026-08-23

Expand Down
14 changes: 14 additions & 0 deletions compose/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
// - Scale / Deploy.Replicas (we only run a single replica)
// - DependsOn (graph ordering, not container identity)
// - Profiles (filter, not config)
// - PreStart / PostStart / PreStop (lifecycle hooks this
// orchestrator does not execute; refused by Plan.Validate)
//
// Inputs that DO NOT affect the hash (incidental differences):
// - map iteration order of Environment / Labels / Networks
Expand Down Expand Up @@ -73,5 +75,17 @@ func stripForHash(svc composetypes.ServiceConfig) composetypes.ServiceConfig {
out.Deploy = nil
out.DependsOn = nil
out.Profiles = nil
// Lifecycle hooks (pre_start / post_start / pre_stop) are
// refused outright by Plan.Validate, which runs before any hash
// is computed, so in practice these are already empty here.
// Stripped anyway: a recreation destroys the container's
// writable layer, and a field the orchestrator does not execute
// must not be able to trigger that if the refusal is ever moved
// or a future caller reaches ConfigHash directly. This is the
// one place we deliberately strip more than docker/compose does
// — it executes the hooks, we don't.
out.PreStart = nil
out.PostStart = nil
out.PreStop = nil
return out
}
35 changes: 35 additions & 0 deletions compose/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,38 @@ func TestConfigHash_StripsNonRuntimeFields(t *testing.T) {
}
})
}

// TestConfigHash_StripsLifecycleHooks pins the second half of the
// hook defence. Plan.Validate refuses these fields before any hash
// is computed, so this is belt-and-braces: a recreation destroys the
// container's writable layer, and a field the orchestrator never
// executes must not be able to trigger that even if the refusal is
// moved or a caller reaches ConfigHash directly. Deliberately
// stricter than docker/compose, which executes the hooks.
func TestConfigHash_StripsLifecycleHooks(t *testing.T) {
base := baseService()
wantHash := ConfigHash("img", base)
hook := []composetypes.ServiceHook{{Command: composetypes.ShellCommand{"migrate"}}}

t.Run("PreStart", func(t *testing.T) {
svc := baseService()
svc.PreStart = hook
if got := ConfigHash("img", svc); got != wantHash {
t.Errorf("PreStart edit changed hash: %q vs %q", got, wantHash)
}
})
t.Run("PostStart", func(t *testing.T) {
svc := baseService()
svc.PostStart = hook
if got := ConfigHash("img", svc); got != wantHash {
t.Errorf("PostStart edit changed hash: %q vs %q", got, wantHash)
}
})
t.Run("PreStop", func(t *testing.T) {
svc := baseService()
svc.PreStop = hook
if got := ConfigHash("img", svc); got != wantHash {
t.Errorf("PreStop edit changed hash: %q vs %q", got, wantHash)
}
})
}
35 changes: 35 additions & 0 deletions compose/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1302,3 +1302,38 @@ func TestUp_PatchesHostsWithoutServiceNameDNS(t *testing.T) {
t.Errorf("docker baseline must not patch /etc/hosts, got %v", patched2)
}
}

// TestUp_HookEditCannotRemoveExistingContainer is the regression for
// the destructive path the compose-go bump opened up: pre_start
// became a legal field, the native orchestrator does not execute it,
// and it is covered by the recreation hash — so adding or editing a
// hook on an already-running project would have stopped and removed
// the container, destroying its writable layer, for a hook that
// never ran. Up must refuse instead, and touch nothing.
func TestUp_HookEditCannotRemoveExistingContainer(t *testing.T) {
rt := newMockRuntime()
orch := NewOrchestrator(rt)
proj := newProject(t, map[string][]string{"app": nil})

if _, err := orch.Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"}); err != nil {
t.Fatalf("first Up: %v", err)
}
runsBefore, removesBefore := rt.runCalls, rt.removeCalls

svc := proj.Services["app"]
svc.PreStart = []composetypes.ServiceHook{{Command: composetypes.ShellCommand{"migrate"}}}
proj.Services["app"] = svc

_, err := orch.Up(context.Background(), &Plan{Project: proj, ProjectName: "dc-x"})
var unsup *UnsupportedFieldError
if !errors.As(err, &unsup) {
t.Fatalf("want *UnsupportedFieldError, got %T: %v", err, err)
}
if rt.removeCalls != removesBefore {
t.Errorf("removeCalls=%d, want %d: the existing container must survive a refused hook edit",
rt.removeCalls, removesBefore)
}
if rt.runCalls != runsBefore {
t.Errorf("runCalls=%d, want %d: nothing should be recreated", rt.runCalls, runsBefore)
}
}
35 changes: 35 additions & 0 deletions compose/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func refuseUnsupportedFields(proj *composetypes.Project) error {
Reason: "multi-replica services not supported",
})
}
found = append(found, unsupportedHooks(name, svc)...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] [D12] Native compose rejects lifecycle hooks only after building images and can leave those build side effects behind

  • Witness: A native compose project with an unsupported hook and a buildable sidecar reaches upComposeNative: up.go:721 calls buildComposeSidecarImages before compose.Orchestrator.Up, while the new hook refusal is only reached from compose/plan.go:187 via Orchestrator.UpPlan.Validate at compose/orchestrator.go:109. buildComposeSidecarImages calls e.runtime.BuildImage at up.go:834-840, so the request can build/tag selected sidecar images and only then return *UnsupportedFieldError without starting the project.
  • Consumer: up.go:721-723 consumes the loaded project by building selected sidecar images before validation; up.go:834-840 is the backend side-effecting build call. The caller receives a refusal for the hook only after that work has completed.
  • Fix: Validate the loaded native compose plan immediately after compose.Load (before primary-image preparation, feature layering, or sidecar builds), or otherwise run the same hook/unsupported-feature validation before any BuildImage/pull side effect. Keep the later orchestrator validation as a defense in depth.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — the reviewer verified this was fixed.

Verified: Verified at head ddcd80a: Engine.createFreshCompose loads the project and calls refuseUnsupportedComposeProject at up.go:~670 before PrimaryService, prepareComposeServiceImage, layerFeatures, or sidecar builds. That helper invokes the same side-effect-free Plan.Validate used by Orchestrator.Up, so unsupported native compose hooks now fail before image/build side effects.

The review comment above records what was claimed at the time, and is left unedited.

}

if len(found) == 0 {
Expand All @@ -192,6 +193,40 @@ func refuseUnsupportedFields(proj *composetypes.Project) error {
return &UnsupportedFieldError{Fields: sortFields(found)}
}

// unsupportedHooks refuses the compose lifecycle hooks. compose-go
// parses pre_start, post_start and pre_stop, but this orchestrator
// creates the service container directly and has no ephemeral
// init-container step to run them in, so it would drop them
// silently. Accepting them loses the hook's work — and worse: the
// hooks are part of the ServiceConfig that ConfigHash is computed
// over, so editing a hook that never runs reads as a config change
// and stops and removes the existing container, destroying its
// writable layer for work the backend never performed. Refused in
// Validate, before any infrastructure side effect.
//
// Only the native orchestrator reaches this. The shell-out backend
// delegates to `docker compose`, which implements the hooks itself.
func unsupportedHooks(service string, svc composetypes.ServiceConfig) []UnsupportedField {
var out []UnsupportedField
for _, h := range []struct {
field string
hooks []composetypes.ServiceHook
}{
{"pre_start", svc.PreStart},
{"post_start", svc.PostStart},
{"pre_stop", svc.PreStop},
} {
if len(h.hooks) == 0 {
continue
}
out = append(out, UnsupportedField{
Service: service, Field: h.field,
Reason: "lifecycle hooks are not executed by the native orchestrator",
})
}
return out
}

// deployUnsupported collects refusals for sub-fields of deploy: that
// this orchestrator can't honor. We accept deploy when it only carries
// resources.limits with memory/cpus — that's how compose v3+ users
Expand Down
53 changes: 53 additions & 0 deletions compose/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,56 @@ func TestValidate_RefusesHealthyWithoutHealthchecks(t *testing.T) {
t.Errorf("want accepted on a backend with Healthchecks: %v", err)
}
}

// TestValidate_RefusesLifecycleHooks pins the refusal of the three
// compose lifecycle hooks. The native orchestrator has no ephemeral
// init-container step, so it would drop them silently; because the
// hooks are part of the ServiceConfig the recreation hash covers,
// editing one would also stop and remove the running container for
// work that never happened.
func TestValidate_RefusesLifecycleHooks(t *testing.T) {
hook := []composetypes.ServiceHook{{Command: composetypes.ShellCommand{"migrate"}}}

for _, tc := range []struct {
field string
set func(*composetypes.ServiceConfig)
}{
{"pre_start", func(s *composetypes.ServiceConfig) { s.PreStart = hook }},
{"post_start", func(s *composetypes.ServiceConfig) { s.PostStart = hook }},
{"pre_stop", func(s *composetypes.ServiceConfig) { s.PreStop = hook }},
} {
t.Run(tc.field, func(t *testing.T) {
svc := composetypes.ServiceConfig{Name: "app", Image: "alpine"}
tc.set(&svc)
proj := &composetypes.Project{
Services: composetypes.Services{"app": svc},
}
p := &Plan{Project: proj, ProjectName: "dc-x"}
err := p.Validate(dockerCaps())
var unsup *UnsupportedFieldError
if !errors.As(err, &unsup) {
t.Fatalf("want *UnsupportedFieldError, got %T: %v", err, err)
}
if len(unsup.Fields) != 1 || unsup.Fields[0].Field != tc.field {
t.Errorf("want a single %q refusal, got %+v", tc.field, unsup.Fields)
}
})
}
}

func TestValidate_AcceptsAbsentLifecycleHooks(t *testing.T) {
proj := &composetypes.Project{
Services: composetypes.Services{
"app": composetypes.ServiceConfig{
Name: "app",
Image: "alpine",
PreStart: []composetypes.ServiceHook{},
PostStart: nil,
},
},
}
p := &Plan{Project: proj, ProjectName: "dc-x"}
if err := p.Validate(dockerCaps()); err != nil {
t.Errorf("empty hook slices must not be refused: %v", err)
}
}
17 changes: 17 additions & 0 deletions design/compose-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,30 @@ have to cross-reference:
- `links:` (legacy, replaced by network DNS in compose v2)
- `external: true` networks / volumes (user-managed shared resources)
- `services.<name>.scale` > 1 (multiple replicas of the same service)
- `services.<name>.pre_start` / `post_start` / `pre_stop` (lifecycle
hooks — the orchestrator creates the service container directly and
has no ephemeral init-container step to run them in). Refused rather
than quietly ignored (§2.3) because the hooks are part of the
`ServiceConfig` that `ConfigHash` covers: silently dropping them
would make editing a hook that never runs read as a config change
and recreate the container, destroying its writable layer. The
shell-out backend is unaffected — it delegates to `docker compose`,
which implements the hooks.

Mechanism: `compose.Plan` walks the parsed `*types.Project`, returns a
typed `*compose.UnsupportedFieldError` listing exactly which fields on
which services tripped the refusal. Wired through to `EngineEvent`
warnings so the user gets a clear "your compose file uses X which our
engine does not implement" rather than a silent partial run.

`Orchestrator.Up` runs the walk at the top of its own flow, and the
engine runs the same side-effect-free check immediately after
`compose.Load` (`Engine.refuseUnsupportedComposeProject`, native
backend only) so the refusal lands before primary-image preparation,
feature layering and sidecar builds — otherwise a project we were
never going to start still costs a build and leaves the tagged images
behind.

### 2.3 Out of scope — quietly ignored (documented)

These are spec fields that compose-go parses but the orchestrator does not
Expand Down
Loading