diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c66d50e..559ab8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version: "1.25" cache: true @@ -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 @@ -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 diff --git a/.github/workflows/devcontainer-cache.yml b/.github/workflows/devcontainer-cache.yml index e5e543c..57ef60e 100644 --- a/.github/workflows/devcontainer-cache.yml +++ b/.github/workflows/devcontainer-cache.yml @@ -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 }} @@ -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 }} diff --git a/.github/workflows/devcontainer-release.yml b/.github/workflows/devcontainer-release.yml index 16ff972..33a0446 100644 --- a/.github/workflows/devcontainer-release.yml +++ b/.github/workflows/devcontainer-release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bba9aef..9c5dce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/compose/hash.go b/compose/hash.go index ca85389..9092695 100644 --- a/compose/hash.go +++ b/compose/hash.go @@ -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 @@ -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 } diff --git a/compose/hash_test.go b/compose/hash_test.go index e2d9f0c..23a29a6 100644 --- a/compose/hash_test.go +++ b/compose/hash_test.go @@ -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) + } + }) +} diff --git a/compose/orchestrator_test.go b/compose/orchestrator_test.go index 01515d4..3a1e382 100644 --- a/compose/orchestrator_test.go +++ b/compose/orchestrator_test.go @@ -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) + } +} diff --git a/compose/plan.go b/compose/plan.go index 6faf91e..e4cf77a 100644 --- a/compose/plan.go +++ b/compose/plan.go @@ -184,6 +184,7 @@ func refuseUnsupportedFields(proj *composetypes.Project) error { Reason: "multi-replica services not supported", }) } + found = append(found, unsupportedHooks(name, svc)...) } if len(found) == 0 { @@ -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 diff --git a/compose/plan_test.go b/compose/plan_test.go index f05cd20..ff99ed3 100644 --- a/compose/plan_test.go +++ b/compose/plan_test.go @@ -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) + } +} diff --git a/design/compose-native.md b/design/compose-native.md index c1a964e..df103b0 100644 --- a/design/compose-native.md +++ b/design/compose-native.md @@ -121,6 +121,15 @@ have to cross-reference: - `links:` (legacy, replaced by network DNS in compose v2) - `external: true` networks / volumes (user-managed shared resources) - `services..scale` > 1 (multiple replicas of the same service) +- `services..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 @@ -128,6 +137,14 @@ 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 diff --git a/go.mod b/go.mod index 649cbcd..e3cfd8b 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/crunchloop/devcontainer go 1.25.0 require ( - github.com/compose-spec/compose-go/v2 v2.11.0 - github.com/google/go-containerregistry v0.21.7 - github.com/moby/moby/api v1.54.2 - github.com/moby/moby/client v0.4.1 + github.com/compose-spec/compose-go/v2 v2.14.0 + github.com/google/go-containerregistry v0.22.0 + github.com/moby/moby/api v1.55.0 + github.com/moby/moby/client v0.5.1 github.com/spf13/cobra v1.10.2 github.com/tidwall/jsonc v0.3.3 - golang.org/x/term v0.43.0 - google.golang.org/protobuf v1.36.11 + golang.org/x/term v0.45.0 + google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,7 +20,7 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.5.3+incompatible // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -29,7 +29,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -44,7 +44,7 @@ require ( go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index dc29147..5731957 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM= -github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= +github.com/compose-spec/compose-go/v2 v2.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ= +github.com/compose-spec/compose-go/v2 v2.14.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -15,8 +15,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= -github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -34,14 +34,14 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= -github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/go-containerregistry v0.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I= +github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -50,10 +50,10 @@ github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebG github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= -github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -95,16 +95,16 @@ go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJc go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/up.go b/up.go index 03bcec0..44523b8 100644 --- a/up.go +++ b/up.go @@ -538,6 +538,10 @@ func (e *Engine) createFreshCompose(ctx context.Context, cfg *config.ResolvedCon return nil, err } + if err := e.refuseUnsupportedComposeProject(project, projectName); err != nil { + return nil, err + } + primary, err := compose.PrimaryService(project, src.Service) if err != nil { return nil, err @@ -702,6 +706,28 @@ func (e *Engine) upComposeShellout( return e.buildWorkspace(ctx, containerID, cfg, opts.LocalEnv) } +// refuseUnsupportedComposeProject rejects a project the native +// orchestrator will not run, before anything is built or pulled. +// +// Orchestrator.Up validates again at the top of its own flow; that +// call stays as the authoritative one, and this is the same +// side-effect-free check hoisted ahead of the work. Without it the +// refusal lands only after primary-image preparation, feature +// layering and sidecar builds have already run, so a project we were +// never going to start still costs the user a build and leaves the +// tagged images behind. +// +// Native backend only. The shell-out backend hands the project to +// `docker compose`, which implements fields we refuse — validating +// there would reject projects that work today. +func (e *Engine) refuseUnsupportedComposeProject(project *composetypes.Project, projectName string) error { + if e.opts.ComposeBackend != ComposeBackendNative { + return nil + } + plan := &compose.Plan{Project: project, ProjectName: projectName} + return plan.Validate(e.runtime.Capabilities()) +} + // upComposeNative is the new path: mutate the project in-memory via // the Apply* override helpers, then drive compose.Orchestrator.Up // against the runtime's primitive surface. No tmpfile, no docker diff --git a/up_compose_sidecar_test.go b/up_compose_sidecar_test.go index a5755be..cd2e228 100644 --- a/up_compose_sidecar_test.go +++ b/up_compose_sidecar_test.go @@ -2,11 +2,13 @@ package devcontainer import ( "context" + "errors" "path/filepath" "testing" composetypes "github.com/compose-spec/compose-go/v2/types" + "github.com/crunchloop/devcontainer/compose" "github.com/crunchloop/devcontainer/config" "github.com/crunchloop/devcontainer/events" "github.com/crunchloop/devcontainer/runtime" @@ -152,3 +154,48 @@ func TestBuildComposeSidecarImages_BuildsDependenciesOfSelection(t *testing.T) { t.Fatalf("builds = %+v, want app's dependency db and nothing else", rt.builds) } } + +// TestRefuseUnsupportedComposeProject_NativeRefusesBeforeAnyBuild pins +// the ordering: the native path refuses a §2.2 field straight after +// the project loads, before primary-image preparation, feature +// layering or sidecar builds. Refusing only inside Orchestrator.Up +// left those images built and tagged for a project that never starts. +func TestRefuseUnsupportedComposeProject_NativeRefusesBeforeAnyBuild(t *testing.T) { + eng, rt := sidecarEngine(t) + project := sidecarProject(map[string]composetypes.ServiceConfig{ + "app": { + Image: "alpine", + PreStart: []composetypes.ServiceHook{{Command: composetypes.ShellCommand{"migrate"}}}, + }, + }) + + err := eng.refuseUnsupportedComposeProject(project, "dc-x") + var unsup *compose.UnsupportedFieldError + if !errors.As(err, &unsup) { + t.Fatalf("want *compose.UnsupportedFieldError, got %T: %v", err, err) + } + if len(rt.builds) != 0 { + t.Errorf("builds=%v, want none before the refusal", rt.builds) + } +} + +// The shell-out backend delegates to `docker compose`, which +// implements hooks and other fields the native orchestrator refuses. +// Validating there would reject projects that work today. +func TestRefuseUnsupportedComposeProject_ShelloutDoesNotRefuse(t *testing.T) { + rt := &buildRecorder{fakeRuntime: newFakeRuntime()} + eng, err := New(EngineOptions{Runtime: rt, ComposeBackend: ComposeBackendShellout}) + if err != nil { + t.Fatalf("New: %v", err) + } + project := sidecarProject(map[string]composetypes.ServiceConfig{ + "app": { + Image: "alpine", + PreStart: []composetypes.ServiceHook{{Command: composetypes.ShellCommand{"migrate"}}}, + }, + }) + + if err := eng.refuseUnsupportedComposeProject(project, "dc-x"); err != nil { + t.Errorf("shellout must not refuse: %v", err) + } +}