hostclaim: Plan 15, durable admitted-generation provenance (inert behind CARLOS_HOST_CLAIM)
Content-addressed admission nodes in the deployment store with the head committed in the admission CAS; AdmittedOrigin and a batch ClaimOriginProof.Proven(set) walking the chain once (O(N), counting witness at N=K=1000); bounded 4096-byte node reads; seeded from retained admissions. Moves' chainOriginProof switches to the batch proof after this lands.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+1030
−4
internal/host/claimoriginproof.go+52 −0internal/host/plan15_originproof_test.go+67 −0internal/hostclaim/claim.go+6 −0internal/hostclaim/generation.go+3 −0internal/hostclaim/origins.go+389 −0internal/hostclaim/plan15_batch_test.go+141 −0internal/hostclaim/plan15_origins_test.go+292 −0internal/hostclaim/store.go+3 −0internal/hostops/lifecycle_grant.go+4 −1internal/hostops/mutation_classifier.go+6 −0internal/hostops/plan15_origins_test.go+62 −0internal/provision/plan9_b8_census_test.go+5 −3
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 321.455s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.920s
ok github.com/carlosframework/platform/cmd/moveprobe 93.185s
ok github.com/carlosframework/platform/internal/activator 228.747s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.280s
ok github.com/carlosframework/platform/internal/apperr 0.070s
ok github.com/carlosframework/platform/internal/applog 0.120s
ok github.com/carlosframework/platform/internal/awsinv 0.007s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.695s
? 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.011s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.188s
ok github.com/carlosframework/platform/internal/changelog 0.235s
ok github.com/carlosframework/platform/internal/clamd 0.057s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 358.234s
? 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 5.556s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
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.004s
ok github.com/carlosframework/platform/internal/domains 1.985s
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 25.942s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.009s
ok github.com/carlosframework/platform/internal/fleetapi 0.118s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.017s
ok github.com/carlosframework/platform/internal/geoip 0.032s
ok github.com/carlosframework/platform/internal/host 60.272s
ok github.com/carlosframework/platform/internal/hostactors 0.333s
ok github.com/carlosframework/platform/internal/hostbox 1.310s
ok github.com/carlosframework/platform/internal/hostclaim 0.288s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 0.310s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 4.156s
ok github.com/carlosframework/platform/internal/hostfloor 0.009s
ok github.com/carlosframework/platform/internal/hostguard 0.133s
ok github.com/carlosframework/platform/internal/hostops 1.358s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.005s
ok github.com/carlosframework/platform/internal/hostresolve 0.135s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.705s
ok github.com/carlosframework/platform/internal/instanceexec 2.681s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.341s
ok github.com/carlosframework/platform/internal/lease 9.426s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.162s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.496s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.070s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 20.905s
ok github.com/carlosframework/platform/internal/moves/archive 0.725s
ok github.com/carlosframework/platform/internal/objstore 0.167s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.194s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 3.174s
ok github.com/carlosframework/platform/internal/publicrelease 0.006s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.841s
ok github.com/carlosframework/platform/internal/relay 7.225s
ok github.com/carlosframework/platform/internal/release 18.971s
ok github.com/carlosframework/platform/internal/rollout 3.220s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
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.227s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.279s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.161s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.174s
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.003s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
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.001s
ok github.com/carlosframework/platform/internal/vet 1.547s
=== 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.022s
=== RUN TestT4StorageProbeMinio
2026/10/12 22:25:01 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDE88903F9FC93, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/host/claimoriginproof.go b/internal/host/claimoriginproof.go| new file mode 100644 |
| index 000000000..33e153eb3 |
| --- /dev/null |
| +++ b/internal/host/claimoriginproof.go |
| @@ -0,0 +1,52 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/regionname" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ClaimOriginProof implements moves' Proven contract. HomeRegion must come from |
| +// the verified roster/placement binding, and Claims is the deployment store. |
| +// Admission.NewGeneration is deliberately not evidence of admission. |
| +type ClaimOriginProof struct { |
| + Claims release.Store |
| + Cache *hostclaim.OriginProofCache |
| + HomeRegion, Region string |
| +} |
| + |
| +func (p ClaimOriginProof) Proven(ctx context.Context, o hostclaim.Observed, _ hostclaim.Admission, generations []string) (map[string]bool, error) { |
| + if o.Absent || o.Claim == nil || o.Claim.Holder == nil || p.Region == "" { |
| + return nil, hostclaim.ErrWrongState |
| + } |
| + h := o.Claim.Holder |
| + regions := []string{p.Region} |
| + if p.HomeRegion != "" && regionname.PlacementRegion(p.HomeRegion) == p.HomeRegion && regionname.SameRegion(p.HomeRegion, p.Region, regionname.HomeRegion) { |
| + if p.Region == regionname.HomeRegion { |
| + regions = append(regions, p.HomeRegion) |
| + } else { |
| + regions = append(regions, regionname.HomeRegion) |
| + } |
| + } |
| + set := make([]hostclaim.OriginTuple, 0, len(regions)*len(generations)) |
| + for _, generation := range generations { |
| + for _, region := range regions { |
| + set = append(set, hostclaim.OriginTuple{Incarnation: h.Incarnation, Location: h.Location, Region: region, Generation: generation}) |
| + } |
| + } |
| + cache := p.Cache |
| + if cache == nil { |
| + cache = new(hostclaim.OriginProofCache) |
| + } |
| + proven, err := cache.Proven(ctx, p.Claims, o, set) |
| + if err != nil { |
| + return nil, err |
| + } |
| + result := make(map[string]bool, len(generations)) |
| + for _, tuple := range set { |
| + result[tuple.Generation] = result[tuple.Generation] || proven[tuple] |
| + } |
| + return result, nil |
| +} |
diff --git a/internal/host/plan15_originproof_test.go b/internal/host/plan15_originproof_test.go| new file mode 100644 |
| index 000000000..62858930f |
| --- /dev/null |
| +++ b/internal/host/plan15_originproof_test.go |
| @@ -0,0 +1,67 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var _ interface { |
| + Proven(context.Context, hostclaim.Observed, hostclaim.Admission, []string) (map[string]bool, error) |
| +} = ClaimOriginProof{} |
| + |
| +// The moves session will adopt the batch signature after Plan 15 lands. |
| +// This test-only bridge checks compatibility with its current singleton API. |
| +type plan15SingleOriginAdapter struct{ ClaimOriginProof } |
| + |
| +var _ movesOriginProof = plan15SingleOriginAdapter{} |
| + |
| +func (p plan15SingleOriginAdapter) Proven(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, generation string) (bool, error) { |
| + proven, err := p.ClaimOriginProof.Proven(ctx, o, in, []string{generation}) |
| + return proven[generation], err |
| +} |
| + |
| +func TestPlan15MovesProof(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| + s := release.NewDirStore(t.TempDir()) |
| + host := "test.example" |
| + o, err := hostclaim.Read(t.Context(), s, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + n := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/app/instances/test.example.json", Account: "bab", App: "app", Host: host, Region: "ie", CreatedAt: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)} |
| + o, err = hostclaim.Update(t.Context(), s, o, hostclaim.BeginCreate(n, []byte(`{"host":"test.example","account":"bab","app":"app","created_at":"2026-10-12T00:00:00Z"}`), "create", "console", time.Now())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Update(t.Context(), s, o, hostclaim.CompleteCreate("create", "box")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = hostclaim.Update(t.Context(), s, o, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", Region: "ie", NewGeneration: "admitted"})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + home, region, generation string |
| + want bool |
| + }{ |
| + {"", "ie", "admitted", true}, {"ie", "@home", "admitted", true}, {"", "@home", "admitted", false}, |
| + {"ie", "uk", "admitted", false}, {"", "ie", "prepared", false}, |
| + } { |
| + p := ClaimOriginProof{Claims: s, HomeRegion: tc.home, Region: tc.region} |
| + got, err := p.Proven(t.Context(), o, hostclaim.Admission{NewGeneration: tc.generation}, []string{tc.generation}) |
| + if got[tc.generation] != tc.want || err != nil { |
| + t.Fatalf("%+v: %v %v", tc, got, err) |
| + } |
| + var adapter movesOriginProof = plan15SingleOriginAdapter{p} |
| + one, err := adapter.Proven(t.Context(), o, hostclaim.Admission{NewGeneration: tc.generation}, tc.generation) |
| + if one != tc.want || err != nil { |
| + t.Fatalf("singleton adapter %+v: %v %v", tc, one, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostclaim/claim.go b/internal/hostclaim/claim.go| index 86fc8b95e..56ff9abdf 100644 |
| --- a/internal/hostclaim/claim.go |
| +++ b/internal/hostclaim/claim.go |
| @@ -155,6 +155,9 @@ type CancelledCreate struct { |
| } |
| type Claim struct { |
| + Origins *OriginHead `json:"admitted_origins,omitempty"` |
| + originAdmission *originTuple |
| + |
| RetiredLocations map[string]string `json:"retired_locations,omitempty"` |
| EvidenceRefs []AutomaticLoss `json:"evidence_refs,omitempty"` |
| V int `json:"v"` |
| @@ -225,6 +228,9 @@ func isHex64(s string) bool { |
| } |
| func (c *Claim) Validate() error { |
| + if c != nil && !originHeadValid(c.Origins) { |
| + return ErrMalformed |
| + } |
| if c == nil || c.V != schemaVersion || !controlname.ValidHostShape(c.Host) || c.Gen == 0 { |
| return fmt.Errorf("%w: header", ErrMalformed) |
| } |
diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go| index 351462b6b..c58c4469a 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -163,6 +163,9 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| entry.WritesLost = &WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID} |
| } |
| } |
| + if err := pendingOrigin(c, entry); err != nil { |
| + return err |
| + } |
| h.Admissions = append(h.Admissions, entry) |
| for len(h.Admissions) > 8 && h.Admissions[0].Covered && admissionLossArchived(h.Admissions[0]) { |
| h.Admissions = h.Admissions[1:] |
diff --git a/internal/hostclaim/origins.go b/internal/hostclaim/origins.go| new file mode 100644 |
| index 000000000..500fe527c |
| --- /dev/null |
| +++ b/internal/hostclaim/origins.go |
| @@ -0,0 +1,389 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const originNodeLimit = 4096 |
| +const originCountLimit = 1 << 20 |
| + |
| +// OriginHead authenticates only nodes reachable from this claim's committed head. |
| +type OriginHead struct { |
| + Digest string `json:"digest"` |
| + Count uint64 `json:"count"` |
| +} |
| + |
| +type OriginTuple struct { |
| + Incarnation string `json:"incarnation"` |
| + Location string `json:"location"` |
| + Region string `json:"region"` |
| + Generation string `json:"generation"` |
| +} |
| + |
| +type originTuple = OriginTuple |
| + |
| +type originNode struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Count uint64 `json:"count"` |
| + Previous string `json:"previous,omitempty"` |
| + Origin originTuple `json:"origin"` |
| +} |
| + |
| +func originEnabled() bool { |
| + return os.Getenv("CARLOS_HOST_CLAIM") == "1" && os.Getenv("CARLOS_HOST_CLAIM_ORIGINS") == "1" |
| +} |
| + |
| +func originDigest(b []byte) string { |
| + h := sha256.New() |
| + h.Write([]byte("carlos-admitted-origin-v1\x00")) |
| + h.Write(b) |
| + return hex.EncodeToString(h.Sum(nil)) |
| +} |
| + |
| +func originKey(host, digest string) string { |
| + return "control/host-origins/" + host + "/" + digest + ".json" |
| +} |
| + |
| +func originHeadValid(h *OriginHead) bool { |
| + return h == nil || h.Count > 0 && h.Count <= originCountLimit && isHex64(h.Digest) && h.Digest == strings.ToLower(h.Digest) |
| +} |
| + |
| +func (o originTuple) valid(host string) bool { |
| + if !isHex64(o.Incarnation) || o.Incarnation != strings.ToLower(o.Incarnation) || |
| + o.Region == "" || len(o.Region) > 64 || strings.ContainsAny(o.Region, "/\\\x00") || |
| + o.Generation == "" || len(o.Generation) > 256 || o.Generation == RootGeneration || |
| + o.Generation == "." || o.Generation == ".." || strings.ContainsAny(o.Generation, "/\\\x00") { |
| + return false |
| + } |
| + return o.Location == "instances/"+host || o.Location == "instances/"+host+"/"+o.Incarnation |
| +} |
| + |
| +// ValidateOriginObject classifies inert, content-addressed candidates. Creating a |
| +// candidate grants nothing; only the claim CAS can make one reachable. |
| +func ValidateOriginObject(key string, body []byte) error { |
| + if len(body) == 0 || len(body) > originNodeLimit { |
| + return ErrMalformed |
| + } |
| + d := json.NewDecoder(bytes.NewReader(body)) |
| + d.DisallowUnknownFields() |
| + var n originNode |
| + if err := d.Decode(&n); err != nil { |
| + return ErrMalformed |
| + } |
| + if _, err := d.Token(); err != io.EOF { |
| + return ErrMalformed |
| + } |
| + if _, err := Key(n.Host); err != nil { |
| + return ErrMalformed |
| + } |
| + if n.V != 1 || n.Count == 0 || n.Count > originCountLimit || !n.Origin.valid(n.Host) || |
| + (n.Count == 1) != (n.Previous == "") || |
| + n.Previous != "" && (!isHex64(n.Previous) || n.Previous != strings.ToLower(n.Previous)) || |
| + key != originKey(n.Host, originDigest(body)) { |
| + return ErrMalformed |
| + } |
| + return nil |
| +} |
| + |
| +func readOrigin(ctx context.Context, s release.Store, host string, h OriginHead) (originNode, error) { |
| + var n originNode |
| + if err := ctx.Err(); err != nil { |
| + return n, err |
| + } |
| + key := originKey(host, h.Digest) |
| + b, err := readOriginBytes(ctx, s, key) |
| + if err != nil { |
| + return n, err |
| + } |
| + if err = ValidateOriginObject(key, b); err != nil { |
| + return n, err |
| + } |
| + if err = json.Unmarshal(b, &n); err != nil { |
| + return n, err |
| + } |
| + if n.Host != host || n.Count != h.Count { |
| + return n, ErrMalformed |
| + } |
| + return n, nil |
| +} |
| + |
| +func pendingOrigin(c *Claim, entry AdmissionRecord) error { |
| + if !originEnabled() { |
| + // Once written, provenance cannot silently stop collecting admissions. |
| + if c.Origins != nil { |
| + return ErrWrongState |
| + } |
| + return nil |
| + } |
| + if c.originAdmission != nil || c.Holder == nil { |
| + return ErrWrongState |
| + } |
| + o := originTuple{c.Holder.Incarnation, c.Holder.Location, entry.Region, entry.Generation} |
| + // The empty legacy root has no files/g-<generation>/ origin. |
| + if entry.Generation == "" { |
| + return nil |
| + } |
| + if !o.valid(c.Host) { |
| + return ErrWrongState |
| + } |
| + c.originAdmission = &o |
| + return nil |
| +} |
| + |
| +// commitOrigins stages immutable candidates before Update's existing claim CAS. |
| +// A failed or uncertain CAS leaves candidates without granting them authority. |
| +func commitOrigins(ctx context.Context, s release.Store, obs Observed, next *Claim) error { |
| + var old *OriginHead |
| + if obs.Claim != nil { |
| + old = obs.Claim.Origins |
| + } |
| + if !sameOriginHead(old, next.Origins) { |
| + return ErrWrongState |
| + } |
| + if next.originAdmission == nil { |
| + return nil |
| + } |
| + if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || next.Holder == nil { |
| + return ErrWrongState |
| + } |
| + h := next.Holder |
| + want := *next.originAdmission |
| + if want.Incarnation != h.Incarnation || want.Location != h.Location || want.Generation != h.Generation || |
| + h.Session != next.Gen || len(h.Admissions) == 0 { |
| + return ErrWrongState |
| + } |
| + a := h.Admissions[len(h.Admissions)-1] |
| + if a.Session != h.Session || a.Generation != want.Generation || a.Region != want.Region { |
| + return ErrWrongState |
| + } |
| + // Bind migration to the stored observation, never a caller-edited admission list. |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil { |
| + return err |
| + } |
| + before, _ := json.Marshal(obs.Claim) |
| + stored, _ := json.Marshal(fresh.Claim) |
| + if fresh.Absent || fresh.ETag != obs.ETag || !bytes.Equal(before, stored) { |
| + return ErrConflict |
| + } |
| + var origins []originTuple |
| + if old == nil { |
| + for _, a := range obs.Claim.Holder.Admissions { |
| + if a.Generation == "" { |
| + continue |
| + } |
| + origins = append(origins, originTuple{obs.Claim.Holder.Incarnation, obs.Claim.Holder.Location, a.Region, a.Generation}) |
| + } |
| + } |
| + origins = append(origins, want) |
| + head := OriginHead{} |
| + if old != nil { |
| + head = *old |
| + } |
| + for _, o := range origins { |
| + if !o.valid(obs.Host) { |
| + return ErrWrongState |
| + } |
| + if head.Count != 0 { |
| + last, err := readOrigin(ctx, s, obs.Host, head) |
| + if err != nil { |
| + return err |
| + } |
| + if last.Origin == o { |
| + continue |
| + } |
| + } |
| + if head.Count == originCountLimit { |
| + return ErrWrongState |
| + } |
| + node := originNode{V: 1, Host: obs.Host, Count: head.Count + 1, Previous: head.Digest, Origin: o} |
| + body, err := json.Marshal(node) |
| + if err != nil { |
| + return err |
| + } |
| + key := originKey(obs.Host, originDigest(body)) |
| + if err = ValidateOriginObject(key, body); err != nil { |
| + return err |
| + } |
| + if _, err = s.Put(ctx, key, body, ""); errors.Is(err, release.ErrCASConflict) { |
| + existing, readErr := readOriginBytes(ctx, s, key) |
| + if readErr != nil { |
| + return readErr |
| + } |
| + if !bytes.Equal(existing, body) { |
| + return ErrMalformed |
| + } |
| + } else if err != nil { |
| + return err |
| + } |
| + head = OriginHead{Digest: originDigest(body), Count: node.Count} |
| + } |
| + next.Origins = &head |
| + next.originAdmission = nil |
| + return nil |
| +} |
| + |
| +func sameOriginHead(a, b *OriginHead) bool { |
| + if a == nil || b == nil { |
| + return a == nil && b == nil |
| + } |
| + return *a == *b |
| +} |
| + |
| +// OriginProofCache retains one exact batch, after authenticating its observation. |
| +// Stores with noncomparable identities are never cached. Zero value is usable. |
| +type OriginProofCache struct { |
| + mu sync.Mutex |
| + store release.Store |
| + key string |
| + result map[OriginTuple]bool |
| +} |
| + |
| +func readOriginBytes(ctx context.Context, s release.Store, key string) ([]byte, error) { |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + // OpenObject's Get fallback would allocate an unbounded object. |
| + opener, ok := s.(release.Opener) |
| + if !ok { |
| + return nil, ErrMalformed |
| + } |
| + reader, _, err := opener.Open(ctx, key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + b, readErr := io.ReadAll(io.LimitReader(reader, originNodeLimit+1)) |
| + closeErr := reader.Close() |
| + if readErr != nil { |
| + return nil, readErr |
| + } |
| + if len(b) > originNodeLimit { |
| + return nil, ErrMalformed |
| + } |
| + if closeErr != nil { |
| + return nil, closeErr |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + return b, nil |
| +} |
| + |
| +// AdmittedOrigins authenticates once and proves the complete requested set in |
| +// one walk. It publishes no partial results on a malformed or unreadable chain. |
| +func AdmittedOrigins(ctx context.Context, s release.Store, obs Observed, set []OriginTuple) (map[OriginTuple]bool, error) { |
| + return new(OriginProofCache).Proven(ctx, s, obs, set) |
| +} |
| + |
| +func (cache *OriginProofCache) Proven(ctx context.Context, s release.Store, obs Observed, set []OriginTuple) (map[OriginTuple]bool, error) { |
| + cache.mu.Lock() |
| + defer cache.mu.Unlock() |
| + invalidate := func() { cache.store, cache.key, cache.result = nil, "", nil } |
| + if err := ctx.Err(); err != nil { |
| + invalidate() |
| + return nil, err |
| + } |
| + if s == nil || obs.Absent || obs.Claim == nil || obs.ETag == "" || obs.Host != obs.Claim.Host { |
| + invalidate() |
| + return nil, ErrMalformed |
| + } |
| + wanted := make(map[OriginTuple]bool, len(set)) |
| + for _, tuple := range set { |
| + if !tuple.valid(obs.Host) { |
| + invalidate() |
| + return nil, ErrMalformed |
| + } |
| + wanted[tuple] = false |
| + } |
| + fresh, err := Read(ctx, s, obs.Host) |
| + if err != nil { |
| + invalidate() |
| + return nil, err |
| + } |
| + before, marshalErr := json.Marshal(obs.Claim) |
| + stored, storedErr := json.Marshal(fresh.Claim) |
| + if fresh.Absent || fresh.ETag != obs.ETag || marshalErr != nil || storedErr != nil || !bytes.Equal(before, stored) { |
| + invalidate() |
| + return nil, ErrConflict |
| + } |
| + if err := ctx.Err(); err != nil { |
| + invalidate() |
| + return nil, err |
| + } |
| + // The store identity, host, head digest/count and exact canonical tuple set |
| + // are all in the cache key. ETag and the full body also bind legacy admissions. |
| + tuples := make([]string, 0, len(wanted)) |
| + for tuple := range wanted { |
| + b, _ := json.Marshal(tuple) |
| + tuples = append(tuples, string(b)) |
| + } |
| + sort.Strings(tuples) |
| + keyBytes, _ := json.Marshal(struct { |
| + Host, ETag, Claim string |
| + Set []string |
| + }{obs.Host, obs.ETag, string(stored), tuples}) |
| + key := string(keyBytes) |
| + comparable := reflect.TypeOf(s).Comparable() |
| + if comparable && cache.store == s && cache.key == key { |
| + for tuple := range wanted { |
| + wanted[tuple] = cache.result[tuple] |
| + } |
| + return wanted, nil |
| + } |
| + invalidate() |
| + c := fresh.Claim |
| + if c.Origins == nil { |
| + if h := c.Holder; h != nil { |
| + for _, a := range h.Admissions { |
| + tuple := OriginTuple{h.Incarnation, h.Location, a.Region, a.Generation} |
| + if _, ok := wanted[tuple]; ok { |
| + wanted[tuple] = true |
| + } |
| + } |
| + } |
| + } else { |
| + for head := *c.Origins; head.Count > 0; { |
| + node, err := readOrigin(ctx, s, obs.Host, head) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if _, ok := wanted[node.Origin]; ok { |
| + wanted[node.Origin] = true |
| + } |
| + head = OriginHead{Digest: node.Previous, Count: head.Count - 1} |
| + } |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return nil, err |
| + } |
| + if comparable { |
| + cache.store, cache.key = s, key |
| + cache.result = make(map[OriginTuple]bool, len(wanted)) |
| + for tuple, proven := range wanted { |
| + cache.result[tuple] = proven |
| + } |
| + } |
| + return wanted, nil |
| +} |
| + |
| +// AdmittedOrigin is the singleton convenience form; multi-origin callers use |
| +// AdmittedOrigins or one OriginProofCache.Proven call for their entire set. |
| +func AdmittedOrigin(ctx context.Context, s release.Store, obs Observed, incarnation, location, region, generation string) (bool, error) { |
| + tuple := OriginTuple{incarnation, location, region, generation} |
| + proven, err := AdmittedOrigins(ctx, s, obs, []OriginTuple{tuple}) |
| + return proven[tuple], err |
| +} |
diff --git a/internal/hostclaim/plan15_batch_test.go b/internal/hostclaim/plan15_batch_test.go| new file mode 100644 |
| index 000000000..4d8e5656a |
| --- /dev/null |
| +++ b/internal/hostclaim/plan15_batch_test.go |
| @@ -0,0 +1,141 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type plan15BoundedStore struct { |
| + *release.DirStore |
| + oversized, conflict bool |
| + read, closed, opens, gets int |
| +} |
| + |
| +func (s *plan15BoundedStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasPrefix(key, "control/host-origins/") { |
| + panic("unbounded node Get") |
| + } |
| + s.gets++ |
| + return s.DirStore.Get(ctx, key) |
| +} |
| +func (s *plan15BoundedStore) Put(ctx context.Context, key string, b []byte, tag string) (string, error) { |
| + if s.conflict && strings.HasPrefix(key, "control/host-origins/") { |
| + return "", release.ErrCASConflict |
| + } |
| + return s.DirStore.Put(ctx, key, b, tag) |
| +} |
| + |
| +type plan15Reader struct { |
| + io.Reader |
| + store *plan15BoundedStore |
| +} |
| + |
| +func (r plan15Reader) Read(b []byte) (int, error) { |
| + n, e := r.Reader.Read(b) |
| + r.store.read += n |
| + return n, e |
| +} |
| +func (r plan15Reader) Close() error { r.store.closed++; return nil } |
| +func (s *plan15BoundedStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + s.opens++ |
| + if s.oversized { |
| + return plan15Reader{strings.NewReader(strings.Repeat("x", 1<<20)), s}, "oversized", nil |
| + } |
| + return s.DirStore.Open(ctx, key) |
| +} |
| +func TestPlan15BoundedReachableAndConflicting(t *testing.T) { |
| + for _, conflict := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "reachable", true: "candidate-conflict"}[conflict], func(t *testing.T) { |
| + plan15Enable(t) |
| + s := &plan15BoundedStore{DirStore: release.NewDirStore(t.TempDir())} |
| + o := servingClaim(t, t.Context(), s) |
| + if !conflict { |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "real", "ie")) |
| + } |
| + s.oversized, s.conflict = true, conflict |
| + s.opens, s.closed, s.read = 0, 0, 0 |
| + var err error |
| + if conflict { |
| + _, err = Update(t.Context(), s, o, plan15Admit(o, "candidate", "ie")) |
| + } else { |
| + h := o.Claim.Holder |
| + _, err = AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, "ie", "real") |
| + } |
| + if !errors.Is(err, ErrMalformed) || s.read != 4097 || s.closed != 1 || s.opens != 1 { |
| + t.Fatalf("err=%v bytes=%d closes=%d opens=%d", err, s.read, s.closed, s.opens) |
| + } |
| + }) |
| + } |
| +} |
| +func TestPlan15CacheBindings(t *testing.T) { |
| + plan15Enable(t) |
| + s := &plan15BoundedStore{DirStore: release.NewDirStore(t.TempDir())} |
| + o := servingClaim(t, t.Context(), s) |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| + h := o.Claim.Holder |
| + first := OriginTuple{h.Incarnation, h.Location, "ie", "first"} |
| + cache := new(OriginProofCache) |
| + prove := func(obs Observed, set []OriginTuple, want bool) { |
| + t.Helper() |
| + result, err := cache.Proven(t.Context(), s, obs, set) |
| + if err != nil || result[set[0]] != want { |
| + t.Fatalf("%v %v", result, err) |
| + } |
| + } |
| + s.opens, s.gets = 0, 0 |
| + prove(o, []OriginTuple{first, first}, true) |
| + prove(o, []OriginTuple{first}, true) |
| + if s.opens != int(o.Claim.Origins.Count) || s.gets != 2 { |
| + t.Fatalf("cache opens=%d reads=%d", s.opens, s.gets) |
| + } |
| + different := first |
| + different.Region = "uk" |
| + prove(o, []OriginTuple{different}, false) |
| + if s.opens != 2*int(o.Claim.Origins.Count) { |
| + t.Fatal("tuple set reused") |
| + } |
| + other := &plan15BoundedStore{DirStore: release.NewDirStore(t.TempDir())} |
| + key, _ := Key(o.Host) |
| + body, _, err := s.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := other.Put(t.Context(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := cache.Proven(t.Context(), other, o, []OriginTuple{different}); got != nil || err == nil { |
| + t.Fatal("foreign store reused") |
| + } |
| + prove(o, []OriginTuple{first}, true) |
| + edited := o |
| + c := *o.Claim |
| + edited.Claim = &c |
| + c.Origins = &OriginHead{Digest: strings.Repeat("f", 64), Count: o.Claim.Origins.Count} |
| + if got, err := cache.Proven(t.Context(), s, edited, []OriginTuple{first}); got != nil || !errors.Is(err, ErrConflict) { |
| + t.Fatal("edited head reused") |
| + } |
| + prove(o, []OriginTuple{first}, true) |
| + newer := updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "second", "ie")) |
| + if got, err := cache.Proven(t.Context(), s, o, []OriginTuple{first}); got != nil || !errors.Is(err, ErrConflict) { |
| + t.Fatal("stale observation reused") |
| + } |
| + s.opens = 0 |
| + prove(newer, []OriginTuple{first}, true) |
| + if s.opens != int(newer.Claim.Origins.Count) { |
| + t.Fatal("changed head did not traverse") |
| + } |
| + // No partial positives escape a failed batch, including a corrupt tail. |
| + if err := s.Delete(t.Context(), originKey(o.Host, o.Claim.Origins.Digest)); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := first |
| + second.Generation = "second" |
| + if got, err := cache.Proven(t.Context(), s, newer, []OriginTuple{first, second}); got != nil || err == nil { |
| + t.Fatal("partial proof escaped") |
| + } |
| +} |
diff --git a/internal/hostclaim/plan15_origins_test.go b/internal/hostclaim/plan15_origins_test.go| new file mode 100644 |
| index 000000000..5f2595a66 |
| --- /dev/null |
| +++ b/internal/hostclaim/plan15_origins_test.go |
| @@ -0,0 +1,292 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func plan15Enable(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| +} |
| +func plan15Admit(o Observed, generation, region string) func(*Claim) error { |
| + h := o.Claim.Holder |
| + return AdmitWriter(Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, |
| + ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: o.Claim.Op.Exec, |
| + Mode: "cold", NewGeneration: generation, Region: region, PredecessorRegion: region, PredecessorCertified: true}) |
| +} |
| +func plan15Proof(t *testing.T, s release.Store, o Observed, generation, region string, want bool) { |
| + t.Helper() |
| + h := o.Claim.Holder |
| + got, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, region, generation) |
| + if err != nil || got != want { |
| + t.Fatalf("proof %s/%s=%v,%v want %v", region, generation, got, err, want) |
| + } |
| +} |
| +func TestPlan15PruningMigrationAndScope(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), s) |
| + // A legacy claim can have more admissions than the covered retention window. |
| + for i := 0; i < 12; i++ { |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("old%d", i), "ie")) |
| + o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| + for i := range c.Holder.Admissions { |
| + c.Holder.Admissions[i].Covered = true |
| + } |
| + return nil |
| + }) |
| + } |
| + if len(o.Claim.Holder.Admissions) != 8 { |
| + t.Fatal("fixture did not prune") |
| + } |
| + plan15Enable(t) |
| + plan15Proof(t, s, o, "old0", "ie", false) |
| + plan15Proof(t, s, o, "old4", "ie", true) |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| + for i := 0; i < 24; i++ { |
| + o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| + for i := range c.Holder.Admissions { |
| + c.Holder.Admissions[i].Covered = true |
| + } |
| + return nil |
| + }) |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("new%d", i), "ie")) |
| + } |
| + for _, g := range []string{"first", "old4", "old11", "new0", "new23"} { |
| + plan15Proof(t, s, o, g, "ie", true) |
| + } |
| + plan15Proof(t, s, o, "old0", "ie", false) |
| + plan15Proof(t, s, o, "never", "ie", false) |
| + plan15Proof(t, s, o, "first", "uk", false) |
| + h := o.Claim.Holder |
| + for _, q := range []struct{ inc, loc string }{{strings.Repeat("b", 64), "instances/" + o.Host}, {h.Incarnation, "instances/" + o.Host}} { |
| + got, err := AdmittedOrigin(t.Context(), s, o, q.inc, q.loc, "ie", "first") |
| + if got || err != nil { |
| + t.Fatalf("wrong scope %v %v", got, err) |
| + } |
| + } |
| + // The old incarnation's history survives holder removal and the retirement cap. |
| + old := *h |
| + o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| + c.Holder = nil |
| + c.Op = Op{} |
| + c.Obligations = nil |
| + c.RetiredIncarnations = nil |
| + return nil |
| + }) |
| + got, err := AdmittedOrigin(t.Context(), s, o, old.Incarnation, old.Location, "ie", "first") |
| + if !got || err != nil { |
| + t.Fatalf("retired origin %v %v", got, err) |
| + } |
| +} |
| + |
| +type plan15Store struct { |
| + release.Store |
| + failNode, failClaim, conflict, uncertain bool |
| + puts int |
| +} |
| + |
| +func (s *plan15Store) Put(ctx context.Context, key string, b []byte, tag string) (string, error) { |
| + s.puts++ |
| + if strings.HasPrefix(key, "control/host-origins/") && s.failNode { |
| + return "", os.ErrPermission |
| + } |
| + if strings.HasPrefix(key, "control/hosts/") { |
| + if s.failClaim { |
| + return "", os.ErrPermission |
| + } |
| + if s.conflict { |
| + return "", release.ErrCASConflict |
| + } |
| + if s.uncertain { |
| + _, err := s.Store.Put(ctx, key, b, tag) |
| + if err != nil { |
| + return "", err |
| + } |
| + return "", os.ErrDeadlineExceeded |
| + } |
| + } |
| + return s.Store.Put(ctx, key, b, tag) |
| +} |
| +func TestPlan15AtomicityAndRetry(t *testing.T) { |
| + for _, fault := range []string{"node", "claim", "conflict", "uncertain"} { |
| + t.Run(fault, func(t *testing.T) { |
| + plan15Enable(t) |
| + raw := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), raw) |
| + s := &plan15Store{Store: raw, failNode: fault == "node", failClaim: fault == "claim", conflict: fault == "conflict", uncertain: fault == "uncertain"} |
| + if _, err := Update(t.Context(), s, o, plan15Admit(o, "candidate", "ie")); err == nil { |
| + t.Fatal("fault ignored") |
| + } |
| + fresh, err := Read(t.Context(), raw, o.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + committed := fault == "uncertain" |
| + if (fresh.Claim.Holder.Generation == "candidate") != committed || (fresh.Claim.Origins != nil) != committed { |
| + t.Fatal("split commit") |
| + } |
| + plan15Proof(t, raw, fresh, "candidate", "ie", committed) |
| + if !committed { |
| + fresh = updateGenerationTest(t, t.Context(), raw, fresh, plan15Admit(fresh, "candidate", "ie")) |
| + plan15Proof(t, raw, fresh, "candidate", "ie", true) |
| + } |
| + if _, err := Update(t.Context(), raw, o, plan15Admit(o, "stale", "ie")); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale: %v", err) |
| + } |
| + plan15Proof(t, raw, fresh, "stale", "ie", false) |
| + }) |
| + } |
| +} |
| +func TestPlan15NoSelfAuthentication(t *testing.T) { |
| + plan15Enable(t) |
| + raw := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), raw) |
| + o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "real", "ie")) |
| + h := o.Claim.Holder |
| + fake := originNode{V: 1, Host: o.Host, Count: o.Claim.Origins.Count + 1, Previous: o.Claim.Origins.Digest, Origin: originTuple{h.Incarnation, h.Location, "ie", "forged"}} |
| + body, _ := json.Marshal(fake) |
| + digest := originDigest(body) |
| + if _, err := raw.Put(t.Context(), originKey(o.Host, digest), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + plan15Proof(t, raw, o, "forged", "ie", false) |
| + // Even a caller who knows the ETag cannot substitute a new head in the reader. |
| + tampered := o |
| + clone := *o.Claim |
| + tampered.Claim = &clone |
| + clone.Origins = &OriginHead{digest, fake.Count} |
| + if ok, err := AdmittedOrigin(t.Context(), raw, tampered, h.Incarnation, h.Location, "ie", "forged"); ok || !errors.Is(err, ErrConflict) { |
| + t.Fatalf("edited observation: %v %v", ok, err) |
| + } |
| + for _, change := range []func(*Claim){func(c *Claim) { c.Origins = &OriginHead{digest, fake.Count} }, func(c *Claim) { c.Origins = nil }} { |
| + if _, err := Update(t.Context(), raw, o, func(c *Claim) error { change(c); return nil }); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("head bypass: %v", err) |
| + } |
| + } |
| + // A caller-edited migration seed is refused against the stored observation. |
| + fresh := servingClaim(t, t.Context(), release.NewDirStore(t.TempDir())) |
| + fresh.Claim.Holder.Admissions = append(fresh.Claim.Holder.Admissions, AdmissionRecord{Region: "ie", Generation: "invented", Mode: "cold"}) |
| + if _, err := Update(t.Context(), raw, fresh, plan15Admit(fresh, "new", "ie")); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("forged migration: %v", err) |
| + } |
| + // Missing and corrupt reachable nodes are errors, never an affirmative answer. |
| + key := originKey(o.Host, o.Claim.Origins.Digest) |
| + b, tag, err := raw.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = raw.Put(t.Context(), key, append(b, ' '), tag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| + t.Fatal("corruption accepted") |
| + } |
| + if err = raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| + t.Fatal("missing node accepted") |
| + } |
| +} |
| +func TestPlan15FlagOffAndWarm(t *testing.T) { |
| + for _, flags := range [][2]string{{"", ""}, {"", "1"}, {"1", ""}} { |
| + t.Run(fmt.Sprint(flags), func(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", flags[0]) |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", flags[1]) |
| + raw := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), raw) |
| + s := &plan15Store{Store: raw} |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "legacy", "ie")) |
| + if o.Claim.Origins != nil || s.puts != 1 { |
| + t.Fatal("flag-off wrote provenance") |
| + } |
| + b, _ := json.Marshal(o.Claim) |
| + if strings.Contains(string(b), "admitted_origins") { |
| + t.Fatal("flag-off wire changed") |
| + } |
| + }) |
| + } |
| + plan15Enable(t) |
| + raw := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), raw) |
| + o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "same", "ie")) |
| + before := *o.Claim.Origins |
| + o = updateGenerationTest(t, t.Context(), raw, o, AdmitWriter(Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "same", Box: o.Claim.Op.Exec, Mode: "warm", Region: "ie", PredecessorCertified: true})) |
| + if !reflect.DeepEqual(before, *o.Claim.Origins) { |
| + t.Fatal("warm duplicated origin") |
| + } |
| + plan15Proof(t, raw, o, "same", "ie", true) |
| + t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| + if _, err := Update(t.Context(), raw, o, plan15Admit(o, "gap", "ie")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("disabled writer made gap: %v", err) |
| + } |
| +} |
| + |
| +func TestPlan15LimitsAndMalformedHistory(t *testing.T) { |
| + plan15Enable(t) |
| + s := release.NewDirStore(t.TempDir()) |
| + o := servingClaim(t, t.Context(), s) |
| + for _, gen := range []string{"../escape", RootGeneration, strings.Repeat("x", 257)} { |
| + if _, err := Update(t.Context(), s, o, plan15Admit(o, gen, "ie")); err == nil { |
| + t.Fatalf("bad generation %q", gen) |
| + } |
| + } |
| + o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| + h := o.Claim.Holder |
| + // A synthetic near-limit history tests refusal without writing a million nodes. |
| + node := originNode{V: 1, Host: o.Host, Count: originCountLimit, Previous: strings.Repeat("a", 64), Origin: originTuple{h.Incarnation, h.Location, "ie", "first"}} |
| + b, _ := json.Marshal(node) |
| + head := OriginHead{originDigest(b), originCountLimit} |
| + if _, err := s.Put(t.Context(), originKey(o.Host, head.Digest), b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + o.Claim.Origins = &head |
| + key, _ := Key(o.Host) |
| + b, _ = json.Marshal(o.Claim) |
| + tag, err := s.Put(t.Context(), key, b, o.ETag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o.ETag = tag |
| + if _, err := Update(t.Context(), s, o, plan15Admit(o, "over-limit", "ie")); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("limit: %v", err) |
| + } |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + if ok, err := AdmittedOrigin(ctx, s, o, h.Incarnation, h.Location, "ie", "absent"); ok || err == nil { |
| + t.Fatal("ignored cancellation") |
| + } |
| + node.Count-- |
| + b, _ = json.Marshal(node) |
| + // Same digest-shaped head with a mismatched count cannot prove membership. |
| + if _, err := s.Put(t.Context(), originKey(o.Host, originDigest(b)), b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + o.Claim.Origins = &OriginHead{originDigest(b), originCountLimit} |
| + b, _ = json.Marshal(o.Claim) |
| + o.ETag, err = s.Put(t.Context(), key, b, o.ETag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, "ie", "first"); ok || err == nil { |
| + t.Fatal("count mismatch accepted") |
| + } |
| + for _, head := range []*OriginHead{{"bad", 1}, {strings.Repeat("a", 64), 0}, {strings.Repeat("a", 64), originCountLimit + 1}} { |
| + o.Claim.Origins = head |
| + if err := o.Claim.Validate(); err == nil { |
| + t.Fatal("bad head parsed") |
| + } |
| + } |
| +} |
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go| index b421bab85..7ede91e4d 100644 |
| --- a/internal/hostclaim/store.go |
| +++ b/internal/hostclaim/store.go |
| @@ -98,6 +98,9 @@ func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) |
| if err := next.Validate(); err != nil { |
| return Observed{}, err |
| } |
| + if err := commitOrigins(ctx, s, obs, next); err != nil { |
| + return Observed{}, err |
| + } |
| body, err := json.Marshal(next) |
| if err != nil { |
| return Observed{}, err |
diff --git a/internal/hostops/lifecycle_grant.go b/internal/hostops/lifecycle_grant.go| index fb94ded30..ed55d85e5 100644 |
| --- a/internal/hostops/lifecycle_grant.go |
| +++ b/internal/hostops/lifecycle_grant.go |
| @@ -9,6 +9,7 @@ import ( |
| "io" |
| "math" |
| "os" |
| + "reflect" |
| "strings" |
| "time" |
| @@ -169,7 +170,9 @@ func authorizeClaimWrite(ctx context.Context, raw release.Store, host string, t |
| if err != nil || next == nil { |
| return ErrIdentity |
| } |
| - if !claimTransitionShape(host, old, next) { |
| + if !claimTransitionShape(host, old, next) || |
| + (old == nil && next.Origins != nil) || |
| + (old != nil && !reflect.DeepEqual(old.Origins, next.Origins)) { |
| return ErrIdentity |
| } |
| for _, g := range grants { |
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index 69ae1ce2a..a31f18b5d 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -13,6 +13,7 @@ import ( |
| "strings" |
| "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -70,6 +71,11 @@ var nonAppFamilies = []nonAppFamily{ |
| // are never deleted (C3). |
| func lifecycleFamily(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (string, bool, error) { |
| switch { |
| + case strings.HasPrefix(t.Key, "control/host-origins/"): |
| + if t.Method != "Put" || t.ETag != "" || len(t.Before) != 0 || hostclaim.ValidateOriginObject(t.Key, t.After) != nil { |
| + return "", true, ErrIdentity |
| + } |
| + return "lifecycle-origin-candidate", true, nil |
| case strings.HasPrefix(t.Key, "control/hosts/") && strings.HasSuffix(t.Key, ".json") && strings.Count(t.Key, "/") == 2: |
| host := strings.TrimSuffix(strings.TrimPrefix(t.Key, "control/hosts/"), ".json") |
| if t.Method != "Put" || !controlname.ValidHostShape(host) { |
diff --git a/internal/hostops/plan15_origins_test.go b/internal/hostops/plan15_origins_test.go| new file mode 100644 |
| index 000000000..8a58456c1 |
| --- /dev/null |
| +++ b/internal/hostops/plan15_origins_test.go |
| @@ -0,0 +1,62 @@ |
| +package hostops |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func TestPlan15OriginCandidateBoundary(t *testing.T) { |
| + w, raw, _ := testWriter(t) |
| + body := []byte(`{"v":1,"host":"x.example","count":1,"origin":{"incarnation":"` + strings.Repeat("a", 64) + `","location":"instances/x.example","region":"ie","generation":"g1"}}`) |
| + sum := sha256.Sum256(append([]byte("carlos-admitted-origin-v1\x00"), body...)) |
| + key := "control/host-origins/x.example/" + hex.EncodeToString(sum[:]) + ".json" |
| + for _, tc := range []struct { |
| + name string |
| + target MutationTarget |
| + ok bool |
| + }{ |
| + {"candidate", MutationTarget{Method: "Put", Key: key, After: body}, true}, |
| + {"overwrite", MutationTarget{Method: "Put", Key: key, After: body, ETag: "v"}, false}, |
| + {"delete", MutationTarget{Method: "Delete", Key: key}, false}, |
| + {"wrong host", MutationTarget{Method: "Put", Key: strings.Replace(key, "x.example", "other.example", 1), After: body}, false}, |
| + {"wrong digest", MutationTarget{Method: "Put", Key: key, After: append(append([]byte(nil), body...), ' ')}, false}, |
| + {"unknown field", MutationTarget{Method: "Put", Key: key, After: []byte(`{"bogus":true}`)}, false}, |
| + {"oversize", MutationTarget{Method: "Put", Key: key, After: make([]byte, 4097)}, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + c, err := ClassifyMutation(t.Context(), raw, w, tc.target) |
| + if (err == nil) != tc.ok { |
| + t.Fatalf("classification %+v %v", c, err) |
| + } |
| + if tc.ok && (c.Family != "lifecycle-origin-candidate" || len(c.Scopes) != 0 || c.NonApp == "") { |
| + t.Fatalf("bad class %+v", c) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan15LifecycleGrantCannotRewriteOrigins(t *testing.T) { |
| + w, o, clock := wakeResetFixture(t) |
| + ctx := registerFor(t, w, o, "create2", "create", o.Claim.Holder.Incarnation, clock) |
| + step := claimStep(t, o, func(c *hostclaim.Claim) error { |
| + c.Origins = &hostclaim.OriginHead{Digest: strings.Repeat("a", 64), Count: 1} |
| + return nil |
| + }) |
| + if err := authorizeClaimWrite(ctx, w.raw, o.Host, step); err == nil { |
| + t.Fatal("general app grant manufactured origin authority") |
| + } |
| + if err := authorizeClaimWrite(ctx, w.raw, o.Host, claimStep(t, o, func(*hostclaim.Claim) error { return nil })); err != nil { |
| + t.Fatalf("unchanged head refused: %v", err) |
| + } |
| + o.Claim.Origins = &hostclaim.OriginHead{Digest: strings.Repeat("b", 64), Count: 2} |
| + for _, head := range []*hostclaim.OriginHead{nil, {Digest: strings.Repeat("a", 64), Count: 1}} { |
| + step = claimStep(t, o, func(c *hostclaim.Claim) error { c.Origins = head; return nil }) |
| + if err := authorizeClaimWrite(ctx, w.raw, o.Host, step); err == nil { |
| + t.Fatal("grant erased or rolled back origin history") |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index bfb7fc6f8..b762f324a 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -748,13 +748,15 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostclaim/adopt.go", "Adopt", "key-site", "lit:instances", "24c06b99cce6c638acb19826985cfadd3c736fd0ace7289bcf7ac3dfe784f05a"}, |
| {"internal/hostclaim/claim.go", "ParkScope.Validate", "key-site", "lit:instances", "80b798b0cb679d8c533ff562c0c4804c06f53e7dba126250a3c8182f7ff0c08c"}, |
| {"internal/hostclaim/claim.go", "holderKeyMatches", "key-site", "lit:instances,lit:sidecars", "d15a6841ea6f8cbfa9c8407fd3af020ddeacdfa0743b9e17de412f113faf7979"}, |
| - {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "3278f2b4b24b6627b9dcd0863c3bef58c6694a65c8a19d05ce280ae34801bdfc"}, |
| + {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "2debcd30dda4747b8b3b00d576998f1fdc1d364ad9bacba1e1279df0b7dfb694"}, |
| {"internal/hostclaim/create.go", "BeginCreate", "key-site", "lit:instances", "45ee1e2b1fc9fa8ffdef4f27cf983dccfa3acf7218dccd06f0ee6d12572f4a44"}, |
| {"internal/hostclaim/lossrecord.go", "LossRecordKey", "key-site", "key:LossRecordKey", "1b109b49a8fcdb09f01dbf908a173394072f01afb0c9e324023fb9b3f71f98b3"}, |
| {"internal/hostclaim/lossrecord.go", "ReadLossRecord", "key-site", "key:LossRecordKey", "1237dd3fa3e79a81e6c35f371bf545c9a04841a6297c078d96852daf25b7b483"}, |
| {"internal/hostclaim/lossrecord.go", "CreateLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "e62263731fa572ed6f195a267abb63ff5a1bf44284db3db76a819ccaec238191"}, |
| {"internal/hostclaim/lossrecord.go", "ReplaceLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "576589457e4a18b64e6b56892603051982bd01770c355504305e72d53149bc52"}, |
| - {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "68f97a49f9a936228cb26e49d7bed3a4ab17c3a4c4255e32c2d2af1dfccd73a6"}, |
| + {"internal/hostclaim/origins.go", "originTuple.valid", "key-site", "lit:instances", "7f6f20f51e51694d665588f0b986def85bda4789f24623ee1993289265b01355"}, |
| + {"internal/hostclaim/origins.go", "commitOrigins", "nonrecord", "sel:Put", "f5f506d8b1e5d4dced896c6be3a6e498354e5f28d81452131e220fec58e8bd78"}, |
| + {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "ec5de8702797ce97a24147cbf16ad8f8e254e2da82d3a0859c0407501f0b575f"}, |
| {"internal/hostevidence/signature.go", "immutable", "nonrecord", "sel:Put", "54fb2bdc230c2cb4193f3b808004b63bcfd092266846fc0c03e61617c3ad0ab5"}, |
| {"internal/hostexec/controller.go", "Controller.run", "nonrecord", "sel:Put", "03df11b375761730700ce30c0db05c57f0828629eda0a5de126ea1a2f2f7501c"}, |
| {"internal/hostfence/termination.go", "TerminationWorker.persist", "nonrecord", "sel:Put", "47758114adcc3c493a9d1ead0fa7087ad0d1e49595c891bab4dfb51c9058a8a1"}, |
| @@ -786,7 +788,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/hostops/mutation_boundary.go", "mutationBoundary.Put", "record-transport", "def:Put,sel:Put,str:Put", "c4222199cdbfa8cce1f97f91bf41fe94857f4d833e30b17636c40f1aae39ff67"}, |
| {"internal/hostops/mutation_boundary_putfrom.go", "mutationBoundary.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom,str:Put", "8d015a924901f0c41e142181f986cb9d2a3bdfa6a9e078dfda05a2d1e7cf9b24"}, |
| {"internal/hostops/mutation_boundary_putfrom.go", "conditionalRecordStore.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom", "3c3cb33a601eab5c4d74042a7ca8baf411c0549508b5a8273ef88f4ee0e9636c"}, |
| - {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "75df2a3966d0bfe4b4b367617de3ebe28055a38ef4738f352859ba94ddbff241"}, |
| + {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "f43b261b71020751372753c17d0de3b5ea51866a9c2c08e7b514c38d775e6f90"}, |
| // Classifies mutation keys and method names without executing a store write. |
| {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "2d22e1d4a99a69ebff6f033a667f59c778855a66828ee5c7646a6b1d18573b65"}, |
| {"internal/hostops/mutation_classifier.go", "ownerUnion", "reference", "str:Put", "0c35233653dfe9ca6cc78bc54bac2c6991f82c9a7c2fd27c228c125f099f489b"}, |