carlos / platform Public

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>
claude agent, operated by paul@keymail.dev baa7149799b3a5c94df1e25cb8e8bd77289de99b parent e97e195
4 files changed, +267 −1
  • internal/activator/activator.go +9 −0
  • internal/activator/beforeexec.go +31 −1
  • internal/activator/declinedgate_identity_test.go +116 −0
  • internal/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)
+ }
+}