carlos / platform Public

claim Plan 14 Part 1 fix round 3: no-census restarts are a superset of main

Merge ruling 4: with no root census, any route root cannot bind (no record,
no root-owned source, box-local stamp, list error) takes main's exact
restart path; a binding refusal never rolls a promoted fix back. Ordinary
routes make no store call. Inactive and activating PID-zero units bind like
failed ones, so the 2026-10-06 recipe works in either reset-failed order.
PointLive keeps the running build as previous across an interrupted
repoint. Legacy-lock quarantine only on hostile evidence. Census overlay
merge3 records the v0.26.1 instance-key hotfix (merge ruling 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 98b14681923def97eb19e0baa20e968b9c3056e2 parent 345b904
20 files changed, +1253 −55
  • internal/host/agent.go +12 −10
  • internal/host/legacyrestart.go +105 −0
  • internal/host/plan14_fix3_adopt_linux_test.go +44 −0
  • internal/host/plan14_fix3_live_linux_test.go +592 −0
  • internal/host/plan14_fix3_lock_linux_test.go +108 −0
  • internal/host/proofbinding.go +7 −1
  • internal/host/rootrestart.go +117 −24
  • internal/hostguard/flock_other.go +1 −1
  • internal/hostguard/flock_unix.go +21 −5
  • internal/hostguard/guard.go +4 −2
  • internal/hostguard/plan14_fix2_unix_test.go +23 −5
  • internal/hostguard/plan14_root_fences_test.go +2 −2
  • internal/provision/plan14_legacy_identity_test.go +45 −0
  • internal/provision/plan14_root_reads_test.go +7 −0
  • internal/provision/plan9_b8_census_test.go +2 −1
  • internal/provision/testdata/plan14-root-reads-merge3.tsv +36 −0
  • internal/release/adopt.go +30 −3
  • internal/release/plan14_fix3_previous_test.go +77 −0
  • internal/release/proofgate.go +3 −0
  • internal/release/versions.go +17 −1

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	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	366.105s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.168s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.255s
ok  	github.com/carlosframework/platform/internal/activator	227.425s
ok  	github.com/carlosframework/platform/internal/agentsock	0.020s
ok  	github.com/carlosframework/platform/internal/alerting	0.006s
ok  	github.com/carlosframework/platform/internal/analytics	0.020s
ok  	github.com/carlosframework/platform/internal/appconfig	2.348s
ok  	github.com/carlosframework/platform/internal/apperr	0.074s
ok  	github.com/carlosframework/platform/internal/applog	0.133s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.757s
?   	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.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	0.205s
ok  	github.com/carlosframework/platform/internal/changelog	0.301s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	368.128s
?   	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.185s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.148s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.338s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.125s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.033s
ok  	github.com/carlosframework/platform/internal/host	92.766s
ok  	github.com/carlosframework/platform/internal/hostactors	0.407s
ok  	github.com/carlosframework/platform/internal/hostbox	2.324s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.508s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.014s
ok  	github.com/carlosframework/platform/internal/hostexec	5.045s
ok  	github.com/carlosframework/platform/internal/hostfence	0.032s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.509s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.014s
ok  	github.com/carlosframework/platform/internal/hostguard	0.980s
ok  	github.com/carlosframework/platform/internal/hostops	1.473s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.014s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.014s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.486s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.149s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.014s
ok  	github.com/carlosframework/platform/internal/infraview	0.016s
ok  	github.com/carlosframework/platform/internal/instancectl	1.730s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.734s
ok  	github.com/carlosframework/platform/internal/instancefs	0.018s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.465s
ok  	github.com/carlosframework/platform/internal/lease	9.467s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.020s
ok  	github.com/carlosframework/platform/internal/ledger	0.031s
ok  	github.com/carlosframework/platform/internal/mail	0.171s
ok  	github.com/carlosframework/platform/internal/maintenance	0.091s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.533s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.079s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.073s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.785s
ok  	github.com/carlosframework/platform/internal/objstore	0.176s
ok  	github.com/carlosframework/platform/internal/ops	0.161s
ok  	github.com/carlosframework/platform/internal/park	0.209s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	13.809s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.010s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.002s
ok  	github.com/carlosframework/platform/internal/relay	7.241s
ok  	github.com/carlosframework/platform/internal/release	19.552s
ok  	github.com/carlosframework/platform/internal/rollout	3.843s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.005s
ok  	github.com/carlosframework/platform/internal/schedule	2.323s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.229s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.350s
ok  	github.com/carlosframework/platform/internal/status	0.040s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.210s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
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.004s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.585s
=== 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: cached, digest verified
  mc: cached, 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.026s
=== RUN   TestT4StorageProbeMinio
2026/10/13 15:42:53 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE212BC7ED4F29, 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.025s
minio contract: PASS
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a817678d8..be471d701 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -4192,15 +4192,14 @@ func runAdopt(args []string, buildClaims ClaimFactory) {
// the rest of adopt startup, including its fatal registry diagnostics, is real.
func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) {
err := withClaimRootRelease(context.Background(), hostwrite.Enabled(os.Getenv), func() (rootstate.Store, error) { return rootstate.Open(rootReleasePrivate, true, false) }, func() error {
- runAdoptUnderRootRelease(args, buildClaims, ensureUnits)
- return nil
+ return runAdoptUnderRootRelease(args, buildClaims, ensureUnits)
})
if err != nil {
log.Fatalf("adopt: %v", err)
}
}
-func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func()) {
+func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUnits func()) error {
reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf)
if os.Geteuid() == 0 {
if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil {
@@ -4358,7 +4357,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
log.Fatalf("adopt: list routes: %v", err)
}
restartPass(ctx, routes, store, restartMarksPath(dataDir()), restart)
- return
+ return nil
}
// Parsed here, before config sync, exactly as the full pass always has
// (a box with an unusable release key must die before it converges
@@ -4392,7 +4391,7 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// reason: a config write is not a restart request, and running the
// comparison here would only move it earlier by minutes at the cost of
// firing on a trigger that has nothing to do with it.
- return
+ return nil
}
// The restart-request half again (issue #113), here as the FLOOR under
@@ -4466,12 +4465,13 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
// run). -stage-only keeps it for the same reason it keeps the converge:
// a relayed stage is exactly the request that performs such a restart.
passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), release.WithRestartFailurePropagation(), release.WithRestartAttemptSettled(restartChecks.finish),
+ release.WithRunningVersion(restartChecks.runningVersion),
release.WithSelectedRestarts(
rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, settled, restartChecks, false),
rootSelectedRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, nil, restartChecks, true)),
- release.WithStartUnit(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), release.WithHostWrites(claims.Writes),
+ release.WithStartUnit(rootStartRoute(reg, release.OptDir(), restartChecks)), release.WithHostWrites(claims.Writes),
release.WithDigestRatchet(pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv)),
- release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)),
+ release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv, restartChecks)),
// D3's gate: a fresh gate per root pass, so the policy file is read
// at the start of this pass (BeginPass) like every other. Healthy
// history is root-owned; diagnostic refusals still use BakeDir.
@@ -4499,10 +4499,11 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
hourly: !*stageOnly,
logf: log.Printf,
}
- if err := pr.around(ctx, func() error {
+ passErr := pr.around(ctx, func() error {
return release.Pass(ctx, reg, store, pub, release.OptDir(), restart, passOpts...)
- }); err != nil {
- log.Printf("adopt: %v", err)
+ })
+ if passErr != nil {
+ log.Printf("adopt: %v", passErr)
}
// A move's held stage (moves design §7.3, T7): the sandboxed agent
@@ -4523,4 +4524,5 @@ func runAdoptUnderRootRelease(args []string, buildClaims ClaimFactory, ensureUni
adviseBackingMismatches(routes)
adviseHostRoleGateMissing(routes, lookupUID, nftGateUIDs)
}
+ return passErr
}
diff --git a/internal/host/legacyrestart.go b/internal/host/legacyrestart.go
new file mode 100644
index 000000000..be4a5d070
--- /dev/null
+++ b/internal/host/legacyrestart.go
@@ -0,0 +1,105 @@
+package host
+
+import (
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+)
+
+var errRestartBinding = errors.Join(errors.New("named-unit binding changed"), os.ErrPermission)
+
+// A retained census keeps the strict path even after the flag is disabled.
+// An unreadable census is never evidence that this is a legacy box.
+func legacyRootRestart() (bool, error) {
+ l, required, err := rootparticipation.OpenForUpdate()
+ if l.State != nil {
+ l.State.Close()
+ }
+ return !required && !hostwrite.Enabled(os.Getenv) && err == nil, err
+}
+
+func (c rootRestartChecks) allowLegacy() (bool, error) {
+ if c.legacy == nil {
+ return false, nil
+ }
+ return c.legacy()
+}
+
+func (c rootRestartChecks) noteLegacy() {
+ if c.legacyLog != nil {
+ c.legacyLog.Do(func() { log.Print("adopt: legacy restart (no root census)") })
+ }
+}
+
+func restartEffectFailed(err error) bool {
+ var notUp *release.UnitNotUpError
+ return errors.As(err, &notUp) && !errors.Is(err, errRestartBinding)
+}
+
+// This is main's stamped-unit path, including its fallback on Show or
+// ExecStart mismatch. Binding is optional until a root census exists.
+func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, settled func(registry.Route) error, c rootRestartChecks) error {
+ before, err := sys.Show(rt.Unit)
+ link := release.LiveLink(optDir, rt.Host)
+ if err != nil || before.ExecPath != link || before.ActiveState != "active" {
+ return sys.TryRestart(rt.Unit)
+ }
+ want, err := filepath.EvalSymlinks(link)
+ if err != nil {
+ return sys.TryRestart(rt.Unit)
+ }
+ if err := sys.TryRestart(rt.Unit); err != nil {
+ return err
+ }
+ err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c))
+ if errors.Is(err, errUnitStopped) {
+ return nil
+ }
+ if err == nil && settled != nil {
+ if e := settled(rt); e != nil {
+ log.Print(e)
+ }
+ }
+ return err
+}
+
+func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, c rootRestartChecks) error {
+ before, _ := sys.Show(rt.Unit)
+ if err := sys.ResetFailed(rt.Unit); err != nil {
+ log.Print(err)
+ }
+ if err := sys.Restart(rt.Unit); err != nil {
+ return err
+ }
+ want, err := filepath.EvalSymlinks(release.LiveLink(optDir, rt.Host))
+ if err != nil {
+ return err
+ }
+ err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c))
+ if errors.Is(err, errUnitStopped) {
+ return nil
+ }
+ return err
+}
+
+func legacySettleOptions(optDir string, getenv func(string) string, rt registry.Route, want string, c rootRestartChecks) settleOpts {
+ now, sleep := c.now, c.sleep
+ if now == nil {
+ now = time.Now
+ }
+ if sleep == nil {
+ sleep = time.Sleep
+ }
+ ready := routeReady(rt, 2*time.Second)
+ if c.legacyReady != nil {
+ ready = c.legacyReady(rt)
+ }
+ return settleOpts{Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second, WantDir: filepath.Dir(want), Ready: ready, Now: now, Sleep: sleep}
+}
diff --git a/internal/host/plan14_fix3_adopt_linux_test.go b/internal/host/plan14_fix3_adopt_linux_test.go
new file mode 100644
index 000000000..ed7cdd0fb
--- /dev/null
+++ b/internal/host/plan14_fix3_adopt_linux_test.go
@@ -0,0 +1,44 @@
+package host
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestR2AdoptFoundationFailureExitsOne(t *testing.T) {
+ if os.Getenv("PLAN14_FIX3_ADOPT_CHILD") == "1" {
+ runAdoptWithEnsureSystemUnits([]string{"-stage-only", "-registry", os.Getenv("PLAN14_FIX3_REGISTRY")}, nil, func() {})
+ return
+ }
+ if os.Geteuid() == 0 {
+ t.Skip("this fixture runs the local registry as an unprivileged user")
+ }
+ dir := t.TempDir()
+ data := filepath.Join(dir, "data")
+ if err := os.MkdirAll(filepath.Join(data, "channel-first-seen.json"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestR2AdoptFoundationFailureExitsOne$", "-test.v")
+ cmd.Dir = dir
+ cmd.Env = isolatedAdoptOrderEnv(t,
+ "PLAN14_FIX3_ADOPT_CHILD=1", "PLAN14_FIX3_REGISTRY="+filepath.Join(data, "registry.db"),
+ "CARLOS_DATA_DIR="+data, "CARLOS_OPT_DIR="+filepath.Join(dir, "opt"),
+ "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR="+filepath.Join(dir, "bucket"),
+ "CARLOS_HOST_CLAIM=", "CARLOS_REGION_HA=", "CARLOS_MOVES_PHASE1=", "CARLOS_SECRETS_KEY=", "CARLOS_RELEASE_PUBKEY="+pub,
+ )
+ out, err := cmd.CombinedOutput()
+ var status *exec.ExitError
+ if !errors.As(err, &status) || status.ExitCode() != 1 || !strings.Contains(string(out), "record bake clock:") {
+ t.Fatalf("foundation failure lost exit status: %v\n%s", err, out)
+ }
+}
diff --git a/internal/host/plan14_fix3_live_linux_test.go b/internal/host/plan14_fix3_live_linux_test.go
new file mode 100644
index 000000000..cadca0d4d
--- /dev/null
+++ b/internal/host/plan14_fix3_live_linux_test.go
@@ -0,0 +1,592 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootparticipation"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+// Model try-restart's no-op for a stopped service, and reset-failed's
+// transition to inactive. Restart represents the operator's subsequent start.
+type r2Units struct {
+ *plan14RestartUnits
+ showErr error
+ tryErr error
+ tries []string
+}
+
+func (u *r2Units) Show(unit string) (unitProps, error) { return u.props, u.showErr }
+func (u *r2Units) TryRestart(unit string) error {
+ u.tries = append(u.tries, unit)
+ if u.tryErr != nil {
+ return u.tryErr
+ }
+ if u.props.ActiveState == "inactive" || u.props.ActiveState == "failed" {
+ return nil
+ }
+ return u.Restart(unit)
+}
+func (u *r2Units) ResetFailed(unit string) error {
+ u.effects = append(u.effects, "reset "+unit)
+ if u.props.MainPID == 0 {
+ u.props.ActiveState, u.props.SubState = "inactive", "dead"
+ }
+ return nil
+}
+
+type r2Live struct {
+ e *proofRootEnv
+ rt registry.Route
+ u *r2Units
+ c rootRestartChecks
+ source *proofBindingSource
+ pub ed25519.PublicKey
+ key ed25519.PrivateKey
+}
+
+func r2Fixture(t *testing.T, sourceKind string) *r2Live {
+ t.Helper()
+ e, rt, base, c, _ := plan14AdoptionFixture(t)
+ u := &r2Units{plan14RestartUnits: base}
+ if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil {
+ t.Fatal(err)
+ }
+ rt.Account = ""
+ if err := e.root.reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ s := productionProofBindingSource(e.store, u, u.opt)
+ s.namedPath = filepath.Join(t.TempDir(), "named-units.json")
+ s.unitDirs = []string{filepath.Dir(u.props.FragmentPath)}
+ s.healthy = nil
+ put := func(path string, body []byte) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, body, 0644); err != nil {
+ t.Fatal(err)
+ }
+ makeProofAncestorsPublic(t, path)
+ }
+ makeProofAncestorsPublic(t, s.namedPath)
+ makeProofAncestorsPublic(t, u.exe)
+ makeProofAncestorsPublic(t, u.props.FragmentPath)
+ switch sourceKind {
+ case "guard":
+ put(filepath.Join(u.opt, "system/guard/console-ready.json"), []byte(release.LiveLink(u.opt, rt.Host)))
+ drop := filepath.Join(s.unitDirs[0], "console.service.d/90-host-floor.conf")
+ put(drop, []byte(hostfloor.ConsoleGuardDropIn(release.LiveLink(u.opt, rt.Host))))
+ u.props.DropInPaths = []string{drop}
+ u.props.ExecPath = "/opt/carlos/system/guard/carlos"
+ put(u.props.ExecPath, []byte("guard"))
+ case "list":
+ body, err := json.Marshal(struct {
+ Schema int `json:"schema"`
+ Units []namedUnit `json:"units"`
+ }{1, []namedUnit{{Host: rt.Host, App: rt.App, Unit: rt.Unit, Addr: rt.Addr, Health: &provision.Health{Path: "/ready"}}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ put(s.namedPath, body)
+ }
+ c.resolve, c.host, c.rollback, c.prepareRollback, c.dispatch, c.targetFile = s.resolve, s.forHost, s.rollback, s.prepareRollback, s.needsRestart, rootstate.OpenTrustedFile
+ c.legacy, c.legacyLog, c.legacyReady = legacyRootRestart, &sync.Once{}, c.ready
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := e.store.Delete(t.Context(), release.ChannelKey("bab", rt.App, rt.Channel)); err != nil {
+ t.Fatal(err)
+ }
+ f := &r2Live{e: e, rt: rt, u: u, c: c, source: s, pub: pub, key: key}
+ f.promote(t, "v1")
+ f.promote(t, "v2")
+ return f
+}
+func (f *r2Live) promote(t *testing.T, v string) {
+ t.Helper()
+ if err := release.Promote(t.Context(), f.e.store, f.key, "bab", f.rt.App, f.rt.Channel, v); err != nil {
+ t.Fatal(err)
+ }
+}
+func (f *r2Live) restart() func(string) error {
+ return rootRestartRouteWith(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, nil, f.c)
+}
+func (f *r2Live) pass(t *testing.T) error {
+ return release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, f.restart(), release.WithRunningVersion(f.c.runningVersion), release.WithRestartFailurePropagation(), release.WithStartUnit(rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, f.c)), release.WithRollbackRestart(rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, func(string) string { return "5s" }, f.c)))
+}
+func (f *r2Live) wantLive(t *testing.T, v string) {
+ t.Helper()
+ got, err := release.LiveVersion(f.u.opt, f.rt.Host)
+ if err != nil || got != v {
+ t.Fatalf("live=%s, err=%v; want %s", got, err, v)
+ }
+}
+func (f *r2Live) wantRunning(t *testing.T, v string) {
+ t.Helper()
+ if f.u.props.MainPID <= 0 || f.u.exe != release.VersionPath(f.u.opt, "", v, f.rt.App) {
+ t.Fatalf("not running %s: %+v", v, f.u)
+ }
+}
+
+func TestR2UnguardedConsoleUpdate(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, kind := range []string{"none", "list"} {
+ t.Run(kind, func(t *testing.T) {
+ f := r2Fixture(t, kind)
+ var logged bytes.Buffer
+ previousLog := log.Writer()
+ log.SetOutput(&logged)
+ defer log.SetOutput(previousLog)
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v2")
+ if len(f.u.effects) != 1 {
+ t.Fatal(f.u.effects)
+ }
+ // The same callback serves config sync and restart-only requests.
+ rt := f.rt
+ rt.Backing = registry.BackingUnit
+ if err := f.e.root.reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.SetEnv(t.Context(), f.e.store, "bab", rt.App, map[string]string{"LOG_LEVEL": "debug"}); err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ for i := 0; i < 2; i++ {
+ if err := appconfig.Sync(t.Context(), f.e.root.reg, f.e.store, nil, dir, f.restart(), nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(f.u.effects) != 2 {
+ t.Fatal("config sync missed or repeated restart", f.u.effects)
+ }
+ routes, err := f.e.root.reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ marks := filepath.Join(t.TempDir(), "restart.json")
+ restartPass(t.Context(), routes, f.e.store, marks, f.restart())
+ askRestart(t, t.Context(), f.e.store, "bab", rt.App)
+ restartPass(t.Context(), routes, f.e.store, marks, f.restart())
+ restartPass(t.Context(), routes, f.e.store, marks, f.restart())
+ if len(f.u.effects) != 3 {
+ t.Fatal("restart-only missed or repeated restart", f.u.effects)
+ }
+ if kind == "none" && strings.Count(logged.String(), "legacy restart (no root census)") != 1 {
+ t.Fatal("legacy restart diagnostic was not once per pass", logged.String())
+ }
+ })
+ }
+}
+
+func TestR2UnguardedIncidentRecipe(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, kind := range []string{"none", "guard", "list"} {
+ for _, order := range []string{"reset before adoption", "reset after adoption", "never failed"} {
+ t.Run(kind+"/"+order, func(t *testing.T) {
+ f := r2Fixture(t, kind)
+ if err := release.StageArtifact(f.u.opt, "", "v2", f.rt.App, []byte("new build")); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PointLive(f.u.opt, f.rt.Host, "", "v2", f.rt.App); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.e.root.reg.SetVersions(f.rt.Host, "v2", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ f.u.exe = release.VersionPath(f.u.opt, "", "v2", f.rt.App)
+ f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0
+ if order == "never failed" {
+ f.u.props.ActiveState, f.u.props.SubState = "inactive", "dead"
+ }
+ if order == "reset before adoption" {
+ if err := f.u.ResetFailed(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := len(f.u.effects)
+ if kind != "none" {
+ if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil {
+ t.Fatal("PID-zero binding rejected", err)
+ }
+ f.c.legacy = nil
+ }
+ f.promote(t, "v1")
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v1")
+ if order != "reset after adoption" && (f.u.props.MainPID != 0 || len(f.u.effects) != before) {
+ t.Fatal("woke inactive unit", f.u.effects)
+ }
+ if order == "reset after adoption" {
+ if err := f.u.ResetFailed(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := f.u.Restart(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ f.wantRunning(t, "v1")
+ })
+ }
+ }
+}
+
+func TestR2GuardedAutoRestartFixForward(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, state := range []unitProps{{ActiveState: "activating", SubState: "auto-restart"}, {ActiveState: "activating", SubState: "start"}} {
+ t.Run(state.SubState, func(t *testing.T) {
+ f := r2Fixture(t, "guard")
+ f.c.legacy = nil
+ f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = state.ActiveState, state.SubState, 0
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v2")
+ if len(f.u.effects) != 2 || f.u.effects[0] != "reset console.service" {
+ t.Fatal(f.u.effects)
+ }
+ })
+ }
+}
+
+func TestR2BoxLocalUnitStamp(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, recordUnit := range []string{"", "custom.service"} {
+ t.Run(recordUnit, func(t *testing.T) {
+ f := r2Fixture(t, "none")
+ // A socket-derived record cannot authorize a custom named unit. The
+ // no-census fallback still uses main's box-local stamp.
+ plan14PutRecord(t, f.e.store, provision.Record{Schema: 1, Host: f.rt.Host, Account: "bab", App: f.rt.App, Channel: f.rt.Channel, Unit: recordUnit})
+ f.rt.Unit = "titogo-canary@t.service"
+ f.u.props.Name = f.rt.Unit
+ if err := f.e.root.reg.Upsert(f.rt); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantRunning(t, "v2")
+ if len(f.u.tries) != 1 || f.u.tries[0] != f.rt.Unit {
+ t.Fatal(f.u.tries)
+ }
+ })
+ }
+}
+
+type r2ListFailure struct {
+ release.Store
+ lists int
+}
+
+func (s *r2ListFailure) List(context.Context, string) ([]string, error) {
+ s.lists++
+ return nil, os.ErrPermission
+}
+func TestR2OrdinaryRestartWithListFailure(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, kind := range []string{"unit", "exec", "derived stamp"} {
+ t.Run(kind, func(t *testing.T) {
+ f := r2Fixture(t, "none")
+ st := &r2ListFailure{Store: f.e.store}
+ f.source.store = st
+ f.rt.Unit = ""
+ f.rt.Backing = registry.BackingUnit
+ if kind == "exec" {
+ f.rt.Backing = "exec"
+ }
+ if kind == "derived stamp" {
+ f.rt.Unit = activator.UnitName(f.rt.Host)
+ }
+ if err := f.e.root.reg.Delete(f.rt.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.e.root.reg.Upsert(f.rt); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v2")
+ if st.lists != 0 || len(f.u.tries) != 1 || f.u.tries[0] != activator.UnitName(f.rt.Host) {
+ t.Fatal(st.lists, f.u.tries)
+ }
+ })
+ }
+}
+
+func TestR2NamedRestartWithListFailure(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ f := r2Fixture(t, "none")
+ f.source.store = &r2ListFailure{Store: f.e.store}
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v2")
+}
+
+func TestR2OrdinaryRestartMatchesMain(t *testing.T) {
+ for _, scenario := range []string{"show error", "exec mismatch", "activating", "inactive", "missing row", "registry error", "try-restart error"} {
+ t.Run(scenario, func(t *testing.T) {
+ e, rt, base, c, _ := plan14AdoptionFixture(t)
+ u := &r2Units{plan14RestartUnits: base}
+ rt.Unit = ""
+ rt.Backing = registry.BackingUnit
+ if err := e.root.reg.Delete(rt.Host); err != nil {
+ t.Fatal(err)
+ }
+ if err := e.root.reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ c.legacy = func() (bool, error) { return true, nil }
+ c.dispatch = func(context.Context, registry.Route) (bool, error) {
+ t.Fatal("ordinary restart consulted records")
+ return false, nil
+ }
+ switch scenario {
+ case "show error":
+ u.showErr = os.ErrPermission
+ case "exec mismatch":
+ u.props.ExecPath = "/different"
+ case "activating":
+ u.props.ActiveState = "activating"
+ case "inactive":
+ u.props.ActiveState = "inactive"
+ case "missing row":
+ if err := e.root.reg.Delete(rt.Host); err != nil {
+ t.Fatal(err)
+ }
+ case "registry error":
+ e.root.reg.Close()
+ case "try-restart error":
+ u.tryErr = os.ErrPermission
+ }
+ if err := rootRestartRouteWith(e.root.reg, u, u.opt, nil, nil, c)(rt.Host); err != nil {
+ t.Fatal(err)
+ }
+ if len(u.tries) != 1 || u.tries[0] != activator.UnitName(rt.Host) {
+ t.Fatal(u.tries)
+ }
+ if scenario == "inactive" && len(u.effects) != 0 {
+ t.Fatal("woke inactive ordinary unit")
+ }
+ if err := rollbackUnitRestart(e.root.reg, u, u.opt, nil, c)(rt.Host); err != nil {
+ t.Fatal("ordinary rollback reports outage", err)
+ }
+ })
+ }
+}
+
+func TestR2BindingRefusalKeepsPromotedFix(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, during := range []bool{false, true} {
+ t.Run(map[bool]string{false: "before restart", true: "after restart"}[during], func(t *testing.T) {
+ f := r2Fixture(t, "list")
+ if during {
+ f.u.afterRestart = func() { f.source.namedPath = filepath.Join(t.TempDir(), "missing") }
+ } else {
+ f.source.unitDirs = []string{"/wrong"}
+ }
+ if err := f.pass(t); err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v2")
+ })
+ }
+}
+
+func TestR2CrashBeforeRestartCapture(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, fail := range []bool{false, true} {
+ t.Run(map[bool]string{false: "forward", true: "rollback"}[fail], func(t *testing.T) {
+ f := r2Fixture(t, "list")
+ crashed := errors.New("crash after repoint before restart capture")
+ func() {
+ defer func() {
+ if recover() != crashed {
+ t.Fatal("missing crash")
+ }
+ }()
+ _ = release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, func(string) error { panic(crashed) }, release.WithRunningVersion(f.c.runningVersion))
+ }()
+ f.wantLive(t, "v2")
+ f.wantRunning(t, "v1")
+ state, err := f.c.attempts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readRollbackAttempt(state, f.rt.Host); !os.IsNotExist(err) {
+ t.Fatal("attempt already captured", err)
+ }
+ m := release.Manifest{App: f.rt.App, Version: "v3", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
+ if err := release.Ship(t.Context(), f.e.store, "bab", m, map[string][]byte{release.AdoptPlatform(): []byte("third build")}); err != nil {
+ t.Fatal(err)
+ }
+ f.promote(t, "v3")
+ // A new resolver and restart callback model the next root process.
+ f.source.index = nil
+ if fail {
+ f.u.afterEffect = func() {
+ if f.u.exe == release.VersionPath(f.u.opt, "", "v3", f.rt.App) {
+ f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0
+ }
+ }
+ }
+ err = f.pass(t)
+ if fail {
+ if err == nil {
+ t.Fatal("failed build accepted")
+ }
+ f.wantLive(t, "v1")
+ f.wantRunning(t, "v1")
+ } else {
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.wantLive(t, "v3")
+ f.wantRunning(t, "v3")
+ prior, err := os.Readlink(filepath.Join(f.u.opt, "previous", f.rt.Host))
+ if err != nil || prior != release.VersionPath(f.u.opt, "", "v1", f.rt.App) {
+ t.Fatal("lost running authority", prior, err)
+ }
+ pins, err := release.LivePins(f.u.opt)
+ if err != nil || !pins["v1"] {
+ t.Fatal("rollback build not pinned", pins, err)
+ }
+ }
+ })
+ }
+}
+
+func TestR2RetainedCensusKeepsStrictRestart(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, body := range []string{"{}", "corrupt"} {
+ t.Run(body, func(t *testing.T) {
+ f := r2Fixture(t, "none")
+ s, err := rootstate.Open(rootparticipation.Namespace, true, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Write("census.json", []byte(body)); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { s.Remove("census.json"); s.Close() }()
+ if legacy, err := legacyRootRestart(); err != nil || legacy {
+ t.Fatal("retained census selected legacy", legacy, err)
+ }
+ if err := f.restart()(f.rt.Host); err == nil || len(f.u.effects) != 0 {
+ t.Fatal("unbound restart passed retained census", err, f.u.effects)
+ }
+ })
+ }
+}
+
+func TestR2UnguardedCrashRollback(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ f := r2Fixture(t, "none")
+ f.u.afterEffect = func() {
+ if f.u.exe == release.VersionPath(f.u.opt, "", "v2", f.rt.App) {
+ f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = "failed", "failed", 0
+ }
+ }
+ if err := f.pass(t); err == nil {
+ t.Fatal("crashed build accepted")
+ }
+ f.wantLive(t, "v1")
+ f.wantRunning(t, "v1")
+ if len(f.u.effects) != 3 {
+ t.Fatal("rollback did not reset and restart", f.u.effects)
+ }
+}
+
+func TestR2NamedFallbackMatchesMain(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, scenario := range []string{"show error", "exec mismatch", "inactive", "failed", "activating", "try-restart error"} {
+ t.Run(scenario, func(t *testing.T) {
+ f := r2Fixture(t, "none")
+ switch scenario {
+ case "show error":
+ f.u.showErr = os.ErrPermission
+ case "exec mismatch":
+ f.u.props.ExecPath = "/other"
+ case "inactive", "failed", "activating":
+ f.u.props.ActiveState = scenario
+ f.u.props.MainPID = 0
+ case "try-restart error":
+ f.u.tryErr = os.ErrPermission
+ }
+ err := f.restart()(f.rt.Host)
+ if (scenario == "try-restart error") != errors.Is(err, os.ErrPermission) {
+ t.Fatal("fallback changed try-restart result", err)
+ }
+ if len(f.u.tries) != 1 || f.u.tries[0] != f.rt.Unit {
+ t.Fatal(f.u.tries)
+ }
+ if (scenario == "inactive" || scenario == "failed") && len(f.u.effects) != 0 {
+ t.Fatal("try-restart woke stopped unit")
+ }
+ if scenario == "try-restart error" {
+ starts := 0
+ f.c.legacyStart = func(unit string) error {
+ if unit != f.rt.Unit {
+ t.Fatal(unit)
+ }
+ starts++
+ return nil
+ }
+ if err := rollbackUnitRestart(f.e.root.reg, f.u, f.u.opt, nil, f.c)(f.rt.Host); err != nil || starts != 1 {
+ t.Fatal("non-NotUp legacy recovery did not use main start", err, starts)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/host/plan14_fix3_lock_linux_test.go b/internal/host/plan14_fix3_lock_linux_test.go
new file mode 100644
index 000000000..c7d716df2
--- /dev/null
+++ b/internal/host/plan14_fix3_lock_linux_test.go
@@ -0,0 +1,108 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "golang.org/x/sys/unix"
+)
+
+func TestR2LegacyLockContentionRetries(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, scenario := range []string{"contention", "fifo", "foreign owner", "io error"} {
+ t.Run(scenario, func(t *testing.T) {
+ f := newLaunchFixture(t, servingOp())
+ g := f.root.Guard
+ store := &rollbackDisk{dir: t.TempDir()}
+ g.Root = hostguard.NewRootAuthority(store)
+ in := hostguard.RootInstallation{Host: f.target.Host, Account: "bab", App: "app", Record: "record", Incarnation: f.target.Incarnation, Stamp: f.target.Stamp, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1}
+ // The fixture's target carries installation identity; the claim remains
+ // the authority checked by EstablishRoot and BeginFence.
+ f.claims.set(func(c *hostclaim.Claim) {
+ c.Holder.Account = in.Account
+ c.Holder.App = in.App
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: f.target.Box, Region: f.target.Region, Session: f.target.Session, Generation: c.Holder.Generation}}
+ })
+ if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ before, err := store.Read(in.Host + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(g.LockDir, in.Host+".lock")
+ var held *os.File
+ switch scenario {
+ case "contention":
+ held, err = os.OpenFile(path, os.O_RDWR, 0660)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Close()
+ if err := unix.Flock(int(held.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Flock(int(held.Fd()), unix.LOCK_UN)
+ case "fifo":
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Mkfifo(path, 0660); err != nil {
+ t.Fatal(err)
+ }
+ case "foreign owner":
+ if err := unix.Mount("/foreign", path, "", unix.MS_BIND, ""); err != nil {
+ t.Fatal(err)
+ }
+ defer unix.Unmount(path, 0)
+ case "io error":
+ g.LockDir = filepath.Join(g.LockDir, "missing")
+ }
+ target := f.target
+ target.FenceID = "fix3-fence"
+ start := time.Now()
+ _, err = g.BeginFence(t.Context(), target)
+ if err == nil {
+ t.Fatal("mutation crossed unavailable lock")
+ }
+ if time.Since(start) > time.Second {
+ t.Fatal("lock bound exceeded")
+ }
+ after, readErr := store.Read(in.Host + ".json")
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ captured := hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.Stamp}
+ _, viewErr := g.Root.View(t.Context(), in.Host, captured)
+ if scenario == "fifo" || scenario == "foreign owner" {
+ if !errors.Is(err, hostguard.ErrUnsafeFile) || !errors.Is(viewErr, hostguard.ErrFenced) || bytes.Equal(before, after) {
+ t.Fatal("hostile lock not durably quarantined", err, viewErr)
+ }
+ } else {
+ if !bytes.Equal(before, after) || viewErr != nil {
+ t.Fatal("transient lock failure changed durable state", err, viewErr)
+ }
+ }
+ if scenario == "contention" {
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal(err)
+ }
+ if err := unix.Flock(int(held.Fd()), unix.LOCK_UN); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal("released lock did not retry", err)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/host/proofbinding.go b/internal/host/proofbinding.go
index a30c805a7..bb87bd0e6 100644
--- a/internal/host/proofbinding.go
+++ b/internal/host/proofbinding.go
@@ -227,7 +227,7 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
exe, err = release.VersionPath(s.optDir, rec.Account, rollback.Version, rec.App), nil
}
} else {
- if p.ActiveState == "failed" && p.MainPID == 0 {
+ if pointerBoundUnit(p) {
exe, err = filepath.EvalSymlinks(release.LiveLink(s.optDir, host))
} else {
if p.MainPID <= 0 || p.InvocationID == "" {
@@ -303,6 +303,12 @@ func (s *proofBindingSource) binding(ctx context.Context, host string, rollback
return proofBinding{ExecPath: execPath, Local: evidence.local, CandidateUnit: rec.Unit, History: history, Invocation: p.InvocationID, PID: p.MainPID, Start: start, Route: rt, Source: source, Network: true, Version: version, Digest: digest, Historical: historical, RecordKey: evidence.key, RecordETag: etag, Files: files}, nil
}
+// A unit without a process still has its checked unit definition and root
+// release pointer. Inactive units bind for repointing but must stay asleep.
+func pointerBoundUnit(p unitProps) bool {
+ return p.MainPID == 0 && (p.ActiveState == "failed" || p.ActiveState == "inactive" || p.ActiveState == "activating" || p.SubState == "auto-restart")
+}
+
func (s *proofBindingSource) forHost(ctx context.Context, host string) (proofBinding, error) {
return s.binding(ctx, host, nil)
}
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go
index e5cac0a64..25e5a243e 100644
--- a/internal/host/rootrestart.go
+++ b/internal/host/rootrestart.go
@@ -22,6 +22,10 @@ import (
)
type rootRestartChecks struct {
+ legacy func() (bool, error)
+ legacyLog *sync.Once
+ legacyReady func(registry.Route) func() error
+ legacyStart func(string) error
targetDigest string
targetFile func(string) (*os.File, error)
dispatch func(context.Context, registry.Route) (bool, error)
@@ -55,7 +59,7 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck
cached = productionProofBindingSource(s, sys, optDir)
return cached, nil
}
- return rootRestartChecks{targetFile: rootstate.OpenTrustedFile, started: &sync.Map{},
+ return rootRestartChecks{legacy: legacyRootRestart, legacyLog: &sync.Once{}, targetFile: rootstate.OpenTrustedFile, started: &sync.Map{},
dispatch: func(ctx context.Context, rt registry.Route) (bool, error) {
s, err := source(ctx)
if err != nil {
@@ -102,6 +106,27 @@ func productionRootRestartChecks(sys unitSystem, optDir string) rootRestartCheck
}
}
+func (c rootRestartChecks) runningVersion(ctx context.Context, rt registry.Route) (string, error) {
+ if rt.Unit == "" || rt.Unit == activator.UnitName(rt.Host) {
+ return "", nil
+ }
+ b, err := c.resolve(ctx, rt)
+ if err != nil {
+ legacy, checkErr := c.allowLegacy()
+ if checkErr != nil {
+ return "", checkErr
+ }
+ if legacy {
+ return "", nil
+ }
+ return "", err
+ }
+ if b.PID > 0 {
+ return b.Version, nil
+ }
+ return "", nil
+}
+
func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks ...rootRestartChecks) func(registry.Route) error {
c := productionRootRestartChecks(sys, optDir)
if len(checks) == 1 {
@@ -132,7 +157,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
if before.ActiveState == "inactive" {
return errUnitStopped
}
- if before.ActiveState != "failed" && (before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) {
+ if !pointerBoundUnit(before) && (before.ActiveState != "active" || before.MainPID <= 0 || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) {
return os.ErrPermission
}
target, err := filepath.EvalSymlinks(release.LiveLink(optDir, candidate.Host))
@@ -160,7 +185,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
targetDigest := hex.EncodeToString(hash.Sum(nil))
c.targetDigest = targetDigest
captured := binding
- if before.ActiveState == "failed" {
+ if pointerBoundUnit(before) {
previous, e := filepath.EvalSymlinks(filepath.Join(optDir, "previous", candidate.Host))
if e == nil && previous != target {
rel, e := filepath.Rel(filepath.Join(optDir, "versions"), previous)
@@ -207,6 +232,9 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
if errors.Is(err, errUnitStopped) {
return errors.Join(err, settleRollbackAttempt(s, attempt))
}
+ if errors.Is(err, errRestartBinding) {
+ return errors.Join(err, settleRollbackAttempt(s, attempt))
+ }
attempt.Failed = true
var notUp *release.UnitNotUpError
if !errors.As(err, &notUp) {
@@ -219,20 +247,20 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string, binding proofBinding, rollback bool, settled func(registry.Route) error, c rootRestartChecks) error {
rt := binding.Route
if binding.Source == "" || !binding.Network || c.ready == nil {
- return os.ErrPermission
+ return errRestartBinding
}
ready := c.ready(rt)
if ready == nil {
- return os.ErrPermission
+ return errRestartBinding
}
before, err := sys.Show(rt.Unit)
if err != nil {
- return err
+ return errors.Join(errRestartBinding, err)
}
if before.ExecPath != proofExecPath(binding, optDir) {
- return os.ErrPermission
+ return errRestartBinding
}
- if before.ActiveState == "inactive" && before.SubState == "dead" {
+ if before.ActiveState == "inactive" {
return errUnitStopped
}
want, err := filepath.EvalSymlinks(release.LiveLink(optDir, rt.Host))
@@ -249,7 +277,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if rollback {
current, err := c.rollback(context.Background(), binding)
if err != nil || current.Source != binding.Source {
- return os.ErrPermission
+ return errRestartBinding
}
if err := sys.ResetFailed(rt.Unit); err != nil {
return err
@@ -258,20 +286,20 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return err
}
} else {
- if before.ActiveState != "failed" && (before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) {
- return os.ErrPermission
+ if !pointerBoundUnit(before) && (before.ActiveState != "active" || before.MainPID != binding.PID || before.InvocationID != binding.Invocation) {
+ return errRestartBinding
}
if before.MainPID > 0 {
start, err := sys.StartTime(before.MainPID)
if err != nil || start != binding.Start {
- return os.ErrPermission
+ return errRestartBinding
}
}
current, err := c.host(context.Background(), rt.Host)
if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation {
- return os.ErrPermission
+ return errRestartBinding
}
- if before.ActiveState == "failed" {
+ if pointerBoundUnit(before) {
if err := sys.ResetFailed(rt.Unit); err != nil {
return err
}
@@ -286,23 +314,28 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return err
}
var observed proofBinding
+ var bindingErr error
checkedReady := func() error {
first, err := c.host(context.Background(), rt.Host)
if err != nil || first.Source != binding.Source || want != release.VersionPath(optDir, first.Route.Account, first.Version, first.Route.App) {
- return os.ErrPermission
+ bindingErr = errors.Join(errRestartBinding, err)
+ return bindingErr
}
if c.targetDigest != "" && first.Digest != c.targetDigest {
- return os.ErrPermission
+ bindingErr = errRestartBinding
+ return bindingErr
}
if rollback && (first.Version != binding.Version || first.Digest != binding.Digest) {
- return os.ErrPermission
+ bindingErr = errRestartBinding
+ return bindingErr
}
if err := ready(); err != nil {
return err
}
last, err := c.host(context.Background(), rt.Host)
if err != nil || last.Source != first.Source || last.Version != first.Version || last.Digest != first.Digest || last.Invocation != first.Invocation || last.PID != first.PID || last.Start != first.Start {
- return os.ErrPermission
+ bindingErr = errors.Join(errRestartBinding, err)
+ return bindingErr
}
observed = last
return nil
@@ -312,14 +345,17 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
WantDir: filepath.Dir(want), Ready: checkedReady, Now: c.now, Sleep: c.sleep, RequireExe: true,
})
if err != nil {
+ if bindingErr != nil {
+ return bindingErr
+ }
return err
}
current, err := c.host(context.Background(), rt.Host)
if err != nil {
- return err
+ return errors.Join(errRestartBinding, err)
}
if current.Invocation != observed.Invocation || current.PID != observed.PID || current.Start != observed.Start || current.Source != binding.Source || want != release.VersionPath(optDir, current.Route.Account, current.Version, current.Route.App) || (rollback && (current.Version != binding.Version || current.Digest != binding.Digest)) {
- return os.ErrPermission
+ return errRestartBinding
}
if settled != nil {
if err := effect(func() error { return settled(binding.Route) }); err != nil {
@@ -329,13 +365,35 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return nil
}
-func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, checks ...rootRestartChecks) func(string) error {
+func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, checks ...rootRestartChecks) func(string) error {
c := productionRootRestartChecks(sys, optDir)
if len(checks) == 1 {
c = checks[0]
}
return func(host string) (result error) {
+ legacy, err := c.allowLegacy()
+ if err != nil {
+ return err
+ }
+ if legacy {
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil || rt.Unit == "" {
+ return nil
+ }
+ defer func() {
+ if errors.Is(result, errUnitStopped) {
+ result = nil
+ } else if result != nil && !restartEffectFailed(result) {
+ c.noteLegacy()
+ if c.legacyStart != nil {
+ result = c.legacyStart(rt.Unit)
+ } else {
+ result = legacyRollbackRestart(sys, optDir, getenv, *rt, c)
+ }
+ }
+ }()
+ }
if c.attempts == nil || c.rollback == nil {
return os.ErrPermission
}
@@ -398,6 +456,11 @@ func rollbackUnitRestart(_ *registry.Registry, sys unitSystem, optDir string, ge
}
+func rootStartRoute(reg *registry.Registry, optDir string, checks rootRestartChecks) func(string) error {
+ checks.legacyStart = systemctlStartUnit
+ return rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv, checks)
+}
+
func rootRestartRoute(reg *registry.Registry, optDir string, settled func(registry.Route) error) func(string) error {
return rootRestartRouteWith(reg, systemdUnits{}, optDir, os.Getenv, settled, productionRootRestartChecks(systemdUnits{}, optDir))
}
@@ -420,7 +483,14 @@ func rootSelectedRestart(reg *registry.Registry, sys unitSystem, optDir string,
func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, settled func(registry.Route) error, checks rootRestartChecks) func(string) error {
restart := checkedUnitRestart(sys, optDir, getenv, settled, checks)
return func(host string) error {
+ legacy, err := checks.allowLegacy()
+ if err != nil {
+ return err
+ }
rt, err := reg.Get(host)
+ if legacy && (err != nil || rt == nil || rt.Unit == "" || rt.Unit == activator.UnitName(host)) {
+ return restartOrdinaryUnit(sys, host)
+ }
if err != nil {
return err
}
@@ -430,17 +500,40 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string,
if checks.dispatch != nil {
need, err := checks.dispatch(context.Background(), *rt)
if err != nil {
+ if legacy {
+ checks.noteLegacy()
+ return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
+ }
return err
}
if !need {
- return restartOrdinaryUnit(sys, optDir, host, checks.targetFile)
+ if legacy {
+ checks.noteLegacy()
+ return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
+ }
+ return restartVerifiedOrdinaryUnit(sys, optDir, host, checks.targetFile)
}
}
- return restart(*rt)
+ err = restart(*rt)
+ if errors.Is(err, errUnitStopped) {
+ return nil
+ }
+ if legacy && err != nil && !restartEffectFailed(err) {
+ checks.noteLegacy()
+ return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
+ }
+ return err
}
}
-func restartOrdinaryUnit(sys unitSystem, optDir, host string, open func(string) (*os.File, error)) error {
+func restartOrdinaryUnit(sys unitSystem, host string) error {
+ if err := sys.TryRestart(activator.UnitName(host)); err != nil {
+ log.Print(err)
+ }
+ return nil
+}
+
+func restartVerifiedOrdinaryUnit(sys unitSystem, optDir, host string, open func(string) (*os.File, error)) error {
unit := activator.UnitName(host)
p, err := sys.Show(unit)
if err != nil || p.Name != unit || p.ExecPath != release.LiveLink(optDir, host) || p.ActiveState != "active" || open == nil {
diff --git a/internal/hostguard/flock_other.go b/internal/hostguard/flock_other.go
index 8e0502b5a..4647869b5 100644
--- a/internal/hostguard/flock_other.go
+++ b/internal/hostguard/flock_other.go
@@ -4,4 +4,4 @@ package hostguard
import "context"
-func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported }
+func acquire(context.Context, string, string, ...bool) (func(), error) { return nil, ErrUnsupported }
diff --git a/internal/hostguard/flock_unix.go b/internal/hostguard/flock_unix.go
index 9ba78ef87..78a251a77 100644
--- a/internal/hostguard/flock_unix.go
+++ b/internal/hostguard/flock_unix.go
@@ -11,7 +11,7 @@ import (
"golang.org/x/sys/unix"
)
-func acquire(ctx context.Context, dir, host string) (func(), error) {
+func acquire(ctx context.Context, dir, host string, verifyOwner ...bool) (func(), error) {
if ctx == nil {
return nil, ErrInvalidInput
}
@@ -26,6 +26,17 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
return nil, err
}
defer unix.Close(dirFD)
+ var parent unix.Stat_t
+ if err := unix.Fstat(dirFD, &parent); err != nil {
+ return nil, err
+ }
+ unsafe := func(st unix.Stat_t) bool {
+ return st.Mode&unix.S_IFMT != unix.S_IFREG || (len(verifyOwner) == 1 && verifyOwner[0] && st.Uid != 0 && st.Uid != parent.Uid)
+ }
+ var prior unix.Stat_t
+ if err := unix.Fstatat(dirFD, host+".lock", &prior, unix.AT_SYMLINK_NOFOLLOW); err == nil && unsafe(prior) {
+ return nil, ErrUnsafeFile
+ }
flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK
fd, err := unix.Openat(dirFD, host+".lock", flags|unix.O_EXCL, 0660)
created := err == nil
@@ -33,6 +44,9 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
fd, err = unix.Openat(dirFD, host+".lock", flags, 0660)
}
if err != nil {
+ if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EISDIR) {
+ return nil, ErrUnsafeFile
+ }
return nil, err
}
f := os.NewFile(uintptr(fd), host+".lock")
@@ -41,7 +55,7 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
if err := unix.Fstat(fd, &st); err != nil {
return fail(err)
}
- if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ if unsafe(st) {
return fail(ErrUnsafeFile)
}
if created || st.Mode&07777 != 0660 {
@@ -49,8 +63,7 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
return fail(err)
}
}
- tick := time.NewTicker(10 * time.Millisecond)
- defer tick.Stop()
+ delay := 10 * time.Millisecond
for {
if err := ctx.Err(); err != nil {
return fail(err)
@@ -67,10 +80,13 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) && !errors.Is(err, unix.EINTR) {
return fail(err)
}
+ timer := time.NewTimer(delay)
select {
case <-ctx.Done():
+ timer.Stop()
return fail(ctx.Err())
- case <-tick.C:
+ case <-timer.C:
}
+ delay = min(2*delay, 100*time.Millisecond)
}
}
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
index 19a831554..50ea2c032 100644
--- a/internal/hostguard/guard.go
+++ b/internal/hostguard/guard.go
@@ -75,9 +75,11 @@ func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) erro
lockCtx, cancel = context.WithTimeout(ctx, 250*time.Millisecond)
}
defer cancel()
- unlock, err := acquire(lockCtx, dir, host)
+ unlock, err := acquire(lockCtx, dir, host, g.Root != nil)
if err != nil {
- if g.Root != nil {
+ // Contention and I/O errors are retryable, not hostile evidence.
+ // Only a positively unsafe lock can quarantine an installation.
+ if g.Root != nil && errors.Is(err, ErrUnsafeFile) {
r, readErr := g.Root.read(host)
if readErr == nil {
r.quarantined = true
diff --git a/internal/hostguard/plan14_fix2_unix_test.go b/internal/hostguard/plan14_fix2_unix_test.go
index f9b603756..15756492e 100644
--- a/internal/hostguard/plan14_fix2_unix_test.go
+++ b/internal/hostguard/plan14_fix2_unix_test.go
@@ -3,6 +3,7 @@
package hostguard
import (
+ "bytes"
"context"
"errors"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -22,7 +23,15 @@ func TestPlan14Fix2LegacyLockCannotStallRoot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer unlock()
+ defer func() {
+ if unlock != nil {
+ unlock()
+ }
+ }()
+ before, err := f.g.Root.store.Read(target.Host + ".json")
+ if err != nil {
+ t.Fatal(err)
+ }
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
if _, err := f.g.PendingFences(ctx); err != nil {
@@ -32,11 +41,20 @@ func TestPlan14Fix2LegacyLockCannotStallRoot(t *testing.T) {
t.Fatal("mutation crossed held legacy lock")
}
r, err := f.g.Root.read(target.Host)
- if err != nil || !r.quarantined {
- t.Fatalf("missing durable deny: %+v %v", r, err)
+ if err != nil || r.quarantined {
+ t.Fatalf("honest contention quarantined: %+v %v", r, err)
+ }
+ after, err := f.g.Root.store.Read(target.Host + ".json")
+ if err != nil || !bytes.Equal(before, after) {
+ t.Fatal("contention wrote durable state", err)
+ }
+ if _, err := f.g.Root.View(ctx, target.Host, captured); err != nil {
+ t.Fatal("contention fenced running installation", err)
}
- if _, err := f.g.Root.View(ctx, target.Host, captured); !errors.Is(err, ErrFenced) {
- t.Fatal("quarantined view admitted", err)
+ unlock()
+ unlock = nil
+ if _, err := f.g.BeginFence(ctx, target); err != nil {
+ t.Fatal("released contention did not retry", err)
}
}
func TestPlan14Fix2PendingIntentCanBeAbandoned(t *testing.T) {
diff --git a/internal/hostguard/plan14_root_fences_test.go b/internal/hostguard/plan14_root_fences_test.go
index 1ee1c82c3..3b4736de3 100644
--- a/internal/hostguard/plan14_root_fences_test.go
+++ b/internal/hostguard/plan14_root_fences_test.go
@@ -281,8 +281,8 @@ func TestPlan14FenceViewWhileRootWaitsForAgent(t *testing.T) {
go func() { _, err := root.Root.View(ctx, target.Host, captured); viewDone <- err }()
select {
case err := <-viewDone:
- if !errors.Is(err, ErrFenced) {
- t.Fatal(err)
+ if err != nil {
+ t.Fatal("honest contention fenced the view", err)
}
case <-ctx.Done():
t.Fatal("fence view waited on root holding a lock needed by the agent")
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go
index 7320c17a6..6166ecb63 100644
--- a/internal/provision/plan14_legacy_identity_test.go
+++ b/internal/provision/plan14_legacy_identity_test.go
@@ -106,3 +106,48 @@ func TestPlan14MergeOverlayPreservesLegacySeal(t *testing.T) {
t.Fatal("new freeze entry accepted without a ruling")
}
}
+
+func TestPlan14Merge3OverlayPreservesLegacySeal(t *testing.T) {
+ root := censusRoot(t)
+ inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}}
+ for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv"} {
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if errs := checkRootReadLegacy(inv, "c3351f27f6467fc33b319b36a7e64b75b2d992232f956d9c6243cc49e0c05396"); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ original := make(map[string]bool, len(inv.Legacy))
+ for hash := range inv.Legacy {
+ original[hash] = true
+ }
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-merge3.tsv", &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ for hash := range original {
+ if !inv.Legacy[hash] {
+ t.Fatalf("merge3 removed original freeze %s", hash)
+ }
+ }
+ if len(inv.Legacy) != len(original)+3 {
+ t.Fatal("merge ruling 3 permits exactly three new frozen declarations")
+ }
+ const seal = "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47"
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ for _, key := range []string{"internal/host/instancekeys.go:backfillInstanceKeys", "internal/host/instancekeys.go:keyBackfillInSlot.BackfillInstanceKey", "internal/host/layoutleg.go:activatorStart.Run"} {
+ e := inv.Entries[key]
+ if e.Class != "legacy" || e.Task != "accepted-legacy" || !strings.HasPrefix(e.Basis, "accepted-legacy;rev3=merge-ruling-3;") {
+ t.Fatalf("wrong hotfix disposition: %+v", e)
+ }
+ }
+ if inv.Entries["internal/host/agent.go:runAgent"].Class != "authority" {
+ t.Fatal("runAgent lost kept authority")
+ }
+ inv.Legacy[strings.Repeat("0", 64)] = true
+ if errs := checkRootReadLegacy(inv, seal); len(errs) == 0 {
+ t.Fatal("unapproved legacy fingerprint accepted")
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 986aef08f..c76b527ec 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -621,6 +621,13 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix2.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-merge3.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ seal = "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 8ef5e26a8..e5947356a 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -487,7 +487,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/hostbox/journalservice.go", "journalRequestHash", "key-site", "key:RecordKey", "8c07978658bb060c28ad88477dcbb10d8591ef194cac283cb54eb6a4e833ac76"},
{"internal/hostbox/journalservice.go", "type JournalRequest", "key-site", "key:RecordKey", "14baf4c57a342a8b2dadaa95c965de4650ee352183de3a8c56768f47642ff5ab"},
{"internal/hostbox/journalservice.go", "validateJournalRequest", "key-site", "key:RecordKey", "724be9634772f00bf0d9da2c39c8049763c9aad6f3075c10f31c97ab69c15103"},
- {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "455e47321aad1c671f1d0ec468d0389b2cd2386b24f99cc3dd7c9251e5108bee"},
+ // Fix3 extends PID-zero binding states; RecordKey remains an identity read, with no record write.
+ {"internal/host/proofbinding.go", "proofBindingSource.binding", "key-site", "key:RecordKey", "8824e9a950f7d92ddbef2315938ec7702ec93814be24f067c6b8df1009ff4835"},
{"internal/host/proofbinding.go", "type proofBinding", "key-site", "key:RecordKey", "418f61bc5a4192d99821b315baeb1a5b4619593303f8a02e9b13ef824003a613"},
{"internal/host/rootroute.go", "proofBindingSource.claimIdentity", "key-site", "key:RecordKey", "ad6e0fc3f2cd360473a5d0a2267d2dc7944b45d80947613f791db8d573f8fee6"},
{"internal/provision/rootrecord.go", "IndexRecords", "key-site", "key:ParseRecordKey,lit:instances", "2e45f3c659a61943727ac1413d431c8320dbc1d4e89cb4f43df60c819764abf9"},
diff --git a/internal/provision/testdata/plan14-root-reads-merge3.tsv b/internal/provision/testdata/plan14-root-reads-merge3.tsv
new file mode 100644
index 000000000..ddf7cc1dd
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-merge3.tsv
@@ -0,0 +1,36 @@
+# Plan 14 fix3: merge ruling 3 records the released hotfix; merge ruling 4 restores no-census main behavior. Earlier overlays are immutable.
+internal/host/agent.go runAdoptUnderRootRelease authority B3,C rev3=merge-ruling-4;basis=Retain the verified running pointer before adoption; share per-pass legacy diagnostics and return errors only after minting and held-stage work. whole:Route@10432,whole:Route@17040,whole:Route@17076,whole:Route@7852 07e79632ccb0e66323fb10fb486308664109da813048dc5301d1225c5cbcd9c9
+internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F rev3=merge-ruling-4;basis=Propagate final adoption error after releasing pass resources so foundation failures exit 1 without skipping remaining work. file:Open@220 21abd9a81113b038cfa5dbed17da9fe4da3cfafa8b80215dd68b85bd15344460
+internal/host/agent.go runAgent authority B,C,D,F rev3=merge-ruling-3;basis=Only Route and preparation Slot wiring changed; claims-on bypasses legacy key backfill and all kept root decisions are unchanged. file:Exec@13447,file:Open@569,registry:App@9838,whole:Route@20948,whole:Route@9830 91afa8ed00ee9f26f047a03f651670ff693aa5bfcb119ac1bc2b7df585ddf897
+@legacy 0655b2d76b56f3c4eb62c9254ae60a968978d8e7c91b53bb0fb549987538f12c internal/host/instancekeys.go backfillInstanceKeys legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Released v0.26.1 backfill runs after Start only with claims off; skipped or fenced rows defer to their next launch. registry:Host@516,registry:Host@664,registry:Host@830,whole:Route@251,whole:Route@283,whole:Route@362 72b84a844c91718c379ffe6a644adfaaccc449bc6893b9264bf43029142f2c2b
+internal/host/instancekeys.go backfillInstanceKeys legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Released v0.26.1 backfill runs after Start only with claims off; skipped or fenced rows defer to their next launch. registry:Host@516,registry:Host@664,registry:Host@830,whole:Route@251,whole:Route@283,whole:Route@362 72b84a844c91718c379ffe6a644adfaaccc449bc6893b9264bf43029142f2c2b
+@legacy f48611141d27534443aa79140c0c8b52a0bb2f3934d05ac47545a8286caf41da internal/host/instancekeys.go keyBackfillInSlot.BackfillInstanceKey legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-off legacy key backfill rereads registry Host and whole Route inside the per-host preparation slot; claims-on returns before backfill. registry:Host@199,registry:Host@258,whole:Route@351,whole:Route@386,whole:Route@550 4bc87bdff90d1c13fbd6210023377e99303318b0e1d24fa15a143298eb480f05
+internal/host/instancekeys.go keyBackfillInSlot.BackfillInstanceKey legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-off legacy key backfill rereads registry Host and whole Route inside the per-host preparation slot; claims-on returns before backfill. registry:Host@199,registry:Host@258,whole:Route@351,whole:Route@386,whole:Route@550 4bc87bdff90d1c13fbd6210023377e99303318b0e1d24fa15a143298eb480f05
+@legacy c78b9ec186c0661d7bff3719593e17e98b9f5a40de827fe44563fc8ccd2abc8b internal/host/layoutleg.go activatorStart.Run legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-on starts and returns before the background backfill; legacy layout recovery precedes Start and each backfill row uses the preparation slot. whole:Route@492 b7045ebf921fb26573077273c2cfeef7dc99a73f036a414e8dbf6ce2cbf2eb3c
+internal/host/layoutleg.go activatorStart.Run legacy accepted-legacy accepted-legacy;rev3=merge-ruling-3;basis=Claims-on starts and returns before the background backfill; legacy layout recovery precedes Start and each backfill row uses the preparation slot. whole:Route@492 b7045ebf921fb26573077273c2cfeef7dc99a73f036a414e8dbf6ce2cbf2eb3c
+internal/host/legacyrestart.go legacyCheckedUnitRestart authority B3 rev3=merge-ruling-4;basis=No-census fallback matches main stamped-unit try-restart and settle behavior; Show or ExecStart mismatch still restarts and history errors are diagnostic. file:EvalSymlinks@378,registry:Host@241,registry:Unit@198,registry:Unit@352,registry:Unit@447,registry:Unit@485,registry:Unit@547,whole:Route@613,whole:Route@733 884d01a2b30cc90df5010341aacc52f5c7a7d3f9c2f9bf98b6dd868fae623133
+internal/host/legacyrestart.go legacyRollbackRestart authority B3 rev3=merge-ruling-4;basis=No-census D1 recovery resets and restarts the stamped unit on the restored pointer and verifies readiness as main does. file:EvalSymlinks@312,registry:Host@359,registry:Unit@157,registry:Unit@193,registry:Unit@259,registry:Unit@425,whole:Route@491 b99366bdf6a7530d83a5dd9d086028a628a33cd1b860ab57cfc4f341d341c8b7
+internal/host/legacyrestart.go legacyRootRestart authority B3,G rev3=merge-ruling-4;basis=Only absent root census with claims off permits main restart fallback; retained or unreadable root state remains strict. b2cf26dd5de78dd8fe57b6aac71b33df8602e9b5b0a0031736261e29f23738db
+internal/host/legacyrestart.go legacySettleOptions authority B3 rev3=merge-ruling-4;basis=Legacy settle uses main readiness inputs and limits; these do not authorize root health history. whole:Route@265,whole:Route@335 4bf5a4a5173c9f7d703502ae75d3aa5f8ef8fab7631625ecea2b1341ad855bc9
+internal/host/legacyrestart.go restartEffectFailed authority B3 rev3=merge-ruling-4;basis=Separate actual restart and readiness failure from optional binding refusal before selecting fallback. d847a3853f83e0b4a08183c57294c9e06955cdcabbebbfc62767ca8a05fa880f
+internal/host/legacyrestart.go rootRestartChecks.allowLegacy authority B3,G rev3=merge-ruling-4;basis=Evaluate the explicit census selector; omitted test selector remains strict. eee21cfff9fbe73b79fd4da4aa034bf5bebc9db8591663f3ec2e122907590097
+internal/host/legacyrestart.go rootRestartChecks.noteLegacy authority B3 rev3=merge-ruling-4;basis=Log the no-census legacy restart once through the pass-shared diagnostic latch. d2b7aad19cfb90d75fc599cf780195c3726060ac8cb8d677a9caa0ae4be267f6
+internal/host/proofbinding.go pointerBoundUnit authority B3 rev3=merge-ruling-4;basis=Only PID-zero stopped or restarting states may bind without process identity; inactive units are never woken. 7a6ef2f2281f2af98db43cf247d6b3625c3c29b9cad48cbfd555ecc0185bd6ec
+internal/host/proofbinding.go proofBindingSource.binding authority B3 rev3=merge-ruling-4;basis=Inactive and PID-zero activating units bind from checked current pointers with the same unit and source checks as failed units. file:EvalSymlinks@2524,file:EvalSymlinks@2763,file:EvalSymlinks@3182,file:EvalSymlinks@3491,registry:Unit@461,registry:Unit@4694,registry:Unit@544,registry:Unit@717,whole:Route@5174,whole:Route@5181 8824e9a950f7d92ddbef2315938ec7702ec93814be24f067c6b8df1009ff4835
+internal/host/rootrestart.go checkedUnitRestart authority B3 rev3=merge-ruling-4;basis=Retain verified rollback binding for active or restarting units; optional binding refusal is distinguished from a failed build. file:EvalSymlinks@1054,file:EvalSymlinks@1831,registry:Account@1400,registry:Account@2199,registry:App@1432,registry:App@2231,registry:Host@1101,registry:Host@1887,registry:Host@2774,registry:Host@3041,registry:Host@3684,registry:Host@510,registry:Unit@3566,registry:Unit@711,whole:Route@1408,whole:Route@1440,whole:Route@2207,whole:Route@2239,whole:Route@3574,whole:Route@638,whole:Route@719 b2abe0330fb6ce30c3f47283cf8ecb5501c60440654af7a05fa78abcce1bac4f
+internal/host/rootrestart.go productionRootRestartChecks authority B3 rev3=merge-ruling-4;basis=Production selects fallback from root census and never opens the record store for ordinary or derived-unit routes. file:Open@934,whole:Route@1568,whole:Route@1835,whole:Route@767 15e0904d572fedf1c6aa7925b981a4bfbc02e844a2b70913dd1e7069c52c3a38
+internal/host/rootrestart.go restartBoundUnit authority B3 rev3=merge-ruling-4;basis=PID-zero activating units reset and restart; inactive units stay stopped; binding-only errors cannot roll back a promoted fix without a census. file:EvalSymlinks@635,registry:Account@2355,registry:Account@3825,registry:App@2391,registry:App@3865,registry:Host@1618,registry:Host@2257,registry:Host@2851,registry:Host@3564,registry:Host@682,registry:Unit@1104,registry:Unit@1172,registry:Unit@1927,registry:Unit@1995,registry:Unit@2037,registry:Unit@3200,registry:Unit@395,whole:Route@198,whole:Route@2361,whole:Route@2397,whole:Route@317,whole:Route@3833,whole:Route@3873,whole:Route@4084 9bf816e686f934cc2284ebfdc4e5be8157fd1b11a71bdd3f00174c27571a4c77
+internal/host/rootrestart.go restartOrdinaryUnit authority B3 rev3=merge-ruling-4;basis=No-census derived-unit try-restart is unconditional and nonfatal, matching main for absent rows and systemd lookup failures. 93d374f29ba477a7698ab3360e64eca412667098a48a4078d9a33d53674bb23a
+internal/host/rootrestart.go restartVerifiedOrdinaryUnit authority B3 rev3=merge-ruling-4;basis=Retain the checked ordinary-unit path for strict census-backed operation. 1c43012a5dd6791d16dc96e79593a82f6b5e422460e11016e08f85dcf47307dc
+internal/host/rootrestart.go rollbackUnitRestart authority B3 rev3=merge-ruling-4;basis=Retained census keeps strict rollback; no-census missing binding falls back to main stamped start or D1 restart and ordinary rows start nothing. file:Read@1458,registry:Host@1750,registry:Unit@1852,registry:Unit@2153,registry:Unit@435,registry:Unit@692,whole:Route@1758,whole:Route@1860,whole:Route@2161,whole:Route@422,whole:Route@774 1121f62300b314b4eb9a7df92ecc65c8bc61f7387732a143760a84080ecd53ba
+internal/host/rootrestart.go rootRestartChecks.runningVersion authority B3 rev3=fix3-Astra-P2;basis=Before PointLive replaces previous, resolve the running build against checked root pointers; absent binding cannot block a no-census promotion. registry:Host@151,registry:Unit@104,registry:Unit@121,whole:Route@208 e5c1f372325482e101e6fac6da462578015c8d804dbe8dc73b2f92478ba944bb
+internal/host/rootrestart.go rootRestartRouteWith authority B3 rev3=merge-ruling-4;basis=No-census ordinary routes use main without store reads; stamped routes fall back on binding refusal and operator stops preserve the promoted link. registry:Unit@439,registry:Unit@456,whole:Route@1123,whole:Route@1320,whole:Route@426,whole:Route@581,whole:Route@709,whole:Route@832,whole:Route@993 110b8180846fff0a47e20963a86b95bcaec41884abbecce34cc6336e8fa6c105
+internal/host/rootrestart.go rootStartRoute authority B3 rev3=merge-ruling-4;basis=Use main systemctl start for legacy non-NotUp recovery while retaining checked rollback when authority binds. bb4a59f4359808db51f47fb7eff1b89eea4b4d6d927cbd5d517a4caccff56e5f
+internal/hostguard/flock_unix.go acquire authority C,J2,J4 rev3=merge-ruling-4;basis=Descriptor checks classify nonregular or foreign-owner locks as unsafe; honest contention waits with bounded exponential backoff. file:Fstat@1356,file:Fstat@398,file:Fstatat@665,file:Openat@1024,file:Openat@897 3f027811822f7c0d967227b3dea6df3c17d0630233559cc016c3dcacfd9db488
+internal/hostguard/guard.go Guard.locked authority C rev3=merge-ruling-4;basis=Only positively unsafe legacy locks durably quarantine; timeout and I/O errors return without changing root installation state. b4cd2e2344a4668cad288cc78b9092e7144505d15c36d5a56faf22e1edb66354
+internal/release/adopt.go WithRunningVersion authority B3 rev3=fix3-Astra-P2;basis=Root-only hook supplies verified current or previous running authority before replacing pointers. d5834f585168970bb17868fdc05cc8eadd9d8b461bb7d831e0327926e9ba23a3
+internal/release/adopt.go adoptHost authority B3,E1 rev3=fix3-Astra-P2;basis=Repoint retains a verified running previous build and uses it as rollback target when a crash left an intermediate live link. registry:App@10271,registry:App@10431,registry:App@11460,registry:App@11917,registry:App@13283,registry:App@17743,registry:App@23119,registry:App@30878,registry:App@3114,registry:App@6423,registry:App@7697,registry:App@7891,registry:Hibernate@31557,registry:Host@10151,registry:Host@10184,registry:Host@10284,registry:Host@10444,registry:Host@10546,registry:Host@10578,registry:Host@10643,registry:Host@11093,registry:Host@11226,registry:Host@11546,registry:Host@12007,registry:Host@12755,registry:Host@13056,registry:Host@13269,registry:Host@13585,registry:Host@17305,registry:Host@17413,registry:Host@17711,registry:Host@18507,registry:Host@18665,registry:Host@19127,registry:Host@19179,registry:Host@19299,registry:Host@19386,registry:Host@20492,registry:Host@20687,registry:Host@2075,registry:Host@21347,registry:Host@21732,registry:Host@21873,registry:Host@2264,registry:Host@23087,registry:Host@23214,registry:Host@2345,registry:Host@24556,registry:Host@25206,registry:Host@28439,registry:Host@29057,registry:Host@29382,registry:Host@29456,registry:Host@29703,registry:Host@29854,registry:Host@31035,registry:Host@31142,registry:Host@3127,registry:Host@31370,registry:Host@31513,registry:Host@32258,registry:Host@32384,registry:Host@32472,registry:Host@4248,registry:Host@5211,registry:Host@5316,registry:Host@6535,registry:Host@6583,registry:Host@6700,registry:Host@6732,registry:Host@7583,registry:Host@7751,registry:Host@7877,registry:Host@7996,registry:Host@8474,registry:Host@8861,registry:Host@9087,registry:Host@9183,registry:Kind@552,registry:Unit@29602,registry:Unit@577,whole:Route@10266,whole:Route@10426,whole:Route@11450,whole:Route@11907,whole:Route@13786,whole:Route@14878,whole:Route@15181,whole:Route@17634,whole:Route@17733,whole:Route@20221,whole:Route@2176,whole:Route@23109,whole:Route@28010,whole:Route@3109,whole:Route@3554,whole:Route@6418,whole:Route@9408 2096f8ff1a3fbfbf393dbf2dbb02a9c876349d9b5f12fc47346ace43c3c75d16
+internal/release/versions.go PointLive authority B3,E2 rev3=fix3-Astra-P2;basis=Optional retained running version must match current or previous; refuse dropping its last pointer, keeping existing sweep pins authoritative. file:Open@1407,file:Readlink@388,file:Readlink@923,file:Stat@147 15c007f95df3b1e29f085276459c5f5a28bc77034a374e30e9229c8efe2ea19f
+@coverage internal/host b68d60475aa8a0c501b9e9b3207a5cbd9b5997332ec37f16c0c57bf3470b8c46
+@coverage internal/hostguard b5146fee2ef6c7fac40f140ba687e85b632687eb04bf69fac107b9936551bbc0
+@coverage internal/release b88ee53f7cc078890a0bc6abba55e830542a63077bc1185dcc1f4b37c9c66f49
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index a543f803f..4a75141c5 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -37,6 +37,7 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
type PassOption func(*passConfig)
type passConfig struct {
+ runningVersion func(context.Context, registry.Route) (string, error)
propagateRestarts bool
restartAttemptSettled func(string) error
restartErrors *[]error
@@ -127,6 +128,12 @@ type passConfig struct {
tags map[string]map[string]string
}
+// WithRunningVersion retains root's verified running build through a repoint.
+// It must already be named by current or previous; PointLive checks that again.
+func WithRunningVersion(running func(context.Context, registry.Route) (string, error)) PassOption {
+ return func(c *passConfig) { c.runningVersion = running }
+}
+
// FailedBuild reports whether host may not be moved onto build: the
// zero-downtime failure book (rollout.FailureBook), taken as a func because
// rollout imports this package. served is build == the route's GoodVersion.
@@ -1519,7 +1526,23 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
converged = true
}
- if err := withReleaseWork(ctx, work, id, func() error { return PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App) }); err != nil {
+ if err := withReleaseWork(ctx, work, id, func() error {
+ running := ""
+ if cfg.runningVersion != nil {
+ var err error
+ running, err = cfg.runningVersion(ctx, rt)
+ if err != nil {
+ return err
+ }
+ }
+ if err := PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App, running); err != nil {
+ return err
+ }
+ if running != "" {
+ cur = running
+ }
+ return nil
+ }); err != nil {
// The symlink did not move, so the box is still serving cur — and the
// unit shape on disk is the new version's. Put the old one back, or a
// port the running binary never asked for stays publicly bound.
@@ -1767,8 +1790,12 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
if cfg.restartErrors != nil {
*cfg.restartErrors = append(*cfg.restartErrors, serr)
}
- fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host may be serving nothing\n",
- rt.Host, cur, serr)
+ if rt.Unit == "" && !isNotUp {
+ fmt.Printf(" %s: rolled back to %s but could not start it: %v\n", rt.Host, cur, serr)
+ } else {
+ fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host may be serving nothing\n",
+ rt.Host, cur, serr)
+ }
}
}
if isNotUp {
diff --git a/internal/release/plan14_fix3_previous_test.go b/internal/release/plan14_fix3_previous_test.go
new file mode 100644
index 000000000..09656d4b1
--- /dev/null
+++ b/internal/release/plan14_fix3_previous_test.go
@@ -0,0 +1,77 @@
+package release
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestPlan14Fix3RetainRunningPrevious(t *testing.T) {
+ for _, running := range []string{"v1", "v2", "v0", "../escape"} {
+ t.Run(running, func(t *testing.T) {
+ opt := t.TempDir()
+ host := "console.example"
+ for _, v := range []string{"v0", "v1", "v2", "v3"} {
+ if err := StageArtifact(opt, "bab", v, "console", []byte(v)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, v := range []string{"v1", "v2"} {
+ if err := PointLive(opt, host, "bab", v, "console"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := PointLive(opt, host, "bab", "v3", "console", running)
+ live, e := LiveVersion(opt, host)
+ if e != nil {
+ t.Fatal(e)
+ }
+ previous, e := os.Readlink(filepath.Join(opt, "previous", host))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if running == "v0" || running == "../escape" {
+ if err == nil || live != "v2" || previous != VersionPath(opt, "bab", "v1", "console") {
+ t.Fatal("unpointed build acquired authority", err, live, previous)
+ }
+ } else {
+ if err != nil || live != "v3" || previous != VersionPath(opt, "bab", running, "console") {
+ t.Fatal("running pointer lost", err, live, previous)
+ }
+ pins, err := LivePins(opt)
+ if err != nil || !pins[running] || !pins["v3"] {
+ t.Fatal(pins, err)
+ }
+ }
+ })
+ }
+}
+
+func TestPlan14Fix3OrdinaryFailureDoesNotClaimOutage(t *testing.T) {
+ for _, unit := range []string{"", "console.service"} {
+ t.Run(unit, func(t *testing.T) {
+ host := "console.example.com"
+ ctx, reg, store, pub, opt := startUnitFixture(t, host)
+ rt, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt.Unit = unit
+ if err := reg.Upsert(*rt); err != nil {
+ t.Fatal(err)
+ }
+ fail := func(string) error { return os.ErrPermission }
+ var output bytes.Buffer
+ withCapturedStdout(t, &output, func() {
+ if err := Pass(ctx, reg, store, pub, opt, fail, WithStartUnit(fail)); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if !strings.Contains(output.String(), "could not start it") || strings.Contains(output.String(), "may be serving nothing") != (unit != "") {
+ t.Fatal(output.String())
+ }
+ })
+ }
+}
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go
index 2a3fb4769..2f83a657a 100644
--- a/internal/release/proofgate.go
+++ b/internal/release/proofgate.go
@@ -85,6 +85,9 @@ func NewProofGate(policyPath, bakeDir string) *ProofGate {
// NewRootProofGate keeps diagnostic output in bakeDir. Health authority is
// always read from the root-owned history, including in the agent's gate.
+// Do not enable a D3 requirement until both current and previous builds have
+// root history. Service-owned history is not imported; enabling it earlier
+// can block an otherwise authorized promote-previous recovery without proof.
func NewRootProofGate(policyPath, bakeDir string) *ProofGate {
g := NewProofGate(policyPath, bakeDir)
g.healthyDir = RootHealthyDir
diff --git a/internal/release/versions.go b/internal/release/versions.go
index 91068977c..829f1739b 100644
--- a/internal/release/versions.go
+++ b/internal/release/versions.go
@@ -282,7 +282,7 @@ func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) {
// PointLive atomically repoints a host's live symlink at ver's build of app.
// It verifies ver is staged first, so a typo can't leave a dangling link
// that would fail the unit's ExecStart on the next restart.
-func PointLive(optDir, host, acct, ver, app string) error {
+func PointLive(optDir, host, acct, ver, app string, runningVersion ...string) error {
target := VersionPath(optDir, acct, ver, app)
if _, err := os.Stat(target); err != nil {
return fmt.Errorf("version %q of %s is not staged (%s)", ver, app, target)
@@ -293,6 +293,22 @@ func PointLive(optDir, host, acct, ver, app string) error {
link := LiveLink(optDir, host)
if old, err := os.Readlink(link); err == nil && old != target {
dir := filepath.Join(optDir, "previous")
+ // Root may have crashed after the last repoint but before restart.
+ // Keep the verified running build's pointer instead of replacing its
+ // last authority with an intermediate build that never ran.
+ if len(runningVersion) == 1 && runningVersion[0] != "" {
+ if !ValidVersion(runningVersion[0]) {
+ return os.ErrPermission
+ }
+ running := VersionPath(optDir, acct, runningVersion[0], app)
+ if running != old {
+ prior, err := os.Readlink(filepath.Join(dir, host))
+ if err != nil || prior != running {
+ return os.ErrPermission
+ }
+ old = running
+ }
+ }
if err := safefile.MkdirAll(dir, 0755); err != nil {
return err
}