diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d99a4ef3e..5130c6efd 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,23 @@ ## Purpose +### Docker SDK dependency compatibility + +The Docker consumers use Moby API v1.56.0 and client v0.6.0 together, without +changing other direct module versions. API negotiation remains enabled: the +new 1.56 ceiling is not a requirement that existing daemons implement 1.56. +Container recreation must retain image, environment, persistent binds, restart +policy and normalised capabilities without mutating caller-owned capability +lists. `internal/dockeragent/moby_compatibility_test.go` exercises real HTTP +negotiation and list/create payloads at API 1.44, 1.51 and 1.56; this is synthetic +compatibility evidence, not native daemon or installed-agent acceptance. + +Dependency-only changes to either `go.mod` or `go.sum` must invalidate a stale +managed backend binary so development checks cannot unknowingly exercise the +previous SDK. The manifest-only fixtures in +`tests/integration/scripts/managed-local-backend.test.mjs` verify fresh, stale +and rebuilt decisions independently for both manifests. + ### Backend preflight resource evidence The exact-source worker brackets the backend phase with bounded, read-only diff --git a/go.mod b/go.mod index 167fe4987..766b9b784 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,8 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/joho/godotenv v1.5.1 - github.com/moby/moby/api v1.55.0 - github.com/moby/moby/client v0.5.0 + github.com/moby/moby/api v1.56.0 + github.com/moby/moby/client v0.6.0 github.com/oklog/ulid/v2 v2.1.1 github.com/opencontainers/image-spec v1.1.1 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index cfb8c80ab..348932b7a 100644 --- a/go.sum +++ b/go.sum @@ -140,10 +140,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.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.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= -github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/dockeragent/moby_compatibility_test.go b/internal/dockeragent/moby_compatibility_test.go new file mode 100644 index 000000000..044362ab5 --- /dev/null +++ b/internal/dockeragent/moby_compatibility_test.go @@ -0,0 +1,74 @@ +package dockeragent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +// Exercise the real transport, not dockerClient mocks: dependency upgrades must +// negotiate older daemons and preserve recreate payloads at the new API ceiling. +func TestMobyNegotiatedContainerContracts(t *testing.T) { + for _, version := range []string{"1.44", "1.51", "1.56"} { + t.Run(version, func(t *testing.T) { + var created container.CreateRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/_ping": + w.Header().Set("API-Version", version) + case "/v" + version + "/containers/json": + if r.Method != http.MethodGet || r.URL.Query().Get("all") != "1" { + t.Errorf("list request: %s %s", r.Method, r.URL) + } + _, _ = w.Write([]byte(`[{"Id":"fixture","Names":["/workload"],"State":"running","Image":"example:test"}]`)) + case "/v" + version + "/containers/create": + if r.Method != http.MethodPost || r.URL.Query().Get("name") != "workload" { + t.Errorf("create request: %s %s", r.Method, r.URL) + } + if err := json.NewDecoder(r.Body).Decode(&created); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"Id":"replacement","Warnings":[]}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + http.Error(w, "unexpected", http.StatusNotFound) + } + })) + defer server.Close() + cli, err := client.New(client.WithHost(server.URL), client.WithAPIVersionNegotiation()) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + listed, err := cli.ContainerList(context.Background(), client.ContainerListOptions{All: true}) + if err != nil { + t.Fatal(err) + } + if len(listed.Items) != 1 || listed.Items[0].ID != "fixture" { + t.Fatalf("inventory: %+v", listed) + } + host := &container.HostConfig{Binds: []string{"persistent:/data"}, CapAdd: []string{"net_admin", "CAP_NET_ADMIN"}, CapDrop: []string{"sys_admin"}, RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}} + result, err := cli.ContainerCreate(context.Background(), client.ContainerCreateOptions{Name: "workload", Config: &container.Config{Image: "example:test", Env: []string{"MODE=test"}}, HostConfig: host}) + if err != nil { + t.Fatal(err) + } + if result.ID != "replacement" || created.Image != "example:test" || !reflect.DeepEqual(created.Env, []string{"MODE=test"}) || created.HostConfig == nil { + t.Fatalf("create result or config lost: %+v %+v", result, created) + } + if !reflect.DeepEqual(created.HostConfig.Binds, host.Binds) || created.HostConfig.RestartPolicy != host.RestartPolicy || !reflect.DeepEqual(created.HostConfig.CapAdd, []string{"CAP_NET_ADMIN"}) || !reflect.DeepEqual(created.HostConfig.CapDrop, []string{"CAP_SYS_ADMIN"}) { + t.Fatalf("host configuration lost: %+v", created.HostConfig) + } + if !reflect.DeepEqual(host.CapAdd, []string{"net_admin", "CAP_NET_ADMIN"}) || !reflect.DeepEqual(host.CapDrop, []string{"sys_admin"}) { + t.Fatalf("client mutated caller-owned recreation config: %+v", host) + } + }) + } +} diff --git a/tests/integration/scripts/managed-local-backend.test.mjs b/tests/integration/scripts/managed-local-backend.test.mjs index 837bdb779..14eb00e25 100644 --- a/tests/integration/scripts/managed-local-backend.test.mjs +++ b/tests/integration/scripts/managed-local-backend.test.mjs @@ -297,6 +297,36 @@ test('shouldBuildManagedLocalBackendBinary skips rebuild when binary is fresh', ); }); +// A dependency-only update must not accidentally exercise a cached old SDK. +for (const manifest of ['go.mod', 'go.sum']) { + test(`shouldBuildManagedLocalBackendBinary rebuilds after ${manifest}-only dependency update`, async (t) => { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-moby-')); + t.after(() => fs.rm(repoRoot, { recursive: true, force: true })); + const binaryPath = path.join(repoRoot, 'pulse'); + const embeddedFrontendDistPath = path.join(repoRoot, 'internal', 'api', 'frontend-modern', 'dist'); + await fs.mkdir(embeddedFrontendDistPath, { recursive: true }); + const inputs = { + 'go.mod': 'module example.com/pulse\nrequire github.com/moby/moby/client v0.6.0\n', + 'go.sum': 'fixture dependency checksum\n', + }; + const older = new Date('2026-03-12T09:00:00Z'); + const built = new Date('2026-03-12T10:00:00Z'); + const updated = new Date('2026-03-12T11:00:00Z'); + for (const [name, content] of Object.entries(inputs)) { + await fs.writeFile(path.join(repoRoot, name), content); + await fs.utimes(path.join(repoRoot, name), older, older); + } + await fs.writeFile(binaryPath, 'fixture binary'); + await fs.utimes(binaryPath, built, built); + const state = { repoRoot, binaryPath, embeddedFrontendDistPath }; + assert.equal(await shouldBuildManagedLocalBackendBinary(state), false); + await fs.utimes(path.join(repoRoot, manifest), updated, updated); + assert.equal(await shouldBuildManagedLocalBackendBinary(state), true); + await fs.utimes(binaryPath, updated, updated); + assert.equal(await shouldBuildManagedLocalBackendBinary(state), false); + }); +} + test('shouldBuildManagedLocalBackendBinary rebuilds enterprise variant when sibling enterprise source is newer', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-backend-enterprise-')); const pulseRepoRoot = path.join(workspaceRoot, 'pulse');