From 789511a8574a07ef4894e3295905af45f5d97b79 Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:49:02 +0000 Subject: [PATCH 1/4] deps: bump the pending Go module updates Folds the six open dependabot module bumps into one change, taking the latest release of each rather than the version each PR was cut against: - compose-spec/compose-go/v2 2.11.0 -> 2.14.0 (#112) - google/go-containerregistry 0.21.7 -> 0.22.0 (#113 asked for 0.21.9) - moby/moby/api 1.54.2 -> 1.55.0, moby/moby/client 0.4.1 -> 0.5.1 (#100) - golang.org/x/term 0.43.0 -> 0.45.0 (#110) - google.golang.org/protobuf 1.36.11 -> 1.36.12 (#114) Co-Authored-By: Claude Opus 5 --- go.mod | 20 ++++++++++---------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 30 deletions(-) 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= From 044791cf84a8c6917989c347b9e115c94fdd264a Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:49:02 +0000 Subject: [PATCH 2/4] ci: bump the pending GitHub Actions updates All four are majors whose breaking changes are the Node 24 runtime default and an ESM migration; the workflows pass no inputs that the majors removed, and no workflow uses pull_request_target or workflow_run, which is the trigger pair actions/checkout v7 newly refuses to check out fork PRs for. - actions/checkout v6 (ci.yml) and v4 (devcontainer-*.yml) -> v7 (#99) - actions/setup-go v6 -> v7 (#111) - docker/login-action v3 -> v4 (#96) - docker/setup-buildx-action v3 -> v4 (#95) Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/devcontainer-cache.yml | 10 +++++----- .github/workflows/devcontainer-release.yml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) 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 From 652f63c7d017087640da54283c4cdbe5a0c2f93a Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:34:23 +0000 Subject: [PATCH 3/4] fix(compose): refuse the compose lifecycle hooks on the native path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose-go 2.14 adds ServiceConfig.PreStart, so `pre_start` — which 2.11 rejected at schema validation — now loads cleanly. The native orchestrator creates the service container directly and has no ephemeral init-container step, so it dropped the hook silently; and because the hooks are part of the ServiceConfig that ConfigHash covers, editing a hook that never ran read as a config change and stopped and removed the running container, destroying its writable layer. Refused in Plan.Validate via the existing §2.2 unsupported-field mechanism, before any infrastructure side effect. stripForHash drops the three fields as well: the refusal already makes them unreachable there, but a recreation is unrecoverable, so no field the orchestrator does not execute should be able to trigger one if the refusal moves or a caller reaches ConfigHash directly. post_start and pre_stop parsed cleanly under 2.11 and had the same defect, so all three are refused rather than just the one the bump introduced. The shell-out backend is untouched — it delegates to `docker compose`, which implements the hooks. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 +++++++++++++++ compose/hash.go | 14 ++++++++++ compose/hash_test.go | 35 ++++++++++++++++++++++++ compose/orchestrator_test.go | 35 ++++++++++++++++++++++++ compose/plan.go | 35 ++++++++++++++++++++++++ compose/plan_test.go | 53 ++++++++++++++++++++++++++++++++++++ design/compose-native.md | 9 ++++++ 7 files changed, 203 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba9aef..7284464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,21 @@ 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)** — 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 +118,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..fa2bcfc 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 From ddcd80afdff368bf6b7535eb8e22d69167022bb1 Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:45:35 +0000 Subject: [PATCH 4/4] fix(compose): refuse unsupported fields before building anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §2.2 refusal ran only inside Orchestrator.Up, which the engine reaches after prepareComposeServiceImage, layerFeatures and buildComposeSidecarImages. A native project using a refused field therefore paid for the primary image and every sidecar build, and left those images tagged, before being told the engine will not run it. Plan.Validate already documents itself as side-effect-free and safe to call before any backend interaction, so the fix is to call it where that matters: straight after compose.Load. The orchestrator's own validation stays as the authoritative one. Native backend only. The shell-out path hands the project to `docker compose`, which implements hooks and other fields the native orchestrator refuses, so validating there would reject projects that work today. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++++++ design/compose-native.md | 8 +++++++ up.go | 26 +++++++++++++++++++++ up_compose_sidecar_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7284464..9c5dce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 diff --git a/design/compose-native.md b/design/compose-native.md index fa2bcfc..df103b0 100644 --- a/design/compose-native.md +++ b/design/compose-native.md @@ -137,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/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) + } +}