settings-4: hooks for paths main added (zdd-flag-readiness)
Main's Task 9 asks a declined box's LiveExecGate only on the backend's live-link arms (exe == "", onto == nil). On this branch a cold wake (instance or worker) and a sidecar on an unstaged generation capture the live link once and run that file by path, so the gate was never asked and a held build ran. The capture now asks it (askLiveExecGate) before the publication and the build-state record, so a build the box will not start is neither published nor recorded and completes no request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+267
−1
internal/activator/activator.go+9 −0internal/activator/beforeexec.go+31 −1internal/activator/declinedgate_identity_test.go+116 −0internal/host/declinedgate_identity_test.go+111 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 336.270s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.170s
ok github.com/carlosframework/platform/cmd/moveprobe 92.239s
ok github.com/carlosframework/platform/internal/activator 231.705s
ok github.com/carlosframework/platform/internal/agentsock 0.020s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.023s
ok github.com/carlosframework/platform/internal/appconfig 2.340s
ok github.com/carlosframework/platform/internal/apperr 0.074s
ok github.com/carlosframework/platform/internal/applog 0.128s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.569s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.009s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.194s
ok github.com/carlosframework/platform/internal/changelog 0.259s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 358.211s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.058s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.012s
ok github.com/carlosframework/platform/internal/dnsrec 0.012s
ok github.com/carlosframework/platform/internal/docsite 0.012s
ok github.com/carlosframework/platform/internal/domains 2.058s
ok github.com/carlosframework/platform/internal/doorkey 0.012s
ok github.com/carlosframework/platform/internal/economics 0.012s
ok github.com/carlosframework/platform/internal/edge 26.084s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.012s
ok github.com/carlosframework/platform/internal/fleetapi 0.140s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.021s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.038s
ok github.com/carlosframework/platform/internal/host 58.501s
ok github.com/carlosframework/platform/internal/hostactors 0.465s
ok github.com/carlosframework/platform/internal/hostbox 1.344s
ok github.com/carlosframework/platform/internal/hostclaim 0.260s
ok github.com/carlosframework/platform/internal/hostevidence 0.020s
ok github.com/carlosframework/platform/internal/hostexec 0.323s
ok github.com/carlosframework/platform/internal/hostfence 0.048s
ok github.com/carlosframework/platform/internal/hostfiles 4.159s
ok github.com/carlosframework/platform/internal/hostfloor 0.024s
ok github.com/carlosframework/platform/internal/hostguard 0.145s
ok github.com/carlosframework/platform/internal/hostops 1.435s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.015s
ok github.com/carlosframework/platform/internal/hostresolve 0.130s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.003s
ok github.com/carlosframework/platform/internal/instancectl 1.709s
ok github.com/carlosframework/platform/internal/instanceexec 2.684s
ok github.com/carlosframework/platform/internal/instancefs 0.004s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.388s
ok github.com/carlosframework/platform/internal/lease 9.447s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.009s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.157s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.532s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.068s
ok github.com/carlosframework/platform/internal/metrics 0.006s
ok github.com/carlosframework/platform/internal/moves 21.148s
ok github.com/carlosframework/platform/internal/moves/archive 0.749s
ok github.com/carlosframework/platform/internal/objstore 0.179s
ok github.com/carlosframework/platform/internal/ops 0.163s
ok github.com/carlosframework/platform/internal/park 0.193s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 3.275s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.852s
ok github.com/carlosframework/platform/internal/relay 7.215s
ok github.com/carlosframework/platform/internal/release 19.168s
ok github.com/carlosframework/platform/internal/rollout 3.264s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.010s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.264s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.515s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.764s
ok github.com/carlosframework/platform/internal/status 0.043s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.131s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.573s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.024s
=== RUN TestT4StorageProbeMinio
2026/10/12 17:17:46 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDD7C4A405FE13, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 5ba00e02d..a13216dd6 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -2386,6 +2386,15 @@ func (a *Service) startSidecar(ctx context.Context, e *actEntry, rt registry.Rou |
| if id.Version == "" && a.BeforeExec == nil { |
| return a.backend.StartSidecar(ctx, rt) |
| } |
| + // The resolved file runs by path, past the backend's live-link |
| + // check (startSidecarChild), so a declined box's live exec gate is |
| + // asked here, before the record (askLiveExecGate). A refusal starts |
| + // nothing, as the backend's would, and the supervisor backs off. |
| + if id.Version != "" { |
| + if err := a.askLiveExecGate(rt, id.Version); err != nil { |
| + return nil, err |
| + } |
| + } |
| g.Build, g.StagedPath, g.Digest, g.Demand = id.Version, id.StagedPath, id.Digest, id.Demand |
| // The resolved build's own tier (ruling R-TIER), never the live |
| // tier read later. |
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go| index 5f6a6be18..81a7366b5 100644 |
| --- a/internal/activator/beforeexec.go |
| +++ b/internal/activator/beforeexec.go |
| @@ -59,6 +59,27 @@ func (a *Service) recordBuild(ctx context.Context, host, build string) error { |
| return nil |
| } |
| +// askLiveExecGate asks the live exec gate (SetLiveExecGate) whether rt may |
| +// start build, a build this branch captured from the live link and will run |
| +// by path: a cold wake's step 2b capture (settleWakeIdentity) and a sidecar |
| +// on an unstaged generation (startSidecar). The backend asks the gate only |
| +// on its live-link arms (exe == "", onto == nil), which those starts never |
| +// take, so without this a declined box's exec-time refusal (zdd-readiness |
| +// Task 9) would not be asked at all. It is asked before the publication and |
| +// the record, so a build the box will not start is never named as the one |
| +// about to run (settings spec §4.2) and completes no request. No gate (every |
| +// box but a declined one that honours records or markers) refuses nothing. |
| +// The gate reads only memory, the row and the link. |
| +func (a *Service) askLiveExecGate(rt registry.Route, build string) error { |
| + a.mu.Lock() |
| + gate := a.liveExecGate |
| + a.mu.Unlock() |
| + if gate == nil { |
| + return nil |
| + } |
| + return gate(rt, build) |
| +} |
| + |
| // claimedBuild is the claimed path's version check before its record |
| // (ruling R-EXEC-CLAIMVERSION). The record must name the bytes that run: |
| // s.Staged is the file the snapshot hashed, and the launch verifies the |
| @@ -92,7 +113,9 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error { |
| // until the process that runs it is gone. A write that fails refuses the |
| // wake as a worker spawn's refuses its exec. The pending record is |
| // returned so the wake drops it once the entry names the build itself |
| -// (wake), or the wake failed. |
| +// (wake), or the wake failed. Before that publication, a declined box's |
| +// live exec gate is asked about the captured build (askLiveExecGate), |
| +// so a build the box will not start is neither published nor recorded. |
| // - A held wake (held, its target) refuses an identity that is neither |
| // its target nor the planner's recovery boot (heldIdentityOK; Astra I2 |
| // #4): root repointed between the wait and the capture. That refusal |
| @@ -141,6 +164,13 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e |
| if id.Version == "" { |
| return release.StagedIdentity{}, 0, a.recordBuild(ctx, rt.Host, "") |
| } |
| + // A declined box's exec-time gate, asked about the file this wake |
| + // will run, before it is published or recorded (askLiveExecGate): |
| + // the spawn below runs it by path, past the backend's live-link |
| + // check. Its refusal comes back as it is, as the backend's would. |
| + if err := a.askLiveExecGate(rt, id.Version); err != nil { |
| + return release.StagedIdentity{}, 0, err |
| + } |
| pend = a.addPendingGeneration(rt.Host, id) |
| if err := a.persistGenerations(); err != nil { |
| log.Printf("activator: %s not woken: the build it would run (%s %s) could not be published first: %v", rt.Host, id.Version, id.StagedPath, err) |
diff --git a/internal/activator/declinedgate_identity_test.go b/internal/activator/declinedgate_identity_test.go| new file mode 100644 |
| index 000000000..b78303d57 |
| --- /dev/null |
| +++ b/internal/activator/declinedgate_identity_test.go |
| @@ -0,0 +1,116 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "slices" |
| + "testing" |
| +) |
| + |
| +// Merge of zdd-flag-readiness (main's Task 9): on a box that declined |
| +// CARLOS_SWAP=overlap and honours failure records or hold-ran markers, the |
| +// live exec gate is asked about the build a live-link start is about to |
| +// run. On this branch a cold wake does not exec the live link: step 2b |
| +// captures the link once, records that build and runs the captured file by |
| +// path (StartGenExe, SeedExe, a worker's onto), and a sidecar on an |
| +// unstaged generation does the same. Main's backend checks sit on the |
| +// live-link arms only (exe == "", onto == nil), so the capture itself must |
| +// ask the gate, and before the record: a build the box will not start is |
| +// never named as the one about to run (settings spec §4.2), so a refusal |
| +// completes no request. |
| + |
| +// A cold instance wake whose live link names a held build runs nothing and |
| +// records nothing; one whose link names a build the gate passes is asked |
| +// about exactly that build and runs it. |
| +func TestTheDeclinedGateIsAskedAboutTheCapturedWakeBeforeItsRecord(t *testing.T) { |
| + a, b := identityActivator(t, "v2") |
| + b.restoreFresh = true |
| + l := &execLog{} |
| + a.BeforeExec = recordInto(b, l) |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + a.SetLiveExecGate(g.gate) |
| + _, err := a.beginRequest(context.Background(), seeded(identityRoute(t, "one.example.com"))) |
| + if !errors.Is(err, errLiveHeld) { |
| + t.Fatalf("err = %v, want the gate's refusal", err) |
| + } |
| + if calls, _, _ := l.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a build the box will not start was recorded", calls) |
| + } |
| + if b.startCount() != 0 || b.seedCount() != 0 { |
| + t.Fatalf("started %d, seeded %d: held v2 ran", b.startCount(), b.seedCount()) |
| + } |
| + if got := g.builds(); !slices.Equal(got, []string{"v2"}) { |
| + t.Fatalf("gate asked %v, want [v2]", got) |
| + } |
| + waitFor(t, "the lease to go back", func() bool { return b.releaseCount() == 1 }) |
| + |
| + b.setLive("v1") |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if err != nil { |
| + t.Fatalf("wake onto v1 = %v", err) |
| + } |
| + done() |
| + if got := g.builds(); !slices.Equal(got, []string{"v2", "v1"}) { |
| + t.Fatalf("gate asked %v, want [v2 v1]", got) |
| + } |
| + if got := identityEvents(b); !slices.Equal(got, []string{"record v1", "start v1"}) { |
| + t.Fatalf("events %v, want v1 recorded and started", got) |
| + } |
| +} |
| + |
| +// A worker wake execs its captured identity (onto), which the backend's |
| +// gate never asks about: step 2b asks it, before the record. |
| +func TestTheDeclinedGateIsAskedAboutTheCapturedWorkerWake(t *testing.T) { |
| + a, b, _, _, _ := stagedActivator(t) |
| + b.fakeBackend.setIdentity("v2") |
| + l := &execLog{} |
| + a.BeforeExec = l.hook |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + a.SetLiveExecGate(g.gate) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(alwaysOnWorkerRoute(host)); !errors.Is(err, errLiveHeld) { |
| + t.Fatalf("WakeWorker = %v, want the gate's refusal", err) |
| + } |
| + if got := b.calls(); len(got) != 0 { |
| + t.Fatalf("staged spawns %v: held v2 ran", got) |
| + } |
| + if calls, _, _ := l.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a build the box will not start was recorded", calls) |
| + } |
| + if got := g.builds(); !slices.Equal(got, []string{"v2"}) { |
| + t.Fatalf("gate asked %v, want [v2]", got) |
| + } |
| +} |
| + |
| +// A sidecar whose generation names no staged file resolves the live link |
| +// and runs that file by path: the gate is asked about it first, and a |
| +// refusal records and starts nothing (the instance keeps serving). |
| +func TestTheDeclinedGateIsAskedAboutAnUnstagedSidecarsCapture(t *testing.T) { |
| + a, b := sidecarActivator(t, "") |
| + host := "one.example.com" |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if gen, _ := a.entryGen(host); gen.StagedPath != "" || b.sidecarStartCount() != 1 { |
| + t.Fatalf("setup: generation %+v, %d live-link sidecars", gen, b.sidecarStartCount()) |
| + } |
| + b.fakeBackend.setIdentity("v2") |
| + l := &execLog{} |
| + a.BeforeExec = l.hook |
| + g := &liveGate{refuse: map[string]bool{"v2": true}} |
| + a.SetLiveExecGate(g.gate) |
| + b.lastSidecar().kill() |
| + a.sweepOnce() |
| + waitFor(t, "the refused restart", func() bool { return len(g.builds()) > 0 && a.sidecarSettled(host) }) |
| + if sc := b.sidecarsOn(); len(sc) != 0 || b.sidecarStartCount() != 1 { |
| + t.Fatalf("sidecars %+v, %d live-link: held v2 ran beside the instance", sc, b.sidecarStartCount()) |
| + } |
| + if calls, _, _ := l.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a build the box will not start was recorded", calls) |
| + } |
| + if _, ok := a.entryGen(host); !ok { |
| + t.Fatal("the instance stopped serving over its sidecar's refusal") |
| + } |
| +} |
diff --git a/internal/host/declinedgate_identity_test.go b/internal/host/declinedgate_identity_test.go| new file mode 100644 |
| index 000000000..f5bff4298 |
| --- /dev/null |
| +++ b/internal/host/declinedgate_identity_test.go |
| @@ -0,0 +1,111 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rollout" |
| +) |
| + |
| +// liveIdentityBackend is liveLaunchBackend with the exec backend's identity |
| +// capability exposed, as production's ExecBackend and SplitBackend expose |
| +// it: the activator's cold wake then captures live/<host> once at step 2b |
| +// (LiveIdentity) and runs that file by path (StartGenExe, SeedExe), never |
| +// the backend's live-link Start. before runs inside LiveIdentity, before |
| +// the capture: root's repoint after the plan. |
| +type liveIdentityBackend struct { |
| + liveLaunchBackend |
| +} |
| + |
| +func (b *liveIdentityBackend) LiveIdentity(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) { |
| + if b.before != nil { |
| + b.before() |
| + } |
| + return b.eb.LiveIdentity(ctx, rt) |
| +} |
| + |
| +func (b *liveIdentityBackend) StartGenExe(ctx context.Context, rt registry.Route, n uint64, perGen bool, id release.StagedIdentity) (activator.Proc, activator.Gen, error) { |
| + return b.eb.StartGenExe(ctx, rt, n, perGen, id) |
| +} |
| + |
| +func (b *liveIdentityBackend) SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error { |
| + return b.eb.SeedExe(ctx, rt, id) |
| +} |
| + |
| +// TestDeclinedBoxRefusesAHeldBuildAtLaunch on the path production takes on |
| +// this branch (merge of zdd-flag-readiness): the wake plans with the link on |
| +// v1, root repoints it at held v2 before the capture, and the capture is |
| +// asked of the declined box's gate before it is recorded or run. Nothing of |
| +// v2 is exec'd and no build state names it. |
| +func TestDeclinedBoxRefusesAHeldCapturedIdentityAtLaunch(t *testing.T) { |
| + prev := activator.GenerationsEnabled() |
| + activator.SetGenerations(false) |
| + t.Cleanup(func() { activator.SetGenerations(prev) }) |
| + resetRollout(t) |
| + env := newFastFixture(t) |
| + t.Setenv("CARLOS_OPT_DIR", env.optDir) |
| + host := "warm.test" |
| + run := t.TempDir() |
| + rt := registry.Route{Host: host, Kind: "instance", App: "fastapp", Account: "bab", Channel: "edge", |
| + Status: "ready", Hibernate: true, Version: "v1", GoodVersion: "v1", GoodDigest: "sha256:v1", |
| + Socket: filepath.Join(run, host+".sock"), DBPath: filepath.Join(env.dataDir, host+".db")} |
| + if err := env.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + env.stageLive(t, host, "v1") |
| + mark := filepath.Join(t.TempDir(), "v2.ran") |
| + if err := release.StageArtifact(env.optDir, "bab", "v2", "fastapp", []byte("#!/bin/sh\necho ran > '"+mark+"'\nexec sleep 30\n")); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeFailureBook(t, env.dataDir, []rollout.FailureRecord{{Account: release.RouteAccount(rt), App: "fastapp", Build: "v2", Host: "sibling.test"}}, nil) |
| + eb := &activator.ExecBackend{Cfg: activator.Config{Bucket: "test-bucket", Idle: time.Hour}, |
| + OptDir: env.optDir, DataDir: t.TempDir(), RunDirOverride: run} |
| + backend := &liveIdentityBackend{liveLaunchBackend{eb: eb}} |
| + svc := activator.New(activator.Config{Bucket: "test-bucket", Idle: time.Hour}, backend, env.reg) |
| + svc.Routes = env.reg |
| + var recorded []string |
| + svc.BeforeExec = func(_ context.Context, _ string, build string) error { |
| + recorded = append(recorded, build) |
| + return nil |
| + } |
| + t.Cleanup(func() { |
| + sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + svc.Shutdown(sctx) |
| + }) |
| + declineWithBook(t, svc, env.reg, env.dataDir) |
| + if eb.LiveExecGate == nil { |
| + t.Fatal("no LiveExecGate: the box did not decline with its book") |
| + } |
| + if p := svc.Planner(context.Background(), rt); !isTodaysWake(p) { |
| + t.Fatalf("plan with the link on v1 = %+v, want today's wake", p) |
| + } |
| + backend.before = func() { |
| + if err := release.PointLive(env.optDir, host, "bab", "v2", "fastapp"); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + rec := httptest.NewRecorder() |
| + proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) |
| + if !svc.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+host+"/", nil), rt, proxy) { |
| + t.Fatal("the activator did not take the request") |
| + } |
| + if rec.Code == http.StatusNoContent { |
| + t.Fatal("the wake onto held v2 was served") |
| + } |
| + time.Sleep(200 * time.Millisecond) |
| + if _, err := os.Stat(mark); err == nil { |
| + t.Fatal("held v2 was exec'd by path: the declined gate never saw the captured identity") |
| + } |
| + if len(recorded) != 0 { |
| + t.Fatalf("build states recorded %v: a build the box will not start was named as about to run", recorded) |
| + } |
| +} |