mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
revert: remove the uncalled ADR-0007 codec and design stack from the ADR-0001 checkpoint
The instance transition-record codec, its tests and the slice 1a design had no production caller. They leave this package-boundary checkpoint and will be reapplied on a dedicated ADR-0007 branch. Slice 0 characterisation and the vpcd IMDS readers in domains/ec2/instance/state stay.
This commit is contained in:
@@ -75,7 +75,6 @@ var ownerFiles = []string{
|
||||
"spinifex/daemon/instance_running_set.go",
|
||||
"spinifex/daemon/jetstream.go",
|
||||
"spinifex/daemon/local_state.go",
|
||||
"spinifex/domains/ec2/instance/state/record_codec.go",
|
||||
"spinifex/runtime/compute/vm/record.go",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ var allowlist = []allowance{
|
||||
{From: "domains/ec2/instance", To: "runtime/compute/gpu", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
|
||||
{From: "domains/ec2/instance", To: "runtime/compute/vm", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
|
||||
{From: "domains/ec2/instance/state", To: "daemon", Debt: "domains/ec2/instance/state (dependency, IMDS readers)", Reason: "wraps the daemon's local-file reader and JetStreamManager bucket open unchanged for vpcd's IMDS lookup"},
|
||||
{From: "domains/ec2/instance/state", To: "runtime/compute/vm", Debt: "domains/ec2/instance/state (dependency)", Reason: "the record codec wraps the legacy vm.InstanceRecord alias and its spec and status types"},
|
||||
{From: "domains/ec2/instance/state", To: "runtime/compute/vm", Debt: "domains/ec2/instance/state (dependency)", Reason: "the vpcd IMDS local reader returns vm.VM"},
|
||||
|
||||
{From: "domains/ec2/volume", To: "runtime/compute/vm", Debt: "Domain → runtime", Reason: "VM runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
|
||||
@@ -329,11 +329,7 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `38fbd1499` | (characterisation only) | — | ADR-0007 slice 0: pins the local file's exact bytes and schema failure modes, the instance bucket configuration, the `i.<id>` key and wire form, the bucket version gate, generation preservation across every KV writer and the VM conversion, and what a returning node does with a missing, foreign, terminal or unreadable canonical record. Current behaviour, not the ADR target; see the slice 0 section below. |
|
||||
| `6fa09d336` | (governance only) | — | ADR-0007 slice 0 ratchet at `architecture/instancestate`: freezes the 73 direct production uses of raw instance state found by the inventory below. |
|
||||
| `2a196f9ba` | (governance only) | — | ADR-0007 slice 0 ratchet narrowed to instance-state access: the `JetStreamManager` type and constructor are no longer matched, the cluster-state consumers INV-09 and INV-19 leave the allowlist, and 67 entries remain. |
|
||||
| `e9e11d531` | (design only) | — | ADR-0007 slice 1a: `docs/package-boundary/instance-state-transition-record-design.md` designs the transitional `i.<id>` record (per-record `format` marker, `assignment` with generation, epoch and token, fenced `observation`, legacy mirror), the writer authority table, top-level unknown-member preservation, the activation gate (per-slot capability proof over the daemon and `vpcd`, narrowed by the recorded user decisions D1 to D5; Inactive, `PREPARED`, `ACTIVE`, `DEACTIVATING`, `FINALISED`; stamp raise against R0 restarts; fail closed when unreadable), the journal v2 rules and the slice 1b order. No code, schema, writer or test changed; no conformance claimed. |
|
||||
| `e62f921d2` | (new owner code only) | `spinifex/runtime/compute/vm/record_codec.go` | ADR-0007 slice 1b step 1: the instance transition-record codec (`DecodeTransitionRecord`, `TransitionRecord.Encode`) with the per-record `format` marker, the format-2 `assignment` and `observation` types, preservation of unknown members at every object level, and typed rejection of malformed, duplicate-key, case-alias, trailing-data and invalid-marker input. `vm.InstanceRecord` and every existing writer are unchanged, so wire bytes are unchanged (`TestCurrentBehaviour_RecordKeyAndWireForm` passes with its literal unchanged). No production reader or writer uses the codec yet and nothing conforms yet. The ratchet gains the codec as an owner file; the allowlist is unchanged. Full preflight passed except the recorded Go 1.27.0 advisories. |
|
||||
| `8db9771fb` | `spinifex/runtime/compute/vm/record_codec.go` | `spinifex/domains/ec2/instance/state` | The codec moves unchanged to the package that is the eventual owner of the persisted instance record and its evolution; `vm.InstanceRecord` and its spec and status types stay in `runtime/compute/vm` as the temporary legacy types it wraps. The domain-to-runtime import is recorded below as `domains/ec2/instance/state` (dependency) debt and allow-listed against it; the instance-state ratchet names the new owner file and its allowlist is unchanged. Full preflight passed except the recorded Go 1.27.0 advisories. |
|
||||
| `c617914a1` | (codec behaviour only) | — | A changed array whose elements as read carry an unknown member at any depth is refused with `ErrRecordExtensionConflict` instead of being replaced and losing the member; unchanged arrays and changed arrays without element extensions behave as before. Still no production caller. Full preflight passed except the recorded Go 1.27.0 advisories. |
|
||||
| `0d7b4266d` | (codec behaviour only) | — | The extension-conflict refusal becomes uniform: a capable writer may not remove or replace a known object, map entry or array whose stored value carries unknown structured extension fields, and `Encode` returns `ErrRecordExtensionConflict` naming the changed container and the extension. Removal without extensions stays allowed. Still no production caller. Full preflight passed except the recorded Go 1.27.0 advisories. |
|
||||
| (uncommitted; hash added at commit) | (revert only) | — | The ADR-0007 slice 1a design and slice 1b step 1 codec stack (`e9e11d531` to `c3f302423`) leaves this ADR-0001 checkpoint: no production reader or writer called the codec, so carrying it would ship dormant lifecycle implementation. `record_codec.go`, its tests and `docs/package-boundary/instance-state-transition-record-design.md` are removed, with the codec's instance-state ratchet owner-file line and the record rows and section describing them. The slice 0 evidence (`38fbd1499`, `6fa09d336`, `c04222b27`, `2a196f9ba`, `830f70489`) stays, and `domains/ec2/instance/state` stays for the vpcd IMDS readers. The stack is reapplied on a fresh ADR-0007 branch from `dev` after this checkpoint merges. |
|
||||
| `e40e699e5` | `spinifex/vpcd/imds_instance_state.go` | `spinifex/domains/ec2/instance/state/imds_readers.go` | The local-file reader (`LocalVMReader`/`NewLocalVMReader`, formerly `localVMStateReader`) and the instance-record loader opener (`OpenInstanceRecordLoader`, formerly `newInstanceRecordLoader`) move unchanged to the instance-state boundary; `vpcd.Config` gains `LocalVMs guestmetadata.LocalStateReader` and `OpenInstanceRecords func(ctx, nc) (guestmetadata.RecordLoader, error)`, wired by `operator/cli` with `state.NewLocalVMReader(nodeConfig.DataDir)` and `state.OpenInstanceRecordLoader`, so `vpcd` no longer imports `daemon` directly. `guestmetadata`'s own narrow interfaces are exported (`LocalStateReader`, `RecordLoader`) with no semantic change so `vpcd.Config` can name them. The opener's absence is now a fatal startup error rather than an unchecked call, matching the existing mandatory-open behaviour. The instance-state ratchet's INV-13 entries move with the file; `OpenInstanceRecordLoader` returning `guestmetadata.RecordLoader` directly (so the two packages' function types match exactly) removes the file's local `recordLoader` interface and its `vm.InstanceRecord` reference, so INV-13 now cites 5 symbols rather than 6. `vpcd` still runs the legacy bucket-opening path (`InitKVBucket`, the version gate and the instance migrations) through the injected opener; that is ADR-0007 debt (INV-13) and is deliberately not fixed here. Full preflight passed except the recorded Go 1.27.0 advisories. |
|
||||
| `7bf6c10d7` | `spinifex/vpcd` | `spinifex/runtime/roles/vpcd` | The whole vpcd role moves unchanged to its ADR-0001 runtime-role target: all sixteen production and test files (`vpcd.go`, the lease reaper, the OVN controller watchdog, the host-EIP loop, lease datapath reconcile and their tests), with no split and no lifecycle-behaviour change. The package name stays `vpcd`. `operator/cli/service.go` and `runtime/service/{service,service_test}.go` update their import path. `docs/service-interfaces.yaml`'s `vpcd` service entry, `.github/CODEOWNERS` and the real-manifest case in `tests/e2e/select-suites/selector_test.go` follow the new path; that package's own synthetic `testManifest()` fixture keeps its illustrative `spinifex/vpcd` string by design, decoupled from the real manifest. The network invariant scan roots in `domains/network/invariants/{nat_mode,nats_subjects,names}_test.go` and the S3 exemption for `vpcd.go` follow the new path; injected `UplinkMode()` and `vpc-`-prefixed concat faults in moved files failed S3 and S4 respectively, confirming the scan still covers them, and were reverted by hand. `ingress/aws/invariants/deps_test.go` keeps `vpcd` in its `forbiddenRoots` as defence in depth: `runtime` already rejects an import of the new path, confirmed by an injected import of `runtime/roles/vpcd` into an `ingress/aws` file, and the retained entry still rejects an ingress import of a recreated legacy `vpcd` directly. The layering package drops `vpcd` from `legacyRoots`, `runtimeLegacy` and `frozenLegacyPackages` and adds it to `retiredLegacyRoots`, so `runtime/roles/vpcd` classifies as runtime automatically and a revived `spinifex/vpcd` directory fails the frozen-root guard, both confirmed by fault injection and reverted; the stale `operator/cli` → `vpcd` allowlist entry is deleted rather than repointed, because operator already imports other `runtime/roles/*` packages with no allowlist entry and no rule forbids it. The instance-state ratchet's allowlist cites no vpcd path and is unchanged. |
|
||||
| `a130f6b8e` | `spinifex/handlers/ecs/bus` | `contracts/ecs/v1` (`ecsv1`) | The ECS scheduler-agent wire vocabulary moves with no legacy shim: five subject builders and the message types (`RegisterInstance`, `Heartbeat`, `Assign`, `AssignContainer`, `StopDirective`, `TaskState`, `ContainerStatus`, `InstanceCapacity`, `PortMapping`, `SystemControl`, status constants). Subject strings, JSON fields and types are byte-identical; the old-agent `AssignContainer` compatibility test moves unchanged and new golden pins cover every message type that had none. A trace found only `service-reconcile` live on the bus (`handlers/ecs/instances.go` publishes, the scheduler leader subscribes); the agent reuses `Assign`, `StopDirective` and `TaskState` as payloads of the gateway's `PollAssignments` and `SubmitTaskStateChange` actions, and the README route table and type comments say so. `docs/service-interfaces.yaml` lists `ecs.bus.*` and the contract path under the daemon. The layering package drops the stale `agents/ecs/agent` → `handlers/ecs/bus` allowlist entry and the frozen-package entry. |
|
||||
@@ -435,7 +431,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `gateway/bedrock`: holds the Ochre domain core (model catalogue, provisioned throughput, guardrail, access, credential, usage, price, logging and weights stores), the inference engine (provider, vLLM, Anthropic, embeddings, reranker, admission) and the Bedrock action layer in one package with internal cycles. `domains/ochre` imports it, which is the ADR step 7 Ochre gateway import. Splitting it needs a plan: domain core and stores to `domains/ochre/<resource>`, inference to an Ochre-owned package, and only the action layer to `domains/ochre/awsapi`. The top-level `gateway/bedrock*.go` HTTP handlers additionally wait for the ingress registration seam.
|
||||
- `domains/network`: `reconcile/intent.go` builds desired network state by reading EC2's KV buckets and decoding EC2's private VPC, subnet, ENI, route-table, gateway, EIP, NAT and security-group records, and `reconcile/drift.go` watches those buckets. ADR-0001 names this pair explicitly: EC2 and network communicate through a capability/projection or versioned contract, never by one being the other's KV reader. Step 7 fix: an EC2-owned `domains/ec2/projection` exposing network intent with list and watch (it may stay KV-backed, so no data moves); a narrow intent interface declared by `reconcile`; wiring at the composition root; and the duplicated `PurposeEIP`/`PurposeIGWLRP` constants and mirrored `SGRule`/`SGEvent` folded into the projection or `contracts/ec2/v1`. The drift-watch coverage test and reconcile scenario tests are the evidence the projection must keep. `domains/network` also imports `runtime/compute/vm` and `runtime/host/command` (domain-to-runtime).
|
||||
- `domains/ec2/instance`: imports `runtime/compute/vm` and `runtime/compute/gpu` (domain-to-runtime) and `domains/dns` (cross-domain implementation import).
|
||||
- `domains/ec2/instance/state` (dependency): the instance transition-record codec imports `runtime/compute/vm` because it wraps the legacy `vm.InstanceRecord` alias and its `InstanceSpec` and `InstanceStatus` types. Removal condition: the spec and status types and the record alias move into `domains/ec2/instance/state`, and `runtime/compute/vm` consumes them from there (runtime to domain is allowed).
|
||||
- `domains/ec2/instance/state` (dependency): the vpcd IMDS local reader imports `runtime/compute/vm` to return `vm.VM`. Removal condition: that type moves into a domain-owned package that `runtime/compute/vm` consumes (runtime to domain is allowed).
|
||||
- `agents/eks/tokenwebhook`: imports `handlers/eks` for `WebhookTokenReviewResult`, an agent depending on a domain implementation. Temporary: the eventual EKS contract or projection for the token-review reply needs its own design, and no contract is invented to complete the move.
|
||||
- `agents/ecs/agent`: takes its wire types from `contracts/ecs/v1`, and still imports `handlers/ecs` for the poll-assignment and GPU-report inputs and outputs and the instance-type and availability-zone attribute names. Temporary agent-to-domain debt; no contract is created during the relocation.
|
||||
- `agents/rds/agent` (wire contract): imports `handlers/rds` for the command, reply, parameter, health and bootstrap types exchanged with the control plane. Temporary agent-to-domain debt; no contract is created during the relocation. The earlier `LookupEngine` dependency on the RDS domain's engine registry has since cleared: `controlPlaneRulesFrom` and both engine constructors now take the agent-declared `EngineCatalog` (see the narrowed entry below), with zero production uses of `LookupEngine` remaining in `agents/rds/agent`.
|
||||
@@ -470,7 +466,7 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
|
||||
|
||||
The layering test enforcing these rules (`architecture/layering`, a module-root repository governance test, not Spinifex product code; run by `make test`, `test-cover` and `test-race`) is blocking, with a ratchet: the debt recorded in this section is narrowly allow-listed by owner and removal condition, and any new, unrecorded forbidden edge fails local preflight and CI. The allow-list mirrors the edges in this section that break an import rule; same-domain legacy edges, placement residuals and security notes are not import-rule violations and have no allow-list entry.
|
||||
|
||||
- Pointer-bump follow-up in the umbrella repository: `make docs-portal` for the instance transition-record codec move to `domains/ec2/instance/state` is deferred to the Mulga gitlink pointer-bump PR, so the portal evaluates the same Spinifex revision being proposed.
|
||||
- Pointer-bump follow-up in the umbrella repository: `make docs-portal` for this branch's source moves is deferred to the Mulga gitlink pointer-bump PR, so the portal evaluates the same Spinifex revision being proposed.
|
||||
- Merge-time follow-up in the umbrella repository: the `Makefile` `invariants-check` target, the `docs-pr.yml` clause cross-check, `.claude/hooks/architecture-traps.sh` and a `check-invariants.sh` comment hard-code `spinifex/spinifex/network/invariants`. They must change with the pointer bump that brings in this branch, or the docs-pr cross-check silently skips. Live specs and proposals citing `spinifex/network/` will report unresolved citations until updated. A read-only mulga-repository count (excluding submodules, `docs/development/archive/` and agent worktrees) confirms four items still open: `spinifex/network/` appears in 49 mulga files; `services/awsgw` appears in 19 (`.claude/rules/spinifex.md` plus 18 docs; the umbrella `.claude/CLAUDE.md` does not contain it but cites the old `network/policy/policy.go:28`); and `services/spinifexui` (including its `/frontend` subpath) appears in 20 mulga files, including `ansible/roles/build/tasks/main.yml`, the `.claude/settings.json` formatting hook, the `.claude/rules/frontend-standards.md` glob, `.claude/skills/run-spinifex/SKILL.md` and `ui-dev.sh`, `.vscode/settings.json` and `README.md`. The same `SKILL.md` also names `cmd/spinifex/cmd`, the obs-agent Ansible role's `otelsetup` path mirrors the pre-move name, and `docs/specs/guides/dependency-enforcement.md`'s "Known open edges" section carries the same pre-move paths; all four are mulga-repository updates, out of scope for a Spinifex-only commit.
|
||||
|
||||
### Security corrections and branch preflight exception
|
||||
@@ -635,17 +631,6 @@ The `JetStreamManager` type and `NewJetStreamManager` are not matched, because t
|
||||
Adding a `daemon.LocalStatePath` use to `runtime/roles/awsgw` and renaming one allowlist file were each confirmed to fail the gate.
|
||||
After narrowing, a `runtime/roles/awsgw` file constructing a manager and calling `InitClusterStateBucket` passed, the same file calling `WriteRunningSet` failed `NoUnrecordedConsumer`, and restoring the removed INV-19 entry failed `Allowlist_NoStaleEntries`.
|
||||
|
||||
### Slice 1b step 1: record codec
|
||||
|
||||
`e62f921d2` adds the record codec, `8db9771fb` moves it to `domains/ec2/instance/state` `c617914a1` adds the extension-conflict refusal for changed arrays and `0d7b4266d` makes it uniform; the design records it as Section 10 (C1 to C8) of `docs/package-boundary/instance-state-transition-record-design.md`.
|
||||
`domains/ec2/instance/state` is the eventual owner of the persisted instance record and its evolution; `vm.InstanceRecord` in `runtime/compute/vm` is the temporary legacy type it wraps.
|
||||
No production reader or writer uses it, so every row of the generation matrix above still reads as characterised, and no path conforms to ADR-0007 yet.
|
||||
The codec protects capable writers only: a deployed binary still decodes into the typed record and drops unknown members, and excluding such writers remains the activation gate's job.
|
||||
Within the codec, unchanged values keep unknown members at every depth, and a capable writer may not remove or replace a known object, map entry or array whose stored value carries unknown structured extension fields: `Encode` refuses with a typed extension-conflict error naming both paths, never silently dropping them, and allows the removal when no extension is present.
|
||||
`architecture/instancestate` lists `spinifex/domains/ec2/instance/state/record_codec.go` as an owner file; no instance-state allowlist entry was added.
|
||||
|
||||
Operator-visible rollout qualification (design clause G19): old read-only gateway consumers remain safe, but DescribeInstanceStatus gains unconfirmed/degraded visibility only after awsgw upgrades.
|
||||
|
||||
## Close-out status
|
||||
|
||||
The table records every legacy directory still standing, what remains in it
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
# EC2 instance state: transition record and activation gate (slice 1a)
|
||||
|
||||
Code read at `830f70489`; not verified live.
|
||||
|
||||
**Status:** Approved design; decisions recorded in Section 9 (D1 to D5) and clauses G15 to G18 and K5.
|
||||
Slice 1b step 1 (the record codec, Section 10) is implemented with no production caller; nothing else is implemented and no path conforms yet.
|
||||
|
||||
This is the design part of slice 1 ("Transition-record design and expand stage") of `mulga/docs/development/feature/instance-state-authority-migration.md`.
|
||||
It changes no production code, schema, writer or test.
|
||||
Paths are relative to `spinifex/` unless they start with `docs/` (this repository) or `mulga/` (the umbrella repository).
|
||||
|
||||
## Standing
|
||||
|
||||
ADR-0007 (`mulga/docs/adr/0007-instance-state-authority-and-partition-recovery.md`) is accepted and is applied by clause: S1 (owner-controlled desired intent, assignment generation and fence), S2 (local file is an execution journal), S3 (intent before realization, evidence after), S4 and S5 (partition and reconnection), S6 (narrow capabilities), S7 (ADR-0006 evolution; no compatibility from a bucket version alone; no best-effort poll as a fence) and S9 (reuse `kvutil`, `kvstore`, `kvlease`, `migrate`, `reconciler`, `resource.Object`).
|
||||
ADR-0006 (`mulga/docs/adr/0006-contract-guest-and-persisted-state-evolution.md`) is accepted and is applied by clause: S2 (N/N-1 in both rollout orders), S3 (no heuristic as capability proof), S4 (an older writer that discards fields is not compatible; expand, migrate, contract), S5 (prepare, activate, migrate, finalise; controlled rollback) and S8 (evidence).
|
||||
No new ADR is needed: these two decisions authorise the design, and the clauses below are implementation design under them.
|
||||
The generic activation concepts are taken from `docs/package-boundary/eks-release-membership-and-activation-design.md` Section 2 and review decisions M1 to M4; this document does not widen the EKS scope or build a shared package.
|
||||
|
||||
Release names used below:
|
||||
|
||||
| Name | Meaning |
|
||||
|---|---|
|
||||
| R0 | Every binary deployed today: decodes into Go structs and re-encodes, drops unknown fields, has no per-record version and no capability report. |
|
||||
| R1 | The expand release delivered by slice 1b. |
|
||||
| R2+ | Any later release that changes the record format again. |
|
||||
|
||||
## 1. Evidence this design depends on
|
||||
|
||||
All of it is from the slice-0 inventory in `docs/PACKAGE_BOUNDARY_MIGRATION.md` ("ADR-0007 instance-state inventory (slice 0)") or the code at `830f70489`.
|
||||
|
||||
| # | Fact | Evidence |
|
||||
|---|---|---|
|
||||
| E1 | Every KV writer decodes into `vm.InstanceRecord` and re-encodes; unknown fields are lost at every nesting level. | Generation matrix, every row; `TestCurrentBehaviour_TypedRecordRoundTripDropsUnknownFields` |
|
||||
| E2 | `WriteRunningSet`, `WriteStoppedInstance`, `UpdateStoppedInstance`, `WriteTerminatedInstance`, `UpdateTerminatedInstance` and the `LoadState` path also lose `Generation` and `ObservedGeneration`. | Matrix rows marked "Lost"; `daemon/instance_running_set.go:74`, `daemon/jetstream.go:534`, `daemon/jetstream.go:778` (`mutateInstance`), `daemon/instance_records.go:164` (`writeRecord`) |
|
||||
| E3 | `VMFromRecord` then `Record()` also loses `UID`, `Region`, `OwnerRefs`, `Finalizers` and `Metadata.Tags`. | `TestCurrentBehaviour_RecordThroughVMLosesGenerationAndBookkeeping` |
|
||||
| E4 | Records carry no per-record version; the only version is the bucket stamp `_version` (live 5, terminated 3). | Persisted-state table; `daemon/jetstream.go:53`; `foundation/state/kvutil/state.go:4` |
|
||||
| E5 | The stamp is checked only when a handle is opened; `SchemaAheadError` refuses a bucket stamped ahead, and a handle already open keeps writing. | `TestCurrentBehaviour_BucketVersionGate`; `foundation/state/migrate/kv.go:57-89`; `foundation/state/kvstore/bucket.go:89-107` |
|
||||
| E6 | `kvutil.WriteVersion` only raises a stamp; nothing lowers one. | `foundation/state/kvutil/kvutil.go:211-228` |
|
||||
| E7 | Migrations run on open in whichever process opens through the daemon's config, including `vpcd`. | INV-02, INV-13; `vpcd/imds_instance_state.go:88-95` |
|
||||
| E8 | The gateway (INV-16, INV-17, INV-18) and network reconcile (INV-15) open the bucket with no hook and decode records; neither writes instance records. | `runtime/roles/awsgw/awsgw.go:573-633`, `domains/network/reconcile/intent.go:444-474`; no `Put`, `Update`, `Create`, `Replace`, `Set`, `Mutate` or `Delete` call on the instance store in `runtime/compute/cache/cache.go`, `domains/admission/quota/records.go` or `intent.go` |
|
||||
| E9 | Assignment today is `Status.LastNode`, moved by CAS on the entry revision (`ClaimRecoverableInstance`, `ClaimStoppedInstance`, `ReleaseRecoveredInstance`). | `daemon/jetstream.go:596-705`; INV-10 |
|
||||
| E10 | The local file is `{"schema_version":1,"vms":{...}}`; any other version fails daemon boot, and `vpcd` reads the same file through `daemon.ReadLocalState`. | `TestCurrentBehaviour_LocalStateSchemaVersionFailures`, `TestDaemonLoadState_CorruptFile`; INV-13 |
|
||||
| E11 | Unknown fields in the local file at the same version are accepted and dropped on rewrite. | `TestCurrentBehaviour_LocalStateUnknownFieldsAreDroppedOnRewrite` |
|
||||
| E12 | A restore with KV unavailable leaves local instances unlaunched with their in-memory status untouched. | `TestCurrentBehaviour_RestoreWithKVUnavailableLeavesLocalUnlaunched`; owner disposition in the slice-0 record |
|
||||
| E13 | The daemon has no runtime build version; heartbeats in `spinifex-cluster-state` carry node, epoch and services but no capability. | `docs/package-boundary/release-and-version-inventory.md` G1; `daemon/heartbeat.go` |
|
||||
| E14 | `kvlease` requires a TTL bucket whose TTL equals the lease TTL; the instance bucket has no TTL. | `foundation/state/kvlease/lease.go:28-75`; `TestCurrentBehaviour_InstanceBucketConfiguration` |
|
||||
|
||||
## 2. Transition record (T-series)
|
||||
|
||||
**T1. Key and owner.**
|
||||
The record stays at `i.<instance-id>` in `spinifex-instance-state` and, once terminal, in `spinifex-terminated-instances`.
|
||||
Its codec, keys and format marker belong to the EC2 instance owner (ADR-0007 S1); slice 2 moves them behind `domains/ec2/instance`, and this design does not require that move first.
|
||||
`domains/ec2/instance/state` is the eventual owner of the persisted record and its evolution, and holds the codec from slice 1b step 1 (C1); `vm.InstanceRecord` and its spec and status types in `runtime/compute/vm` are the temporary legacy types it wraps.
|
||||
Rationale: the key migration already landed (live 3→4); re-keying again would add a second crossing to every consumer INV-03 to INV-18.
|
||||
|
||||
**T2. Format marker.**
|
||||
Each record gains a top-level integer `format`.
|
||||
An absent marker means format 1, the shape R0 writes today.
|
||||
The transitional shape is format 2.
|
||||
A reader treats the marker as follows:
|
||||
|
||||
| Marker | R1 reader | R1 writer |
|
||||
|---|---|---|
|
||||
| Absent or 1 | Decode the legacy fields. | May rewrite as format 1; may raise to 2 only under G8. |
|
||||
| 2 | Decode legacy and new sections. | Writes format 2, preserving every section. |
|
||||
| Greater than 2 | Decode the legacy fields for reading only and report `unknown_format`. | Refuses the write with an explicit error and leaves the record untouched. |
|
||||
| Not an integer | Refuses to decode the record; it is listed as unreadable, as a corrupt record is today. | Refuses the write. |
|
||||
|
||||
Rationale: E4 shows nothing can tell an old record from a new one, so neither lazy migration (ADR-0006 S7) nor a write-time refusal is possible without a per-record field.
|
||||
The marker states the format a record is in; it is not a fence and grants nothing (ADR-0006 S3).
|
||||
|
||||
**T3. Desired section (reused).**
|
||||
Desired lifecycle intent stays where it is: `spec` (including `spec.desired_state`) and the AWS-visible identity in `metadata` (`name`, `uid`, `account_id`, `region`, `owner_refs`, `finalizers`, `tags`, `deletion_timestamp`).
|
||||
`metadata.generation` becomes the desired generation and advances by one on each accepted desired mutation, through `resource.Object.MutateSpec`.
|
||||
Rationale: the envelope already has these fields (S9); the defect is that E2 and E3 writers erase them, not that they are missing.
|
||||
|
||||
**T4. Assignment section (new).**
|
||||
A new top-level `assignment` holds exactly one execution assignment:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `node` | Node identity the assignment grants realization to. Empty means unassigned (stopped, or awaiting placement). |
|
||||
| `generation` | Integer advanced by one on every assignment change: launch placement, start on another node, recovery claim, release, reassignment, unassignment. |
|
||||
| `epoch` | The activation generation (G5) under which the assignment was made. |
|
||||
| `token` | Random value minted with each assignment change. |
|
||||
| `for_desired_generation` | The desired generation the assignment was made to realize. |
|
||||
| `reason` | `launch`, `start`, `recovery`, `release`, `operator` or `migrated`. |
|
||||
| `storage_fence` | Optional reference to fencing evidence for the previous assignee's storage, required for a cross-node `recovery` assignment (T6). |
|
||||
|
||||
The fence a node holds and presents is the tuple (`instance`, `epoch`, `generation`, `token`).
|
||||
Comparison is lexicographic on (`epoch`, `generation`); `token` must match exactly.
|
||||
`status.last_node` and `status.az` remain as the legacy mirror of `assignment.node` and its zone while R0 readers exist (T9).
|
||||
|
||||
**T5. Observation section (new).**
|
||||
A new top-level `observation` holds the assigned node's report:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `node` | Reporting node. |
|
||||
| `assignment_epoch`, `assignment_generation`, `assignment_token` | The assignment the observation was made under. |
|
||||
| `desired_generation` | The desired generation the node had acted on; mirrored into `metadata.observed_generation`. |
|
||||
| `state` | Canonical observed state, including `unconfirmed` and `degraded` (Section 7). |
|
||||
| `evidence` | Kind (`qmp`, `process`, `journal`, `teardown`), node daemon incarnation and the time the evidence was taken. |
|
||||
| `reported_at` | Time of publication. |
|
||||
|
||||
The legacy `status` object remains the AWS-visible projection and R0-readable mirror until contract (T9).
|
||||
|
||||
**T6. Fence kinds.**
|
||||
|
||||
| Candidate | Viable as | Not viable as |
|
||||
|---|---|---|
|
||||
| KV entry revision (`kvstore.CompareAndSet`, `Mutate`) | The write precondition for every record change; it orders concurrent writers. | An assignment identity: it changes on every write, so a node cannot hold it across a partition or a restart. |
|
||||
| Assignment `generation` plus `token` in the record and the journal | The fence a node holds and presents; checked by the observation reporter inside the CAS and by the node on reconnection. | Proof that a partitioned former assignee has stopped; it cannot see the record. |
|
||||
| `kvlease` | Nothing in this slice. | The assignment fence: E14 requires a separate TTL bucket, and a lease lost by expiry during a partition conflicts with S4, which lets the node keep running an already assigned guest. |
|
||||
| Storage fence held by the EBS provider (`ClaimRecoverableInstance` comment, `daemon/jetstream.go:636-638`) | The only mechanism that stops a partitioned former assignee damaging data; referenced by `assignment.storage_fence` for a cross-node recovery assignment. | Something this design implements; volume fencing is an ADR-0007 non-goal. |
|
||||
|
||||
Forbidden as a fence, by ADR-0007 S4 and S7, ADR-0006 S3 and plan delivery rule 5: the local file or any local checkpoint, the bucket `_version` stamp, the record `format` marker, elapsed time or a polling interval, a best-effort poll, and heartbeat absence alone.
|
||||
Heartbeat staleness may still trigger recovery as it does today; it is the trigger, not the fence.
|
||||
|
||||
**T7. Field map.**
|
||||
|
||||
| Group | Current field | Slice-0 loss | Transition |
|
||||
|---|---|---|---|
|
||||
| Identity | `metadata.name`, `account_id` | Survive | Reused |
|
||||
| Identity | `metadata.uid`, `region`, `owner_refs`, `finalizers`, `tags` | Lost through `VMFromRecord` then `Record()` (E3) | Reused; every writer must preserve |
|
||||
| Desired | `spec.*`, `spec.desired_state`, `metadata.deletion_timestamp` | Survive the VM round trip | Reused |
|
||||
| Desired | `metadata.generation` | Lost by E2 writers; never written from `vm.VM` | Reused as desired generation |
|
||||
| Assignment | `status.last_node`, `status.az` | Overwritten by `WriteRunningSet` with the writer's node | Mirror of `assignment.node` |
|
||||
| Assignment | none | n/a | New: `assignment.*` |
|
||||
| Observation | `status.status`, `status.health`, `status.instance`, network and device fields | Survive | Legacy mirror and AWS projection |
|
||||
| Observation | `metadata.observed_generation` | Lost by E2 writers | Mirror of `observation.desired_generation` |
|
||||
| Observation | none | n/a | New: `observation.*` |
|
||||
| Format | none | n/a | New: `format` |
|
||||
| Exception | `spec.config.net_devs`, `spec.config.devices` | Node appends at launch (`runtime/compute/vm/record.go` comment) | Recorded divergence: a node-written spec field; slice 3 moves the appended part to observation |
|
||||
|
||||
**T8. Terminated bucket.**
|
||||
Records in `spinifex-terminated-instances` follow T2 to T7 unchanged; the owner moves a record there with its sections and members intact.
|
||||
Rationale: R0 writes there too (`WriteTerminatedInstance`, `UpdateTerminatedInstance`; INV-04, INV-06, INV-12), and the matrix shows the same losses.
|
||||
|
||||
**T9. Legacy mirror.**
|
||||
While any R0-shaped reader remains, format-2 writers keep `status.last_node`, `status.az`, `status.status` and `metadata.observed_generation` consistent with `assignment` and `observation` in the same CAS.
|
||||
R1 readers take assignment and observation from the new sections and use the mirror only for format-1 records.
|
||||
The mirror is removed only at finalisation, after every reader slot reports the contract-stage token (G5).
|
||||
|
||||
## 3. Writer authority (W-series)
|
||||
|
||||
**W1. Authority table.**
|
||||
|
||||
| Actor | Process | May write | Must not write |
|
||||
|---|---|---|---|
|
||||
| EC2 API and lifecycle controller (INV-07, INV-08, launch and stop paths) | daemon, any node | `metadata` identity, `spec`, `metadata.generation`, `deletion_timestamp`, `finalizers`; `assignment` for launch, start and operator reassignment | `observation`; `status` other than the AWS-visible transition it is accepting |
|
||||
| Assigned node realization (INV-03 running set, INV-06) | daemon, assignee only | `observation`, the legacy `status` mirror, `metadata.observed_generation`, under a matching fence | `spec`, `metadata.generation`, `assignment` |
|
||||
| Recovery (INV-10) | daemon acting as controller | `assignment` via conditional claim, release and abandon, with `reason` `recovery` or `release` | `observation` for a node other than itself |
|
||||
| Recovered-evidence reporter (ADR-0007 S5) | daemon, any node | A separate evidence key, never `i.<id>` (W4) | The canonical record |
|
||||
| Owner migrations (`daemon/instance_records_migrate.go`) | daemon only | Records and the stamp, through `migrate.Registry` | n/a |
|
||||
| `vpcd` (INV-13, INV-14, INV-15) | vpcd | Nothing | Records, the stamp, migrations |
|
||||
| Gateway instance cache and quota (INV-16, INV-17, INV-18) | awsgw | Nothing (E8) | Records, the stamp |
|
||||
| Operator tooling (INV-19 touches cluster state only) | `spx` | The activation record through the owner (G6) | Records directly |
|
||||
|
||||
**W2. Partial writes replace whole-record writes.**
|
||||
Each writer mutates only its field group inside a CAS on the revision it read, and copies every other section and every unknown top-level member from that read.
|
||||
`WriteRunningSet` becomes a per-instance observation update that checks the fence, rather than `Replace` of a record built from `vm.VM` and stamped with the writer's node.
|
||||
`WriteStoppedInstance` and `WriteTerminatedInstance` become mutations of the existing record (create only when absent), so identity and generations survive.
|
||||
`mutateInstance` (used by `UpdateStoppedInstance` and `UpdateTerminatedInstance`) stops doing `*record = *instance.Record()` and instead applies the caller's change to the record's own fields, or confines the `vm.VM` conversion to the fields the caller's group owns.
|
||||
`VMFromRecord` stays a read-side projection; no write path may round-trip a record through `vm.VM`.
|
||||
This is described for slice 1b and slice 3; nothing changes here.
|
||||
|
||||
**W3. Read-only consumers.**
|
||||
`vpcd`, the gateway and network reconcile are read-only today and stay so.
|
||||
They become narrow-capability consumers in slice 6 (S6); until then the expand release changes only how they open the bucket (G9).
|
||||
|
||||
**W4. Recovered evidence is not a record write.**
|
||||
A node that holds a journal entry for an instance whose canonical record is absent, foreign or terminal writes recovered-execution evidence under its own key (for example `recovered.<instance-id>.<node>`), never `i.<id>`.
|
||||
The owner decides adoption, orphan handling or termination (ADR-0007 S5).
|
||||
Slice 1b must confirm that no R0 reader enumerates the live bucket without a prefix filter before choosing the key.
|
||||
|
||||
## 4. Unknown-field preservation (P-series)
|
||||
|
||||
**P1. R1 preserves unknown top-level members on every decode–re-encode path.**
|
||||
The record codec keeps a retained set of top-level members it did not decode (`map[string]json.RawMessage`) and writes them back unchanged, and writers follow W2.
|
||||
Raw JSON merge at every level and `json.RawMessage` for the whole record were considered; both make every typed mutation a merge problem across about 40 status fields and the embedded AWS SDK structs.
|
||||
Superseded in part by C2: by user requirement the step 1 codec preserves unknown members at every object level, using a snapshot difference rather than a hand-written merge per field.
|
||||
|
||||
**P2. Future format changes add top-level members.**
|
||||
Because P1 preserves only top-level members, a later format adds sections at the top level and never adds a field inside `metadata`, `spec`, `status`, `assignment` or `observation` that an older writer must keep.
|
||||
A change that cannot follow P2 raises `format` and relies on T2's refusal.
|
||||
C2 lets capable writers preserve deeper additions, but P2 keeps future format evolution disciplined while pre-expand writers exist, and it is revisited only at finalisation.
|
||||
|
||||
**P3. Preservation alone is insufficient.**
|
||||
R0 binaries are deployed and cannot be patched: they drop every unknown member (E1), erase generations (E2, E3), keep writing through open handles (E5) and are ordinary writers on every node (INV-03, INV-05 to INV-08, INV-10).
|
||||
An R0 daemon that rewrites a format-2 record leaves a format-1 record with the legacy mirror and no assignment fence, which T2 would read as a valid format-1 record.
|
||||
Format 2 is therefore activated only once no R0 writer can run (Section 5).
|
||||
|
||||
## 5. Activation gate (G-series)
|
||||
|
||||
**G1. Contract scope.**
|
||||
The scope is every process that writes, migrates or decodes instance records or the local file (ADR-0006 S5: only roles that read, write or enforce the contract).
|
||||
|
||||
| Slot role | Why in scope | Required capability token |
|
||||
|---|---|---|
|
||||
| `daemon`, one per configured node | Writes records (W1), runs migrations, writes the local file | `ec2-instance-record/2-preserve` and `ec2-instance-journal/2` |
|
||||
| `vpcd`, one per configured node running it | Reads the local file (a v2 journal fails an R0 `vpcd`, E10), runs migrations today (E7) | `ec2-instance-journal/2-read` and `ec2-instance-record/2-read` |
|
||||
| `awsgw`, one per configured node running it (superseded by G15: not a slot) | Decodes records into the instance cache and quota counts | `ec2-instance-record/2-read` (superseded by G15) |
|
||||
| Operator CLI | Does not touch instance records today (INV-19) | Not a slot; becomes one only if a later command writes records |
|
||||
| Migration runner | The daemon slot after G9 removes migration from `vpcd` | Covered by `daemon` |
|
||||
|
||||
A slot is (Region, node, role), derived from configured membership (`bootstrap/config` node map and its per-node services), never from who reports.
|
||||
The `awsgw` row is superseded by G15: the slot roles are `daemon` and `vpcd` only.
|
||||
|
||||
**G2. Capability report.**
|
||||
Each R1 process writes a self-report for its slot on start and on an interval: role, node, random incarnation, capability tokens compiled into the binary, configuration epoch and a diagnostic build version.
|
||||
The report lives in `spinifex-cluster-state` beside the heartbeat, under a key R0 never reads (`release.<role>.<node>`), as the EKS design chose (E13).
|
||||
The R1 daemon heartbeat gains an additive capability marker; an R0 daemon rewrites its heartbeat every 10 s without it, which is positive legacy evidence.
|
||||
Each slot also answers a per-slot probe at evaluation time: the daemon's existing per-node health reply gains incarnation and tokens; `vpcd` needs a probe route (the `awsgw` probe is superseded by G15).
|
||||
The report and probe follow the shared model of G18.
|
||||
|
||||
**G3. Proof of compatibility.**
|
||||
A slot counts as compatible only when all of these hold at evaluation:
|
||||
|
||||
1. Its report is present, names every required token, and is newer than the staleness threshold.
|
||||
2. Its probe answers with the same incarnation and tokens.
|
||||
3. No legacy heartbeat or probe answer for that node has been observed since the report.
|
||||
4. Its configuration epoch equals the evaluator's.
|
||||
|
||||
A slot that is absent, stale, unreachable, partitioned, crashed, reporting an unexpected node, or answering without tokens is not compatible, and activation is refused.
|
||||
Time only makes the answer more conservative: staleness moves a slot from compatible to stale, never the reverse.
|
||||
A dead or removed node blocks until it reports again or leaves configured membership.
|
||||
`kvlease` is not used: a report is per slot, not mutual exclusion, and a lease's expiry would add a time-based proof ADR-0006 S3 forbids.
|
||||
|
||||
**G4. Activation record.**
|
||||
One record for this contract, stored owner-private in `spinifex-instance-state` under a key outside `i.` (for example `activation.instance-record`), no TTL, history 1.
|
||||
It holds the state, activation generation, prepared incarnation snapshot, legacy observations since activation, and an audit list.
|
||||
Every transition is one `kvstore.CompareAndSet` on the record revision with an operator client token; the loser receives the current state and changes nothing, and a repeat with the same token returns the recorded outcome.
|
||||
Each audit entry holds principal, token, from, to, reason and an evidence digest, written in the same CAS, plus a log line.
|
||||
|
||||
**G5. State machine.**
|
||||
|
||||
| From | To | Condition |
|
||||
|---|---|---|
|
||||
| absent (Inactive) or `PREPARED` | `PREPARED` | G3 holds for every slot; every record decodes; snapshot of incarnations stored |
|
||||
| `PREPARED` | `ACTIVE` | G3 still holds and every incarnation equals the snapshot; activation generation advanced; bucket stamps raised after this CAS succeeds (G16) |
|
||||
| `ACTIVE` | `DEACTIVATING` | Operator authorisation only; freezes format-raising writes and new assignments |
|
||||
| `DEACTIVATING` | `PREPARED` | Every record and journal back in format 1 (G12, J4); stamps lowered |
|
||||
| `DEACTIVATING` | `ACTIVE` | G3 again (abort rollback) |
|
||||
| `ACTIVE` | `FINALISED` | Every record format 2; every slot reports the contract-stage tokens; no legacy observation since activation; explicit confirmation |
|
||||
|
||||
No transition is automatic; only the owner, driven by an `spx admin` operator action evaluated by an R1 daemon on a subject R0 does not subscribe, moves the state.
|
||||
A binary start never activates anything, so first-opener activation (E7) is not used.
|
||||
|
||||
**G6. Who flips.**
|
||||
The operator command reaches an R1 daemon, which evaluates G3 and performs the CAS.
|
||||
On a single-node deployment the same command applies with two slots, `daemon` and `vpcd` (G15; the earlier three-slot text is superseded); the upgrade procedure may invoke it, but it is still an explicit, audited step.
|
||||
|
||||
**G7. Bucket stamp as the guard against R0 restarts.**
|
||||
Decided by D2; G16 states the ordering and the stamp's role.
|
||||
At `ACTIVE`, the owner raises the live bucket from 5 to 6 and the terminated bucket from 3 to 4.
|
||||
An R0 daemon or R0 `vpcd` that starts afterwards then fails its open with `SchemaAheadError` (E5) and never writes.
|
||||
Limits, stated as measured:
|
||||
- An already open R0 handle keeps writing (E5); G3's incarnation snapshot is what excludes one at activation.
|
||||
- The gateway and network reconcile open with no hook (E8) and ignore the stamp until G9 lands.
|
||||
- `WriteVersion` cannot lower a stamp (E6); deactivation needs an owner-run lowering write, which `kvutil` does not provide today.
|
||||
- R1 must accept 6 without migrating, so its open hook runs registered migrations up to 5 and treats 6 as understood; `migrate.Registry.RunKV` has a single target today and needs an "understood ceiling" or an owner-side pre-check.
|
||||
This departs from the EKS design's "no stamp bump" invariant deliberately: there the all-or-nothing refusal was the reason to avoid it, while here, after every slot is proven R1, refusing every R0 opener is the wanted outcome.
|
||||
|
||||
**G8. R1 write-time rules.**
|
||||
These apply in every state, so they also protect against an R2 activation later.
|
||||
1. Inside each CAS, the writer reads the record's `format` and refuses a format it cannot preserve (T2); the check and the write share one revision, so no read-then-write gap exists.
|
||||
2. A writer never changes a record's format except under a readable `ACTIVE` state (raise) or `DEACTIVATING` state (lower).
|
||||
3. The writer watches the activation record with `kvstore.Bucket.Watch` and re-reads it on a periodic backstop (the `reconciler` pattern); the cached state decides only whether to emit format 2 and the journal v2, never whether to preserve.
|
||||
4. Unknown top-level members are always preserved (P1).
|
||||
|
||||
**G9. INV-15 and INV-16 gaps, and `vpcd` migrations.**
|
||||
Before activation, R1 must:
|
||||
- open the bucket in `vpcd` (INV-13) attach-only with a check-only hook that refuses a stamp above its understood ceiling and runs no migration, so migrations have one runner;
|
||||
- give the gateway (INV-16) and network reconcile (INV-15) the same check-only hook, so a later R2 stamp makes them fail visibly rather than misread;
|
||||
- keep their decodes tolerant: `json.Unmarshal` into `vm.InstanceRecord` already ignores the new sections (`foundation/state/kvstore/store.go:338`).
|
||||
These consumers write nothing (E8), so the gap they open is misreading, not corruption.
|
||||
The legacy mirror (T9) keeps their reads correct during `ACTIVE`.
|
||||
Superseded by G15: the earlier text made them activation slots; only `vpcd` is a slot, for the journal, and the gateway and network reconcile are covered by N-1 reader tests instead.
|
||||
|
||||
**G10. Unreadable activation state.**
|
||||
When the activation record cannot be read or decoded, an R1 writer emits no format 2 and no journal v2, writes an existing record only in the format it found (G8.2), and refuses creates and assignment changes that would require choosing a format with a retryable unavailable result.
|
||||
For the EC2 API that result is a retryable 503 through `awserrors`; slice 1b names the exact code against AWS behaviour.
|
||||
An absent record means Inactive; only that case selects format-1 semantics.
|
||||
This mirrors EKS M3 (`ActivationStateUnavailable`): unreadable selects neither old nor new semantics.
|
||||
|
||||
**G11. Legacy writer after activation.**
|
||||
An R0 daemon heartbeat or probe answer observed while `ACTIVE` is recorded in the activation record and freezes the instance-state contract; the freeze scope is decided by G17, which supersedes the scope stated in the earlier text of this clause.
|
||||
The freeze clears only on a compatible new incarnation for that slot or an operator rollback.
|
||||
Damage already done by such a writer appears as format-1 records with no assignment; the owner reports them and does not re-derive a fence from `status.last_node` without an operator action.
|
||||
|
||||
**G12. Rollback.**
|
||||
- Before activation: R1 has written only format 1 and journal v1, so R0 reads and writes everything; P1's retained members are empty.
|
||||
- After activation: freeze, drain or cancel outstanding format-2 obligations (in-flight recovery assignments, unpublished observations), down-convert every record to format 1 (drop `format`, `assignment`, `observation`; the legacy mirror is already current), down-convert each node's journal to v1 (Section 6), lower the stamps, then move to `PREPARED`; only then may an R0 binary start.
|
||||
- Re-activation advances the activation generation, so every assignment minted afterwards outranks any lost before (T4); no fence from the previous epoch is honoured without re-issue.
|
||||
- After `FINALISED`, rollback to R0 is unsupported.
|
||||
|
||||
**G13. Mixed-version rolling upgrade.**
|
||||
Both rollout orders are safe while Inactive: R1 and R0 both write format 1 and journal v1, R1 preserves what R0 drops, and R0 refuses nothing new.
|
||||
Activation is impossible until the last slot is R1, which is the gate's purpose and not a requirement that every node stop at once (ADR-0006 S2).
|
||||
|
||||
**G14. Reusable versus instance-specific.**
|
||||
|
||||
| Mechanism | Class |
|
||||
|---|---|
|
||||
| Slot derivation from configured membership; incarnation; capability token compiled into the binary; self-report and probe; legacy signal from the heartbeat | Reusable concept; one platform-internal report and probe model shared with the EKS design (G18) |
|
||||
| Activation record shape, CAS transitions with client token and in-record audit; unreadable means fail closed; activation generation | Reusable concept; each contract keeps its own record |
|
||||
| `migrate.Registry` understood ceiling; owner-run stamp lowering | Reusable extension of `foundation/state/migrate` and `kvutil` |
|
||||
| Per-record `format` marker and top-level member preservation | Reusable pattern; codec is instance-specific |
|
||||
| Scope (daemon, vpcd, awsgw), tokens, stamp values, assignment fence, legacy mirror, journal v2, freeze scope | Instance-specific |
|
||||
| Rollout driver, global "all upgraded" gate, release manager | Out of scope |
|
||||
|
||||
**G15. Activation participants are the daemon and `vpcd` (D5).**
|
||||
The slot roles are `daemon`, the canonical writer of records and the journal, and `vpcd`, which reads the journal and must understand journal v2 (E10).
|
||||
`awsgw`, the gateway instance cache, quota reconciliation, network reconcile and the operator CLI are read-only (E8, INV-19) and are not activation participants.
|
||||
Their safety rests on T9: under `ACTIVE` every format-2 record carries current legacy fields, and slice 1b proves with N-1 reader tests that each of them consumes a format-2 record through those fields.
|
||||
Proof (G3) is evaluated for the `daemon` slot of every configured node and the `vpcd` slot of every configured node that runs it; on a single node that is two roles.
|
||||
Every role is started from the same binary (`build/systemd/spinifex-daemon.service`, `spinifex-vpcd.service` and `spinifex-awsgw.service` all run `/usr/local/bin/spx service <role> start`), so replacing the file on disk does not change a process already running the old code.
|
||||
The per-start incarnation in the report and the probe (G2, G3) is what detects such a process; a file version check would not.
|
||||
A consequence of not gating `awsgw`: until it is upgraded, `DescribeInstanceStatus` (served from the gateway cache) shows the legacy status checks, so the K5 surfacing of `unconfirmed` is complete only on an R1 gateway; that is a visibility limit, not a corruption path.
|
||||
|
||||
**G19. Operator-visible rollout qualification for `awsgw`.**
|
||||
Old read-only gateway consumers remain safe, but `DescribeInstanceStatus` gains unconfirmed/degraded visibility only after awsgw upgrades.
|
||||
Release notes and the activation status output must state this, so an operator does not read the legacy status checks from an R0 gateway as proof that no instance is `unconfirmed` or `degraded`.
|
||||
|
||||
**G16. The stamp is a restart barrier (D2).**
|
||||
The owner raises the instance-state bucket stamps (live 5 to 6, terminated 3 to 4) only after the G3 proof and the `PREPARED` to `ACTIVE` CAS have both succeeded.
|
||||
The stamp is a restart barrier for an R0 binary: an R0 daemon or `vpcd` that opens the bucket afterwards fails with `SchemaAheadError` (E5).
|
||||
It is not the activation proof, which is G3 and the activation record, and it is not a fence, which is T4 and T6.
|
||||
A failure to raise the stamp after a successful CAS leaves the record `ACTIVE` and is retried by the owner and reported as an incomplete activation; no R1 behaviour depends on the stamp value.
|
||||
This departs from the EKS design's "no stamp bump" invariant on purpose.
|
||||
There, readers outside the gated scope would have refused the whole bucket; here, by the time the stamp moves every writer and journal reader has proven R1, so the all-or-nothing refusal falls only on R0 processes that should not start, and it closes the restart gap the EKS design leaves open (its Section 6 item 2).
|
||||
The gateway and network reconcile open with no hook today (E8) and are unaffected; G9 gives them a check-only hook so a later raise beyond their ceiling is visible.
|
||||
|
||||
**G17. Freeze scope (D3).**
|
||||
Detecting an incompatible writer while `ACTIVE` freezes only the instance-state contract, Region-wide: instance lifecycle mutations, assignment and recovery, activation transitions, and journal migration.
|
||||
Reads, observations under a valid fence, and every unrelated service continue.
|
||||
Existing guests keep running; a node stops or fences a local guest only where local safety requires it (ADR-0007 S4), and records that as local evidence.
|
||||
The freeze clears as G11 states.
|
||||
|
||||
**G18. Shared capability model, per-contract activation (D1).**
|
||||
The slot, incarnation, capability-token, report and probe model is one platform-internal model, reused by this gate and by the EKS add-on design.
|
||||
Each contract keeps its own activation record; this one is G4.
|
||||
The shared model is a future foundation component; the instance-state gate does not import, call or wait on EKS code, and if the instance work lands first it creates the model where the foundation layer allows.
|
||||
No release manager, rollout driver or "all upgraded" gate is built in this refactor.
|
||||
|
||||
## 6. Local execution journal (J-series)
|
||||
|
||||
**J1. Content.**
|
||||
Journal v2 keeps the v1 body (`vms`) and adds, per instance: the assignment fence tuple (T4), the desired generation being realized, the realization handle needed to reattach or stop (process identity, QMP socket, tap and volumes), the last confirmed observation, the queue of unpublished observations, and local safety actions taken during a partition (ADR-0007 S2, S4).
|
||||
Keeping `vms` makes down-conversion to v1 a removal of the added members.
|
||||
|
||||
**J2. Version.**
|
||||
Journal v2 is `schema_version: 2`.
|
||||
An R0 daemon then refuses to boot and an R0 `vpcd` fails its local lookup (E10); that refusal is the intended behaviour for an unplanned downgrade, because keeping version 1 with extra members would be silently dropped on the next R0 rewrite (E11).
|
||||
|
||||
**J3. Tolerant reader.**
|
||||
R1 daemon and R1 `vpcd` read v1 and v2.
|
||||
A node writes v2 only after it has observed `ACTIVE` and writes v1 otherwise, so the file version follows the activation state and never precedes it.
|
||||
|
||||
**J4. Down-conversion.**
|
||||
During `DEACTIVATING`, each R1 daemon rewrites its journal as v1 after its unpublished observations are published or explicitly abandoned, and reports completion; deactivation waits for every node (G5).
|
||||
|
||||
## 7. KV-unavailable disposition (K-series)
|
||||
|
||||
**K1.** Observation `state` includes `unconfirmed` (journal says assigned and was running; no live process confirmed yet) and `degraded` (process confirmed, publication or a dependency failing).
|
||||
No R1 path sets `running` without `evidence` of a confirmed live process.
|
||||
|
||||
**K2.** With KV unavailable at boot, the daemon launches, recreates and migrates nothing (current behaviour, E12, kept), and may reattach only to a live process that matches a journal v2 entry carrying a fence tuple.
|
||||
A journal v1 entry has no fence, so reattachment needs format 2 to be active; until then E12 stands unchanged.
|
||||
|
||||
**K3.** On reconnection, the node publishes an observation only if the canonical fence tuple equals the journal's; otherwise it quarantines or tears down per ADR-0007 S5 and writes recovered evidence (W4).
|
||||
|
||||
**K4.** AWS keeps a host-impaired instance in state `running` and reports it through status checks; the AWS-visible projection of `unconfirmed` is therefore a status-check result, not a new instance state (decided by K5).
|
||||
|
||||
**K5. Unconfirmed keeps the lifecycle state (D4).**
|
||||
An `unconfirmed` or `degraded` instance keeps its legacy lifecycle state, for example `running`, in `status.status` and in `DescribeInstances`.
|
||||
The condition is surfaced through the instance or system status check on the `DescribeInstanceStatus` surface, matching AWS, which keeps a host-impaired instance `running` and reports the impairment through status checks.
|
||||
K1 still holds for the canonical observation: `observation.state` is never `running` without live-process evidence; only the lifecycle projection keeps the legacy value.
|
||||
|
||||
## 8. Slice 1b sequence
|
||||
|
||||
| Step | Delivers | Acceptance tests | ADR-0007 evidence / plan item | Slice-0 tests it flips |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Record codec: `format` marker, `assignment` and `observation` sections, top-level member preservation; no writer emits format 2 | Unknown top-level member survives every writer path; format 3 refused without change; absent reads as 1 | 8 / 7 | `TestCurrentBehaviour_TypedRecordRoundTripDropsUnknownFields` (top level only; nested stays lost by P2) |
|
||||
| 2 | W2 for existing writers: `WriteRunningSet`, stopped and terminated writers and `mutateInstance` preserve identity, generations and members | Every matrix row reads Preserved; a node write does not move `metadata.generation` | 8 / 2, 7 | `TestCurrentBehaviour_GenerationAcrossKVWriters` "Lost" rows; `TestCurrentBehaviour_RecordKeyAndWireForm` if the wire bytes gain members |
|
||||
| 3 | G8 write-time format check inside CAS | Concurrent raise between read and write loses the CAS; format above ceiling refused | 8 / 7 | none |
|
||||
| 4 | Open hooks: understood ceiling, owner-only migrations, check-only hooks in `vpcd`, gateway and network reconcile (G7, G9) | Each process refuses a stamp above its ceiling; `vpcd` runs no migration | 8, 10 / 7 | `TestCurrentBehaviour_BucketVersionGate` (reader without a hook) |
|
||||
| 5 | Capability report, probe fields and heartbeat marker for `daemon` and `vpcd` (G2, G15, G18) | R0 heartbeat recognised as legacy; restart changes incarnation | 8 / 7 | none |
|
||||
| 6 | Activation record and `spx admin` status, prepare, activate, deactivate (G4 to G6, G10) | Refusal for an R0 slot, stale slot, absent slot, unexpected node, restart after prepare; CAS race; same-token retry; unreadable state fails closed | 8 / 7 | none |
|
||||
| 7 | Dual write under `ACTIVE`: format 2 with assignment generation, epoch and token minted on every assignment change, legacy mirror kept | Assignment generation monotonic across claim, release, start; N-1 reader tests: `awsgw` instance cache (INV-17), quota counts (INV-18), network reconcile (INV-15), DNS reconcile (INV-11) and the ENI orphan reaper (INV-12), each at R0 code, consume a format-2 record through the mirrored legacy fields with the same result as for format 1 | 4, 8 / 2, 7 | none |
|
||||
| 8 | Journal v2 reader in daemon and `vpcd`, v2 writer under `ACTIVE`, down-conversion (J1 to J4) | v1 and v2 both read; v2 only after `ACTIVE`; deactivation returns v1 | 2, 8 / 7 | `TestCurrentBehaviour_LocalStateSchemaVersionFailures` (version 2 becomes valid), `TestCurrentBehaviour_LocalStateFileExactBytes` under `ACTIVE` only |
|
||||
| 9 | Upgrade and rollback evidence: both rollout orders; activation refused until the last slot; rollback after activation; R0 restart refused after activation | ADR-0006 S8 items 1, 5, 6 | 7, 8 / 7 | none |
|
||||
|
||||
Not in slice 1b: fence enforcement in realization and observation (slice 3), `unconfirmed` and reattachment (slice 5, flips `TestCurrentBehaviour_RestoreWithKVUnavailableLeavesLocalUnlaunched`), missing-key and terminated-bucket recovery (slice 5, flips `TestCurrentBehaviour_RestoreMissingCanonicalKeyRepublishesFromLocal` and `TestCurrentBehaviour_RestoreIgnoresTerminatedBucket`), and the `vpcd` projection (slice 6).
|
||||
ADR-0007 evidence items 1, 3, 5, 6, 9 and plan items 1, 3 to 6 and 8 belong to those slices.
|
||||
|
||||
## 9. Decisions
|
||||
|
||||
The user approved this design and decided the former open questions as follows.
|
||||
|
||||
| # | Decision | Recorded in |
|
||||
|---|---|---|
|
||||
| D1 | One platform-internal capability report and probe model shared with the EKS design; activation records stay per contract; no generic release manager; the instance gate does not depend on EKS code. | G18; G2, G14 |
|
||||
| D2 | Raise the instance-state bucket stamps at `ACTIVE`, after the proof and the activation CAS; the stamp is a restart barrier for R0, not the proof and not a fence; a deliberate departure from the EKS invariant. | G16; G7 |
|
||||
| D3 | An incompatible writer freezes only the instance-state contract, Region-wide; reads and unrelated services continue; guests keep running unless local safety requires fencing. | G17; G11 |
|
||||
| D4 | `unconfirmed` and `degraded` keep the legacy lifecycle state and are surfaced through status checks. | K5; K4 |
|
||||
| D5 | Activation participants are the daemon and `vpcd`; `awsgw`, its cache, quota reconciliation and the CLI are read-only non-participants covered by N-1 reader tests. | G15; G1, G2, G6, G9, Section 8 steps 5 and 7 |
|
||||
|
||||
## 10. Slice 1b step 1: record codec (C-series)
|
||||
|
||||
Implemented at `e62f921d2` and moved at `8db9771fb` to `spinifex/domains/ec2/instance/state/record_codec.go`, with tests in `record_codec_test.go` beside it.
|
||||
`domains/ec2/instance/state` is the eventual owner of the persisted record and its evolution (T1); `vm.InstanceRecord`, `vm.InstanceSpec` and `vm.InstanceStatus` stay in `runtime/compute/vm` as the temporary legacy types the codec wraps.
|
||||
That import is recorded dependency-rule debt in `docs/PACKAGE_BOUNDARY_MIGRATION.md`, cleared when those types move into `domains/ec2/instance/state`.
|
||||
|
||||
**C1. Codec surface.**
|
||||
`DecodeTransitionRecord` returns a `TransitionRecord` holding the typed `Record` (`vm.InstanceRecord`), the format-2 `Assignment` (T4) and `Observation` (T5), the decoded input and a snapshot of the typed fields as decoded.
|
||||
`Encode` writes the record back; `Format` and `ReadOnly` report the marker and the read-only state.
|
||||
`vm.InstanceRecord` is unchanged, so every existing writer encodes the same bytes; `TestCurrentBehaviour_RecordKeyAndWireForm` passes with its literal unchanged.
|
||||
No production reader or writer calls the codec; it is exercised by tests only.
|
||||
|
||||
**C2. Preservation at every level.**
|
||||
`Encode` re-encodes the typed fields, compares them path by path with the decode-time snapshot and applies only the differing paths onto the decoded input.
|
||||
Unknown members are kept at every object level: top level, inside `metadata`, `spec`, `status`, `assignment` and `observation`, inside nested structs, and inside the AWS SDK projections.
|
||||
A known field the writer did not change keeps the value it was read with, including a form the typed encoder would rewrite (a `time.Time` with trailing zero nanoseconds, an integer map key with a leading zero).
|
||||
A field the writer changed takes the writer's value; a field the writer zeroed under `omitempty` or `omitzero` is removed.
|
||||
Writing back the value a field already had is indistinguishable from not changing it, and keeps the read form.
|
||||
Numbers are kept as their literal text, so integers beyond 2^53 and unknown decimal members do not lose precision.
|
||||
A typed map merges per entry; a key the typed decode normalised is written in the typed form only when that map changed.
|
||||
|
||||
**C3. Changed arrays.**
|
||||
An array whose typed value is unchanged is written back as read, including unknown members inside its elements.
|
||||
An array the writer changed is replaced whole by the writer's value only when no element of the array as read carries an unknown member at any depth.
|
||||
Otherwise `Encode` refuses with `ErrRecordExtensionConflict`, naming the array's path and the first unknown member's path, so a changed array with element extensions is never silently dropped (`c617914a1`).
|
||||
Arrays inside unknown members are never inspected and are always retained.
|
||||
No keyed merge is attempted; a format change that needs element-level preservation in a changing array must raise `format` (T2).
|
||||
C8 generalises this refusal to every removal or replacement.
|
||||
|
||||
**C4. Format marker handling.**
|
||||
Absent decodes as format 1 and is never added on write; an explicit `1` or `2` is written back as read.
|
||||
Format 2 decodes the legacy fields and both sections, and every mirrored legacy field (`status.last_node`, `status.az`, `status.status`, `metadata.observed_generation`) is retained.
|
||||
An integer above 2 decodes the legacy fields only, sets `ReadOnly`, and `Encode` refuses it with `ErrRecordFormatAhead`, as T2 requires.
|
||||
A non-integer marker (a fraction, `2.0`, an exponent, a string, `null`, an object) is refused on decode with `ErrRecordFormatNotInteger`.
|
||||
Zero and negative markers are refused on decode with `ErrRecordFormatOutOfRange`; T2 does not name this case and C4 decides it.
|
||||
`Encode` refuses `Assignment` or `Observation` on a record that is not format 2 (`ErrRecordSectionsNeedFormat`); in a format-1 record, members named `assignment` or `observation` are unknown members and are kept as such.
|
||||
No writer raises a format in this step; that remains G8.
|
||||
|
||||
**C5. Rejection classes.**
|
||||
Each is a distinct sentinel matched with `errors.Is`.
|
||||
|
||||
| Sentinel | Refused input |
|
||||
|---|---|
|
||||
| `ErrRecordMalformed` | Invalid JSON, empty input, invalid UTF-8, a known field of the wrong type |
|
||||
| `ErrRecordNotObject` | A top-level value that is not an object |
|
||||
| `ErrRecordTrailingData` | Anything after the top-level value |
|
||||
| `ErrRecordDuplicateKey` | A repeated key in any object at any depth, including unknown members and array elements |
|
||||
| `ErrRecordCaseAliasKey` | A key that is not a known name but equals one under Unicode case folding at the same level, which `encoding/json` would otherwise decode into the known field |
|
||||
| `ErrRecordFormatNotInteger`, `ErrRecordFormatOutOfRange` | An invalid marker (C4) |
|
||||
|
||||
`Encode` has its own refusals: `ErrRecordFormatAhead` (C4), `ErrRecordSectionsNeedFormat` (C4) and `ErrRecordExtensionConflict` (C3, C8), whose message is `changed <array|object|map|value> <container path> would drop unknown member <member path>`.
|
||||
|
||||
Duplicates and case aliases are detected by a `json.Decoder` token walk against a schema reflected from the typed shape, because `encoding/json` keeps the last duplicate and matches names case-insensitively.
|
||||
Two unknown members that differ only in case from each other are both kept.
|
||||
|
||||
**C6. Capable writers only.**
|
||||
The codec protects writers built with it (R1 and later, the N-1 expand release onward).
|
||||
It does not make an R0 binary safe: R0 decodes into the typed record and drops every unknown member (E1), and `TestCurrentBehaviour_TypedRecordRoundTripDropsUnknownFields` still passes because the production typed path is unchanged.
|
||||
Excluding R0 writers remains the activation gate's job (P3, G3, G16).
|
||||
`TestTransitionRecord_CapableWriterPreservesWhatPlainTypedRoundTripStrips` pins the contrast.
|
||||
Section 8 step 1 lists that slice-0 test as flipped; it does not flip in this step, because no writer uses the codec, and flips only when step 2 routes writers through it.
|
||||
|
||||
**C7. Evidence.**
|
||||
Table-driven tests cover the unmarked v1 round trip, single-path mutation, nested unknown members beside a mutated sibling, raw-form retention against a re-encoding precondition, `omitempty` removal, mutation shadowing, the array cases (unchanged with element extensions, changed with known members only, changed scalars, and the extension conflict at element level and one level deeper), the v2 round trip and section mutation, read-only newer formats, every rejection class and integer precision.
|
||||
`FuzzTransitionRecordRoundTrip`, seeded with the golden inputs, asserts that decode never panics and that an accepted, unmutated record encodes to a semantically equal document; a 120 s run (about 940,000 executions) found no failure, and a 60 s rerun after the conflict check (about 660,000 executions) found none.
|
||||
An unmutated record cannot raise the extension conflict, so the fuzz invariant is unchanged.
|
||||
`architecture/instancestate` lists `spinifex/domains/ec2/instance/state/record_codec.go` as an owner file; its allowlist is unchanged.
|
||||
`architecture/layering` allows the edge to `runtime/compute/vm` against the recorded debt entry for `domains/ec2/instance/state`.
|
||||
|
||||
**C8. Removal of extended values (uniform rule).**
|
||||
A capable writer may not remove or replace a known object, map entry or array when its original value contains unknown structured extension fields.
|
||||
`Encode` then returns `ErrRecordExtensionConflict` with the changed container's path and the first extension's path, and returns no bytes.
|
||||
Removal or replacement stays allowed when the original value has no unknown extensions, which preserves deliberate lifecycle changes while preventing a partial writer from erasing future state it cannot understand.
|
||||
The check runs wherever the merge drops or overwrites a stored value instead of merging into it: a known member deleted under `omitempty` or `omitzero` or set to `null`, a typed map entry removed or stored under a key the typed decode rewrote, a value replaced by another JSON kind, and a changed array (C3).
|
||||
It walks the stored value against its schema at every depth; members inside unknown members are never inspected, and map keys are data, not extensions.
|
||||
Where the merge still recurses member by member, unknown members are kept and nothing conflicts.
|
||||
Implemented at `0d7b4266d`; tests cover object deletion with and without a nested extension, an AWS SDK object replaced by `null`, a format-2 section removed, the array cases, a sibling change inside an extended object, and, through an internal probe type, map-entry removal and rewritten-key replacement with and without extensions and object replacement by `null`.
|
||||
The record has no typed map with object values, so the map-entry rule is exercised only by the probe; no schema-typed object can be replaced by a non-null scalar, because a typed decode of such input fails as malformed.
|
||||
A 60 s fuzz rerun found no failure.
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package state provides EC2-domain readers for persisted instance state.
|
||||
// Today it holds only the vpcd IMDS readers, which wrap the daemon's local-file
|
||||
// reader and instance-record bucket opener unchanged.
|
||||
package state
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
// Package state owns the persisted EC2 instance record and its evolution. It
|
||||
// wraps the legacy vm.InstanceRecord until the spec and status types move here.
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/resource"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
// Record formats carried in the top-level "format" member. An absent member
|
||||
// is RecordFormatLegacy, the shape every record has been written in so far.
|
||||
const (
|
||||
RecordFormatLegacy = 1
|
||||
RecordFormatTransition = 2
|
||||
)
|
||||
|
||||
// recordFormatKey is the top-level member that names a record's format.
|
||||
const recordFormatKey = "format"
|
||||
|
||||
// Decode and encode failures. Each class is a distinct sentinel so a caller
|
||||
// can tell a corrupt record from one written by a newer release.
|
||||
var (
|
||||
ErrRecordMalformed = errors.New("instance record is not valid JSON for its schema")
|
||||
ErrRecordNotObject = errors.New("instance record is not a JSON object")
|
||||
ErrRecordTrailingData = errors.New("instance record has data after its JSON value")
|
||||
ErrRecordDuplicateKey = errors.New("instance record has a duplicate key")
|
||||
ErrRecordCaseAliasKey = errors.New("instance record key differs only in case from a known field")
|
||||
ErrRecordFormatNotInteger = errors.New("instance record format is not an integer")
|
||||
ErrRecordFormatOutOfRange = errors.New("instance record format is below 1")
|
||||
ErrRecordFormatAhead = errors.New("instance record format is newer than this binary can write")
|
||||
ErrRecordSectionsNeedFormat = errors.New("assignment and observation need record format 2")
|
||||
ErrRecordExtensionConflict = errors.New("instance record change would drop an unknown member")
|
||||
)
|
||||
|
||||
// AssignmentReason says why an assignment changed.
|
||||
type AssignmentReason string
|
||||
|
||||
const (
|
||||
AssignmentLaunch AssignmentReason = "launch"
|
||||
AssignmentStart AssignmentReason = "start"
|
||||
AssignmentRecovery AssignmentReason = "recovery"
|
||||
AssignmentRelease AssignmentReason = "release"
|
||||
AssignmentOperator AssignmentReason = "operator"
|
||||
AssignmentMigrated AssignmentReason = "migrated"
|
||||
)
|
||||
|
||||
// InstanceAssignment is the single execution assignment of a format-2
|
||||
// record. A node holds (instance, Epoch, Generation, Token) as its fence.
|
||||
type InstanceAssignment struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
Generation int64 `json:"generation,omitempty"`
|
||||
Epoch int64 `json:"epoch,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ForDesiredGeneration int64 `json:"for_desired_generation,omitempty"`
|
||||
Reason AssignmentReason `json:"reason,omitempty"`
|
||||
StorageFence string `json:"storage_fence,omitempty"`
|
||||
}
|
||||
|
||||
// ObservedState is the canonical state the assigned node reports. It takes
|
||||
// the lifecycle values plus the two below, which have no lifecycle state.
|
||||
type ObservedState string
|
||||
|
||||
const (
|
||||
ObservedUnconfirmed ObservedState = "unconfirmed"
|
||||
ObservedDegraded ObservedState = "degraded"
|
||||
)
|
||||
|
||||
// EvidenceKind names what an observation's evidence was taken from.
|
||||
type EvidenceKind string
|
||||
|
||||
const (
|
||||
EvidenceQMP EvidenceKind = "qmp"
|
||||
EvidenceProcess EvidenceKind = "process"
|
||||
EvidenceJournal EvidenceKind = "journal"
|
||||
EvidenceTeardown EvidenceKind = "teardown"
|
||||
)
|
||||
|
||||
// ObservationEvidence is the proof behind an observation.
|
||||
type ObservationEvidence struct {
|
||||
Kind EvidenceKind `json:"kind,omitempty"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
TakenAt time.Time `json:"taken_at,omitzero"`
|
||||
}
|
||||
|
||||
// InstanceObservation is the assigned node's report in a format-2 record,
|
||||
// tagged with the assignment it was made under.
|
||||
type InstanceObservation struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
AssignmentEpoch int64 `json:"assignment_epoch,omitempty"`
|
||||
AssignmentGeneration int64 `json:"assignment_generation,omitempty"`
|
||||
AssignmentToken string `json:"assignment_token,omitempty"`
|
||||
DesiredGeneration int64 `json:"desired_generation,omitempty"`
|
||||
State ObservedState `json:"state,omitempty"`
|
||||
Evidence *ObservationEvidence `json:"evidence,omitempty"`
|
||||
ReportedAt time.Time `json:"reported_at,omitzero"`
|
||||
}
|
||||
|
||||
// transitionWire is the typed shape of a format-2 record. Its first three
|
||||
// members are vm.InstanceRecord's, so a format-1 record is its prefix.
|
||||
type transitionWire struct {
|
||||
Metadata resource.Metadata `json:"metadata"`
|
||||
Spec vm.InstanceSpec `json:"spec"`
|
||||
Status vm.InstanceStatus `json:"status"`
|
||||
Assignment *InstanceAssignment `json:"assignment,omitempty"`
|
||||
Observation *InstanceObservation `json:"observation,omitempty"`
|
||||
}
|
||||
|
||||
// TransitionRecord is a decoded instance record plus the JSON it came from,
|
||||
// so Encode can write back what the caller did not change as it was read. The
|
||||
// zero value encodes as a new format-1 record built from the typed fields.
|
||||
type TransitionRecord struct {
|
||||
Record vm.InstanceRecord
|
||||
Assignment *InstanceAssignment
|
||||
Observation *InstanceObservation
|
||||
|
||||
format int
|
||||
readOnly bool
|
||||
raw map[string]any // the decoded input, numbers kept as json.Number
|
||||
snapshot map[string]any // the typed fields as decoded, re-encoded
|
||||
}
|
||||
|
||||
// Format is the record's format: 1 when the marker is absent.
|
||||
func (r *TransitionRecord) Format() int {
|
||||
if r.format == 0 {
|
||||
return RecordFormatLegacy
|
||||
}
|
||||
return r.format
|
||||
}
|
||||
|
||||
// ReadOnly reports a record in a format newer than this binary writes. Its
|
||||
// legacy fields are readable; Encode refuses it.
|
||||
func (r *TransitionRecord) ReadOnly() bool { return r.readOnly }
|
||||
|
||||
// DecodeTransitionRecord decodes data, rejecting malformed JSON, duplicate or
|
||||
// case-aliased keys at any depth, trailing data and an invalid format marker.
|
||||
// A format above 2 decodes its legacy fields and is marked read-only.
|
||||
func DecodeTransitionRecord(data []byte) (*TransitionRecord, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("%w: invalid UTF-8", ErrRecordMalformed)
|
||||
}
|
||||
if err := validateRecordTokens(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
var raw map[string]any
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
|
||||
format, err := recordFormat(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &TransitionRecord{format: format, raw: raw}
|
||||
if format > RecordFormatTransition {
|
||||
r.readOnly = true
|
||||
}
|
||||
|
||||
if format == RecordFormatTransition {
|
||||
var w transitionWire
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
r.Record = vm.InstanceRecord{Metadata: w.Metadata, Spec: w.Spec, Status: w.Status}
|
||||
r.Assignment, r.Observation = w.Assignment, w.Observation
|
||||
} else if err := json.Unmarshal(data, &r.Record); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
|
||||
r.snapshot, err = r.typedTree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Encode writes only the typed paths that differ from the decoded snapshot
|
||||
// over the input, keeping unknown members. It refuses to remove or replace a
|
||||
// value whose stored form carries unknown members, and adds no format marker.
|
||||
func (r *TransitionRecord) Encode() ([]byte, error) {
|
||||
if r.readOnly {
|
||||
return nil, fmt.Errorf("%w: format %d", ErrRecordFormatAhead, r.format)
|
||||
}
|
||||
if r.Format() != RecordFormatTransition && (r.Assignment != nil || r.Observation != nil) {
|
||||
return nil, fmt.Errorf("%w: record is format %d", ErrRecordSectionsNeedFormat, r.Format())
|
||||
}
|
||||
current, err := r.typedTree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, snapshot := r.raw, r.snapshot
|
||||
if raw == nil {
|
||||
raw, snapshot = map[string]any{}, map[string]any{}
|
||||
}
|
||||
merged, err := mergeChanged(transitionSchema(), raw, snapshot, current, "$")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
// typedTree re-encodes the typed fields that the record's format covers.
|
||||
func (r *TransitionRecord) typedTree() (map[string]any, error) {
|
||||
var (
|
||||
out []byte
|
||||
err error
|
||||
)
|
||||
if r.Format() == RecordFormatTransition {
|
||||
out, err = json.Marshal(transitionWire{
|
||||
Metadata: r.Record.Metadata, Spec: r.Record.Spec, Status: r.Record.Status,
|
||||
Assignment: r.Assignment, Observation: r.Observation,
|
||||
})
|
||||
} else {
|
||||
out, err = json.Marshal(&r.Record)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode instance record: %w", err)
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(out))
|
||||
dec.UseNumber()
|
||||
var tree map[string]any
|
||||
if err := dec.Decode(&tree); err != nil {
|
||||
return nil, fmt.Errorf("re-read instance record: %w", err)
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// mergeChanged applies the paths where current differs from snapshot onto
|
||||
// raw. A struct merges per member and keeps members it does not know; a map
|
||||
// merges per entry; any other differing value is replaced by current's.
|
||||
func mergeChanged(s *fieldSchema, raw, snapshot, current any, path string) (any, error) {
|
||||
if reflect.DeepEqual(snapshot, current) {
|
||||
return raw, nil
|
||||
}
|
||||
rawObj, rawIsObj := raw.(map[string]any)
|
||||
snapObj, snapIsObj := snapshot.(map[string]any)
|
||||
curObj, curIsObj := current.(map[string]any)
|
||||
if s == nil || (s.fields == nil && !s.isMap) || !rawIsObj || !snapIsObj || !curIsObj {
|
||||
if err := refuseDrop(s, raw, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
out := make(map[string]any, len(rawObj))
|
||||
for _, k := range slices.Sorted(maps.Keys(rawObj)) {
|
||||
_, inSnap := snapObj[k]
|
||||
_, inCur := curObj[k]
|
||||
switch {
|
||||
case !s.isMap && (!inSnap || inCur):
|
||||
out[k] = rawObj[k] // unknown, or known and still present
|
||||
case s.isMap && inSnap && inCur:
|
||||
out[k] = rawObj[k] // a map key the typed decode did not normalise
|
||||
default:
|
||||
// A removed member or entry, or a map key the typed decode rewrote.
|
||||
if err := refuseDrop(childSchema(s, k), rawObj[k], path+"."+k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range slices.Sorted(maps.Keys(curObj)) {
|
||||
curV := curObj[k]
|
||||
snapV, inSnap := snapObj[k]
|
||||
rawV, inRaw := out[k]
|
||||
switch {
|
||||
case inSnap && inRaw:
|
||||
merged, err := mergeChanged(childSchema(s, k), rawV, snapV, curV, path+"."+k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = merged
|
||||
case inSnap && !s.isMap && reflect.DeepEqual(snapV, curV):
|
||||
// A zero member the input left out stays out.
|
||||
default:
|
||||
if inRaw {
|
||||
if err := refuseDrop(childSchema(s, k), rawV, path+"."+k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out[k] = curV
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func childSchema(s *fieldSchema, key string) *fieldSchema {
|
||||
if s.isMap {
|
||||
return s.elem
|
||||
}
|
||||
return s.fields[key]
|
||||
}
|
||||
|
||||
// refuseDrop fails when the raw value about to be removed or replaced holds a
|
||||
// member its schema does not know, so a writer cannot erase what it cannot read.
|
||||
func refuseDrop(s *fieldSchema, raw any, path string) error {
|
||||
at, found := unknownMember(s, raw, path)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
kind := "value"
|
||||
switch raw.(type) {
|
||||
case []any:
|
||||
kind = "array"
|
||||
case map[string]any:
|
||||
kind = "object"
|
||||
if s.isMap {
|
||||
kind = "map"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: changed %s %s would drop unknown member %s", ErrRecordExtensionConflict, kind, path, at)
|
||||
}
|
||||
|
||||
// unknownMember finds the first member of v, at any depth, that schema s does
|
||||
// not know. An opaque value (nil schema) is not inspected.
|
||||
func unknownMember(s *fieldSchema, v any, path string) (string, bool) {
|
||||
if s == nil {
|
||||
return "", false
|
||||
}
|
||||
switch v := v.(type) {
|
||||
case []any:
|
||||
for i, e := range v {
|
||||
if at, found := unknownMember(s.elem, e, path+"["+strconv.Itoa(i)+"]"); found {
|
||||
return at, true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, k := range slices.Sorted(maps.Keys(v)) {
|
||||
child := s.elem
|
||||
if !s.isMap {
|
||||
known, ok := s.fields[k]
|
||||
if !ok {
|
||||
return path + "." + k, true
|
||||
}
|
||||
child = known
|
||||
}
|
||||
if at, found := unknownMember(child, v[k], path+"."+k); found {
|
||||
return at, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// recordFormat reads the marker: absent is 1, an integer above 2 is returned
|
||||
// for read-only handling, anything else is refused.
|
||||
func recordFormat(raw map[string]any) (int, error) {
|
||||
v, ok := raw[recordFormatKey]
|
||||
if !ok {
|
||||
return RecordFormatLegacy, nil
|
||||
}
|
||||
n, ok := v.(json.Number)
|
||||
if !ok || !isIntegerLiteral(n.String()) {
|
||||
return 0, fmt.Errorf("%w: %v", ErrRecordFormatNotInteger, v)
|
||||
}
|
||||
lit := n.String()
|
||||
if strings.HasPrefix(lit, "-") {
|
||||
return 0, fmt.Errorf("%w: %s", ErrRecordFormatOutOfRange, lit)
|
||||
}
|
||||
if len(lit) > 9 {
|
||||
return math.MaxInt32, nil // newer than anything this binary can name
|
||||
}
|
||||
f, err := strconv.Atoi(lit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %w", ErrRecordFormatNotInteger, err)
|
||||
}
|
||||
if f < RecordFormatLegacy {
|
||||
return 0, fmt.Errorf("%w: %d", ErrRecordFormatOutOfRange, f)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// isIntegerLiteral accepts only JSON's integer grammar, so 2.0 and 2e0 are
|
||||
// not integers even though they denote one.
|
||||
func isIntegerLiteral(s string) bool {
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
if s == "" || (len(s) > 1 && s[0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fieldSchema is the set of JSON member names a Go type decodes. A nil
|
||||
// schema is a leaf or an opaque value: nothing below it is checked.
|
||||
type fieldSchema struct {
|
||||
fields map[string]*fieldSchema // struct members by exact JSON name
|
||||
elem *fieldSchema // element of a slice or value of a map
|
||||
isMap bool
|
||||
}
|
||||
|
||||
var (
|
||||
recordSchemaOnce sync.Once
|
||||
recordSchema *fieldSchema
|
||||
)
|
||||
|
||||
// transitionSchema covers every format, so a case alias of a format-2
|
||||
// section name is refused in a format-1 record too.
|
||||
func transitionSchema() *fieldSchema {
|
||||
recordSchemaOnce.Do(func() {
|
||||
recordSchema = schemaOf(reflect.TypeFor[transitionWire](), map[reflect.Type]*fieldSchema{})
|
||||
recordSchema.fields[recordFormatKey] = nil
|
||||
})
|
||||
return recordSchema
|
||||
}
|
||||
|
||||
var (
|
||||
jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]()
|
||||
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
||||
)
|
||||
|
||||
func schemaOf(t reflect.Type, seen map[reflect.Type]*fieldSchema) *fieldSchema {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
pt := reflect.PointerTo(t)
|
||||
if pt.Implements(jsonUnmarshalerType) || pt.Implements(textUnmarshalerType) {
|
||||
return nil
|
||||
}
|
||||
if s, ok := seen[t]; ok {
|
||||
return s
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Slice, reflect.Array, reflect.Map:
|
||||
s := &fieldSchema{isMap: t.Kind() == reflect.Map}
|
||||
seen[t] = s
|
||||
s.elem = schemaOf(t.Elem(), seen)
|
||||
return s
|
||||
case reflect.Struct:
|
||||
s := &fieldSchema{fields: map[string]*fieldSchema{}}
|
||||
seen[t] = s
|
||||
addStructFields(s, t, seen)
|
||||
return s
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// addStructFields follows encoding/json's naming: the tag name, else the Go
|
||||
// name; "-" skips; an untagged embedded struct promotes its members.
|
||||
func addStructFields(s *fieldSchema, t reflect.Type, seen map[reflect.Type]*fieldSchema) {
|
||||
for f := range t.Fields() {
|
||||
tag := f.Tag.Get("json")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if f.Anonymous && name == "" {
|
||||
et := f.Type
|
||||
if et.Kind() == reflect.Pointer {
|
||||
et = et.Elem()
|
||||
}
|
||||
if et.Kind() == reflect.Struct {
|
||||
addStructFields(s, et, seen)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
s.fields[name] = schemaOf(f.Type, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// validateRecordTokens walks data token by token. encoding/json keeps the
|
||||
// last of two duplicate keys and matches keys case-insensitively, so both
|
||||
// would otherwise pass silently.
|
||||
func validateRecordTokens(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fmt.Errorf("%w: starts with %v", ErrRecordNotObject, tok)
|
||||
}
|
||||
if err := walkObject(dec, transitionSchema(), "$"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
return ErrRecordTrailingData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// walkObject consumes an object whose '{' has been read.
|
||||
func walkObject(dec *json.Decoder, s *fieldSchema, path string) error {
|
||||
keys := map[string]bool{}
|
||||
for dec.More() {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
key, ok := tok.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: key %v at %s", ErrRecordMalformed, tok, path)
|
||||
}
|
||||
if keys[key] {
|
||||
return fmt.Errorf("%w: %q at %s", ErrRecordDuplicateKey, key, path)
|
||||
}
|
||||
keys[key] = true
|
||||
|
||||
var child *fieldSchema
|
||||
if s != nil && s.fields != nil {
|
||||
known, exact := s.fields[key]
|
||||
if !exact {
|
||||
for name := range s.fields {
|
||||
if strings.EqualFold(name, key) {
|
||||
return fmt.Errorf("%w: %q aliases %q at %s", ErrRecordCaseAliasKey, key, name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
child = known
|
||||
} else if s != nil {
|
||||
child = s.elem
|
||||
}
|
||||
if err := walkValue(dec, child, path+"."+key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return closeDelim(dec, '}', path)
|
||||
}
|
||||
|
||||
func walkValue(dec *json.Decoder, s *fieldSchema, path string) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
d, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch d {
|
||||
case '{':
|
||||
return walkObject(dec, s, path)
|
||||
case '[':
|
||||
var elem *fieldSchema
|
||||
if s != nil {
|
||||
elem = s.elem
|
||||
}
|
||||
for i := 0; dec.More(); i++ {
|
||||
if err := walkValue(dec, elem, path+"["+strconv.Itoa(i)+"]"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return closeDelim(dec, ']', path)
|
||||
default:
|
||||
return fmt.Errorf("%w: unexpected %v at %s", ErrRecordMalformed, d, path)
|
||||
}
|
||||
}
|
||||
|
||||
func closeDelim(dec *json.Decoder, want json.Delim, path string) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrRecordMalformed, err)
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||||
return fmt.Errorf("%w: expected %v at %s", ErrRecordMalformed, want, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The record has no typed map with object values and no non-omitempty object
|
||||
// pointer, so these shapes exercise the merge rules the record cannot reach.
|
||||
type probeInner struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type probe struct {
|
||||
Entries map[string]probeInner `json:"entries,omitempty"`
|
||||
Numbered map[int]probeInner `json:"numbered,omitempty"`
|
||||
Ptr *probeInner `json:"ptr"`
|
||||
}
|
||||
|
||||
func probeTree(t *testing.T, b []byte) any {
|
||||
t.Helper()
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
require.NoError(t, dec.Decode(&v))
|
||||
return v
|
||||
}
|
||||
|
||||
func mergeProbe(t *testing.T, doc string, mutate func(*probe)) (any, error) {
|
||||
t.Helper()
|
||||
var p probe
|
||||
require.NoError(t, json.Unmarshal([]byte(doc), &p))
|
||||
snap, err := json.Marshal(&p)
|
||||
require.NoError(t, err)
|
||||
mutate(&p)
|
||||
cur, err := json.Marshal(&p)
|
||||
require.NoError(t, err)
|
||||
s := schemaOf(reflect.TypeFor[probe](), map[reflect.Type]*fieldSchema{})
|
||||
return mergeChanged(s, probeTree(t, []byte(doc)), probeTree(t, snap), probeTree(t, cur), "$")
|
||||
}
|
||||
|
||||
func TestMergeChanged_ProbeShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, doc string
|
||||
mutate func(*probe)
|
||||
conflict string // container and member, or empty for success
|
||||
want string
|
||||
}{
|
||||
{"map entry with an unknown member removed",
|
||||
`{"entries":{"a":{"name":"x","future":1},"b":{"name":"y"}},"ptr":null}`,
|
||||
func(p *probe) { delete(p.Entries, "a") },
|
||||
"changed object $.entries.a would drop unknown member $.entries.a.future", ""},
|
||||
{"map entry without unknown members removed",
|
||||
`{"entries":{"a":{"name":"x"},"b":{"name":"y","future":1}},"ptr":null}`,
|
||||
func(p *probe) { delete(p.Entries, "a") },
|
||||
"", `{"entries":{"b":{"name":"y","future":1}},"ptr":null}`},
|
||||
{"map entry changed in place keeps its unknown member",
|
||||
`{"entries":{"a":{"name":"x","future":1}},"ptr":null}`,
|
||||
func(p *probe) { p.Entries["a"] = probeInner{Name: "z"} },
|
||||
"", `{"entries":{"a":{"name":"z","future":1}},"ptr":null}`},
|
||||
{"map entry under a rewritten key replaced",
|
||||
`{"numbered":{"07":{"name":"x","future":1}},"ptr":null}`,
|
||||
func(p *probe) { p.Numbered[8] = probeInner{Name: "y"} },
|
||||
"changed object $.numbered.07 would drop unknown member $.numbered.07.future", ""},
|
||||
{"object with an unknown member replaced by null",
|
||||
`{"ptr":{"name":"x","future":{"deep":true}}}`,
|
||||
func(p *probe) { p.Ptr = nil },
|
||||
"changed object $.ptr would drop unknown member $.ptr.future", ""},
|
||||
{"object without unknown members replaced by null",
|
||||
`{"ptr":{"name":"x"}}`,
|
||||
func(p *probe) { p.Ptr = nil },
|
||||
"", `{"ptr":null}`},
|
||||
{"keys of a map are data, not extensions",
|
||||
`{"entries":{"anything":{"name":"x"}},"ptr":null}`,
|
||||
func(p *probe) { p.Entries = nil },
|
||||
"", `{"ptr":null}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := mergeProbe(t, tc.doc, tc.mutate)
|
||||
if tc.conflict != "" {
|
||||
require.ErrorIs(t, err, ErrRecordExtensionConflict)
|
||||
assert.Contains(t, err.Error(), tc.conflict)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, probeTree(t, []byte(tc.want)), got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
package state_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/state"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/resource"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// goldenV1 is an unmarked record with unknown members at every level, in
|
||||
// array elements and in the AWS SDK projection. terminated_at and
|
||||
// hostfwd_port_map are in forms the typed encoder would rewrite.
|
||||
const goldenV1 = `{
|
||||
"metadata":{"name":"i-0123456789abcdef0","account_id":"111122223333","generation":7,"observed_generation":6,
|
||||
"owner_refs":[{"kind":"cluster","name":"c-1","future_ref":"r-1"}],"tags":{"Name":"web"},
|
||||
"future_meta":{"a":1}},
|
||||
"spec":{"instance_type":"t3.micro","desired_state":"running",
|
||||
"config":{"future_cfg":true},
|
||||
"run_instances_input":{"MaxCount":1,"MinCount":1,"FutureInput":"y"},
|
||||
"future_spec":[1,2]},
|
||||
"status":{"status":"running","last_node":"node-1","az":"us-east-1a",
|
||||
"instance":{"InstanceId":"i-0123456789abcdef0","State":{"Name":"running","FutureState":1}},
|
||||
"terminated_at":"2026-10-01T09:00:00.000000000+10:00",
|
||||
"hostfwd_port_map":{"08080":22},
|
||||
"future_status":"z"},
|
||||
"future_top":{"nested":{"deep":[{"k":1}]}}
|
||||
}`
|
||||
|
||||
// goldenV2 is a format-2 record: both new sections, the legacy mirror kept
|
||||
// consistent with them, and unknown members inside each new section.
|
||||
const goldenV2 = `{
|
||||
"format":2,
|
||||
"metadata":{"name":"i-0123456789abcdef0","account_id":"111122223333","generation":7,"observed_generation":6,"future_meta":"m"},
|
||||
"spec":{"instance_type":"t3.micro","desired_state":"running"},
|
||||
"status":{"status":"running","last_node":"node-1","az":"us-east-1a","future_status":"s"},
|
||||
"assignment":{"node":"node-1","generation":3,"epoch":2,"token":"tok-3","for_desired_generation":7,"reason":"launch",
|
||||
"future_assignment":{"x":1}},
|
||||
"observation":{"node":"node-1","assignment_epoch":2,"assignment_generation":3,"assignment_token":"tok-3",
|
||||
"desired_generation":6,"state":"running",
|
||||
"evidence":{"kind":"qmp","incarnation":"inc-1","taken_at":"2026-10-01T09:00:00Z","future_evidence":true},
|
||||
"reported_at":"2026-10-01T09:00:01Z","future_observation":[{"o":1}]},
|
||||
"future_top":"t"
|
||||
}`
|
||||
|
||||
// canonical decodes JSON into a generic tree with numbers kept as literals,
|
||||
// so two documents compare equal regardless of key order and whitespace.
|
||||
func canonical(t testing.TB, b []byte) any {
|
||||
t.Helper()
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
require.NoError(t, dec.Decode(&v), "decode %s", b)
|
||||
return v
|
||||
}
|
||||
|
||||
// edit returns the canonical form of doc with each dotted path set, or
|
||||
// removed when the value is nil.
|
||||
func edit(t *testing.T, doc string, changes map[string]any) any {
|
||||
t.Helper()
|
||||
tree := canonical(t, []byte(doc))
|
||||
for path, v := range changes {
|
||||
parts := strings.Split(path, ".")
|
||||
obj := tree.(map[string]any)
|
||||
for _, p := range parts[:len(parts)-1] {
|
||||
next, ok := obj[p].(map[string]any)
|
||||
if !ok {
|
||||
next = map[string]any{}
|
||||
obj[p] = next
|
||||
}
|
||||
obj = next
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
if v == nil {
|
||||
delete(obj, last)
|
||||
} else {
|
||||
obj[last] = v
|
||||
}
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
func member(t *testing.T, doc []byte, path ...string) (json.RawMessage, bool) {
|
||||
t.Helper()
|
||||
var cur json.RawMessage = doc
|
||||
for _, p := range path {
|
||||
var obj map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(cur, &obj))
|
||||
next, ok := obj[p]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
func decode(t *testing.T, doc string) *state.TransitionRecord {
|
||||
t.Helper()
|
||||
r, err := state.DecodeTransitionRecord([]byte(doc))
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
func encode(t *testing.T, r *state.TransitionRecord) []byte {
|
||||
t.Helper()
|
||||
out, err := r.Encode()
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTransitionRecord_V1WithoutMarkerRoundTripsAndEmitsNoMarker(t *testing.T) {
|
||||
r := decode(t, goldenV1)
|
||||
assert.Equal(t, state.RecordFormatLegacy, r.Format())
|
||||
assert.False(t, r.ReadOnly())
|
||||
assert.Equal(t, "i-0123456789abcdef0", r.Record.Metadata.Name)
|
||||
assert.Nil(t, r.Assignment)
|
||||
|
||||
out := encode(t, r)
|
||||
assert.Equal(t, canonical(t, []byte(goldenV1)), canonical(t, out))
|
||||
_, has := member(t, out, "format")
|
||||
assert.False(t, has, "a format-1 record read without a marker must not gain one")
|
||||
}
|
||||
|
||||
func TestTransitionRecord_ExplicitFormatOneIsKeptAsWritten(t *testing.T) {
|
||||
doc := `{"format":1,"metadata":{"name":"i-1"},"spec":{},"status":{"status":"running"}}`
|
||||
r := decode(t, doc)
|
||||
assert.Equal(t, state.RecordFormatLegacy, r.Format())
|
||||
r.Record.Status.LastNode = "node-1"
|
||||
assert.Equal(t, edit(t, doc, map[string]any{"status.last_node": "node-1"}), canonical(t, encode(t, r)))
|
||||
}
|
||||
|
||||
// Each mutation must change exactly its path; every unknown member at every
|
||||
// level, and every untouched known member, comes back as it was read.
|
||||
func TestTransitionRecord_MutationChangesOnlyItsPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*state.TransitionRecord)
|
||||
want map[string]any
|
||||
}{
|
||||
{"spec field beside unknown spec members", func(r *state.TransitionRecord) {
|
||||
r.Record.Spec.InstanceType = "t3.large"
|
||||
}, map[string]any{"spec.instance_type": "t3.large"}},
|
||||
{"metadata generation beside unknown metadata", func(r *state.TransitionRecord) {
|
||||
r.Record.Metadata.Generation = 8
|
||||
}, map[string]any{"metadata.generation": json.Number("8")}},
|
||||
{"status field beside raw-form siblings", func(r *state.TransitionRecord) {
|
||||
r.Record.Status.LastNode = "node-2"
|
||||
}, map[string]any{"status.last_node": "node-2"}},
|
||||
{"field the raw config omitted is added alone", func(r *state.TransitionRecord) {
|
||||
r.Record.Spec.Config.CPUCount = 2
|
||||
}, map[string]any{"spec.config.cpu_count": json.Number("2")}},
|
||||
{"field inside the AWS SDK projection", func(r *state.TransitionRecord) {
|
||||
r.Record.Status.Instance.State.Name = aws.String("stopped")
|
||||
}, map[string]any{"status.instance.State.Name": "stopped"}},
|
||||
{"map entry added", func(r *state.TransitionRecord) {
|
||||
r.Record.Metadata.Tags["Env"] = "prod"
|
||||
}, map[string]any{"metadata.tags.Env": "prod"}},
|
||||
{"desired state", func(r *state.TransitionRecord) {
|
||||
r.Record.Spec.DesiredState = vm.DesiredStopped
|
||||
}, map[string]any{"spec.desired_state": string(vm.DesiredStopped)}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := decode(t, goldenV1)
|
||||
tc.mutate(r)
|
||||
assert.Equal(t, edit(t, goldenV1, tc.want), canonical(t, encode(t, r)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// plainDoc carries no unknown member inside any known value, so removing a
|
||||
// known value drops nothing a newer writer added.
|
||||
const plainDoc = `{"format":2,"metadata":{"name":"i-1","owner_refs":[{"kind":"cluster","name":"c-1"}]},` +
|
||||
`"spec":{"run_instances_input":{"MaxCount":1,"Placement":{"AvailabilityZone":"a"}}},` +
|
||||
`"assignment":{"node":"node-1","generation":3},"future":{"keep":[{"x":1}]}}`
|
||||
|
||||
func TestTransitionRecord_ZeroedOmitemptyFieldIsRemoved(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, doc string
|
||||
mutate func(*state.TransitionRecord)
|
||||
gone string
|
||||
}{
|
||||
{"string", goldenV1, func(r *state.TransitionRecord) { r.Record.Status.AZ = "" }, "status.az"},
|
||||
{"map of strings", goldenV1, func(r *state.TransitionRecord) { r.Record.Metadata.Tags = nil }, "metadata.tags"},
|
||||
{"omitzero time", goldenV1, func(r *state.TransitionRecord) { r.Record.Status.TerminatedAt = time.Time{} }, "status.terminated_at"},
|
||||
{"slice", plainDoc, func(r *state.TransitionRecord) { r.Record.Metadata.OwnerRefs = nil }, "metadata.owner_refs"},
|
||||
{"pointer", plainDoc, func(r *state.TransitionRecord) { r.Record.Spec.RunInstancesInput = nil }, "spec.run_instances_input"},
|
||||
{"section", plainDoc, func(r *state.TransitionRecord) { r.Assignment = nil }, "assignment"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := decode(t, tc.doc)
|
||||
tc.mutate(r)
|
||||
assert.Equal(t, edit(t, tc.doc, map[string]any{tc.gone: nil}), canonical(t, encode(t, r)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Removing or replacing a known value whose stored form carries an unknown
|
||||
// member would erase state this writer cannot read, so Encode refuses it.
|
||||
func TestTransitionRecord_RemovalOfExtendedValueIsRefused(t *testing.T) {
|
||||
const extended = `{"metadata":{"name":"i-1"},` +
|
||||
`"spec":{"run_instances_input":{"MaxCount":1,"Placement":{"AvailabilityZone":"a","FuturePlacement":1}}}}`
|
||||
cases := []struct {
|
||||
name, doc, container, member string
|
||||
mutate func(*state.TransitionRecord)
|
||||
}{
|
||||
{"object with a nested unknown member", extended,
|
||||
"changed object $.spec.run_instances_input ", "$.spec.run_instances_input.Placement.FuturePlacement",
|
||||
func(r *state.TransitionRecord) { r.Record.Spec.RunInstancesInput = nil }},
|
||||
{"AWS SDK object replaced by null", extended,
|
||||
"changed object $.spec.run_instances_input.Placement ", "$.spec.run_instances_input.Placement.FuturePlacement",
|
||||
func(r *state.TransitionRecord) { r.Record.Spec.RunInstancesInput.Placement = nil }},
|
||||
{"array with an unknown element member", goldenV1,
|
||||
"changed array $.metadata.owner_refs ", "$.metadata.owner_refs[0].future_ref",
|
||||
func(r *state.TransitionRecord) { r.Record.Metadata.OwnerRefs = nil }},
|
||||
{"format-2 section", goldenV2,
|
||||
"changed object $.assignment ", "$.assignment.future_assignment",
|
||||
func(r *state.TransitionRecord) { r.Assignment = nil }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := decode(t, tc.doc)
|
||||
tc.mutate(r)
|
||||
out, err := r.Encode()
|
||||
require.ErrorIs(t, err, state.ErrRecordExtensionConflict)
|
||||
assert.Contains(t, err.Error(), tc.container)
|
||||
assert.Contains(t, err.Error(), "would drop unknown member "+tc.member)
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("AWS SDK object without unknown members replaced by null", func(t *testing.T) {
|
||||
r := decode(t, plainDoc)
|
||||
r.Record.Spec.RunInstancesInput.Placement = nil
|
||||
placement, _ := member(t, encode(t, r), "spec", "run_instances_input", "Placement")
|
||||
assert.JSONEq(t, `null`, string(placement))
|
||||
})
|
||||
|
||||
t.Run("sibling change inside an extended object keeps the extension", func(t *testing.T) {
|
||||
r := decode(t, extended)
|
||||
r.Record.Spec.RunInstancesInput.Placement.AvailabilityZone = aws.String("b")
|
||||
want := edit(t, extended, map[string]any{"spec.run_instances_input.Placement.AvailabilityZone": "b"})
|
||||
assert.Equal(t, want, canonical(t, encode(t, r)))
|
||||
})
|
||||
}
|
||||
|
||||
// terminated_at and hostfwd_port_map are read in forms that the typed encoder
|
||||
// rewrites, so these cases tell retention of the read value from re-encoding.
|
||||
func TestTransitionRecord_UnchangedKnownFieldKeepsItsRawForm(t *testing.T) {
|
||||
var typed vm.InstanceRecord
|
||||
require.NoError(t, json.Unmarshal([]byte(goldenV1), &typed))
|
||||
plain, err := json.Marshal(&typed)
|
||||
require.NoError(t, err)
|
||||
plainAt, _ := member(t, plain, "status", "terminated_at")
|
||||
plainMap, _ := member(t, plain, "status", "hostfwd_port_map")
|
||||
require.JSONEq(t, `"2026-10-01T09:00:00+10:00"`, string(plainAt), "precondition: re-encoding rewrites the time")
|
||||
require.JSONEq(t, `{"8080":22}`, string(plainMap), "precondition: re-encoding rewrites the map key")
|
||||
|
||||
r := decode(t, goldenV1)
|
||||
r.Record.Status.LastNode = "node-2"
|
||||
out := encode(t, r)
|
||||
at, _ := member(t, out, "status", "terminated_at")
|
||||
ports, _ := member(t, out, "status", "hostfwd_port_map")
|
||||
assert.JSONEq(t, `"2026-10-01T09:00:00.000000000+10:00"`, string(at))
|
||||
assert.JSONEq(t, `{"08080":22}`, string(ports))
|
||||
}
|
||||
|
||||
func TestTransitionRecord_ExplicitMutationShadowsRawForm(t *testing.T) {
|
||||
r := decode(t, goldenV1)
|
||||
r.Record.Status.TerminatedAt = time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)
|
||||
r.Record.Status.HostfwdPortMap = map[int]int{9090: 22}
|
||||
want := edit(t, goldenV1, map[string]any{
|
||||
"status.terminated_at": "2026-10-02T00:00:00Z",
|
||||
"status.hostfwd_port_map": map[string]any{"9090": json.Number("22")},
|
||||
})
|
||||
assert.Equal(t, want, canonical(t, encode(t, r)))
|
||||
}
|
||||
|
||||
// An unchanged array is written back as read, unknown element members and
|
||||
// all. A changed array is replaced whole, unless a read element carries an
|
||||
// unknown member at any depth: then Encode refuses rather than drop it.
|
||||
func TestTransitionRecord_Arrays(t *testing.T) {
|
||||
t.Run("unchanged array keeps unknown element members", func(t *testing.T) {
|
||||
r := decode(t, goldenV1)
|
||||
r.Record.Metadata.Name = "i-renamed"
|
||||
out := encode(t, r)
|
||||
refs, _ := member(t, out, "metadata", "owner_refs")
|
||||
assert.JSONEq(t, `[{"kind":"cluster","name":"c-1","future_ref":"r-1"}]`, string(refs))
|
||||
})
|
||||
t.Run("changed array with known members only is replaced whole", func(t *testing.T) {
|
||||
doc := `{"metadata":{"name":"i-1","owner_refs":[{"kind":"cluster","name":"c-1"}]},"future":[{"x":1}]}`
|
||||
r := decode(t, doc)
|
||||
r.Record.Metadata.OwnerRefs = append(r.Record.Metadata.OwnerRefs, resource.OwnerRef{Kind: "asg", Name: "a-1"})
|
||||
want := edit(t, doc, map[string]any{"metadata.owner_refs": []any{
|
||||
map[string]any{"kind": "cluster", "name": "c-1"},
|
||||
map[string]any{"kind": "asg", "name": "a-1"},
|
||||
}})
|
||||
assert.Equal(t, want, canonical(t, encode(t, r)))
|
||||
})
|
||||
t.Run("changed array of scalars is replaced whole", func(t *testing.T) {
|
||||
doc := `{"metadata":{"name":"i-1","finalizers":["eni","volume"]}}`
|
||||
r := decode(t, doc)
|
||||
r.Record.Metadata.Finalizers = []string{"volume"}
|
||||
want := edit(t, doc, map[string]any{"metadata.finalizers": []any{"volume"}})
|
||||
assert.Equal(t, want, canonical(t, encode(t, r)))
|
||||
})
|
||||
|
||||
conflicts := []struct {
|
||||
name, doc, array, member string
|
||||
mutate func(*state.TransitionRecord)
|
||||
}{
|
||||
{
|
||||
"unknown member in an element",
|
||||
goldenV1,
|
||||
"$.metadata.owner_refs",
|
||||
"$.metadata.owner_refs[0].future_ref",
|
||||
func(r *state.TransitionRecord) {
|
||||
r.Record.Metadata.OwnerRefs = append(r.Record.Metadata.OwnerRefs, resource.OwnerRef{Kind: "asg", Name: "a-1"})
|
||||
},
|
||||
},
|
||||
{
|
||||
"unknown member one level deeper in an element",
|
||||
`{"status":{"instance":{"NetworkInterfaces":[{"NetworkInterfaceId":"eni-1",` +
|
||||
`"Attachment":{"DeviceIndex":0,"FutureAttach":"x"}}]}}}`,
|
||||
"$.status.instance.NetworkInterfaces",
|
||||
"$.status.instance.NetworkInterfaces[0].Attachment.FutureAttach",
|
||||
func(r *state.TransitionRecord) {
|
||||
r.Record.Status.Instance.NetworkInterfaces[0].NetworkInterfaceId = aws.String("eni-2")
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range conflicts {
|
||||
t.Run("changed array refused: "+tc.name, func(t *testing.T) {
|
||||
r := decode(t, tc.doc)
|
||||
tc.mutate(r)
|
||||
out, err := r.Encode()
|
||||
require.ErrorIs(t, err, state.ErrRecordExtensionConflict)
|
||||
assert.Contains(t, err.Error(), "changed array "+tc.array+" ")
|
||||
assert.Contains(t, err.Error(), tc.member)
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRecord_V2DecodesSectionsAndKeepsLegacyMirror(t *testing.T) {
|
||||
r := decode(t, goldenV2)
|
||||
assert.Equal(t, state.RecordFormatTransition, r.Format())
|
||||
assert.False(t, r.ReadOnly())
|
||||
assert.Equal(t, &state.InstanceAssignment{
|
||||
Node: "node-1", Generation: 3, Epoch: 2, Token: "tok-3", ForDesiredGeneration: 7, Reason: state.AssignmentLaunch,
|
||||
}, r.Assignment)
|
||||
require.NotNil(t, r.Observation)
|
||||
assert.Equal(t, int64(6), r.Observation.DesiredGeneration)
|
||||
require.NotNil(t, r.Observation.Evidence)
|
||||
assert.Equal(t, state.EvidenceQMP, r.Observation.Evidence.Kind)
|
||||
|
||||
// The legacy mirror an R0 reader still consumes.
|
||||
assert.Equal(t, "node-1", r.Record.Status.LastNode)
|
||||
assert.Equal(t, "us-east-1a", r.Record.Status.AZ)
|
||||
assert.Equal(t, vm.StateRunning, r.Record.Status.Status)
|
||||
assert.Equal(t, int64(6), r.Record.Metadata.ObservedGeneration)
|
||||
|
||||
out := encode(t, r)
|
||||
assert.Equal(t, canonical(t, []byte(goldenV2)), canonical(t, out))
|
||||
format, _ := member(t, out, "format")
|
||||
assert.JSONEq(t, `2`, string(format))
|
||||
}
|
||||
|
||||
func TestTransitionRecord_V2SectionMutationKeepsNestedUnknownMembers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*state.TransitionRecord)
|
||||
want map[string]any
|
||||
}{
|
||||
{"assignment", func(r *state.TransitionRecord) {
|
||||
r.Assignment.Generation = 4
|
||||
r.Assignment.Token = "tok-4"
|
||||
}, map[string]any{"assignment.generation": json.Number("4"), "assignment.token": "tok-4"}},
|
||||
{"observation evidence", func(r *state.TransitionRecord) {
|
||||
r.Observation.Evidence.Incarnation = "inc-2"
|
||||
}, map[string]any{"observation.evidence.incarnation": "inc-2"}},
|
||||
{"observation state", func(r *state.TransitionRecord) {
|
||||
r.Observation.State = state.ObservedUnconfirmed
|
||||
}, map[string]any{"observation.state": "unconfirmed"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := decode(t, goldenV2)
|
||||
tc.mutate(r)
|
||||
assert.Equal(t, edit(t, goldenV2, tc.want), canonical(t, encode(t, r)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRecord_V1RefusesNewSections(t *testing.T) {
|
||||
r := decode(t, goldenV1)
|
||||
r.Assignment = &state.InstanceAssignment{Node: "node-1"}
|
||||
_, err := r.Encode()
|
||||
require.ErrorIs(t, err, state.ErrRecordSectionsNeedFormat)
|
||||
}
|
||||
|
||||
// A format-1 record has no typed sections, so a member named like one is an
|
||||
// unknown member and survives as such.
|
||||
func TestTransitionRecord_V1KeepsSectionNamedMemberAsUnknown(t *testing.T) {
|
||||
doc := `{"metadata":{"name":"i-1"},"spec":{},"status":{},"assignment":{"node":"node-1","generation":3}}`
|
||||
r := decode(t, doc)
|
||||
assert.Nil(t, r.Assignment)
|
||||
r.Record.Status.LastNode = "node-2"
|
||||
assert.Equal(t, edit(t, doc, map[string]any{"status.last_node": "node-2"}), canonical(t, encode(t, r)))
|
||||
}
|
||||
|
||||
func TestTransitionRecord_NewerFormatIsReadOnly(t *testing.T) {
|
||||
for _, marker := range []string{"3", "99999999999999999999"} {
|
||||
t.Run(marker, func(t *testing.T) {
|
||||
doc := `{"format":` + marker + `,"metadata":{"name":"i-1"},"spec":{},"status":{"status":"running","last_node":"node-1"},` +
|
||||
`"assignment":{"node":"node-1"}}`
|
||||
r, err := state.DecodeTransitionRecord([]byte(doc))
|
||||
require.NoError(t, err, "a newer format is readable")
|
||||
assert.True(t, r.ReadOnly())
|
||||
assert.Greater(t, r.Format(), state.RecordFormatTransition)
|
||||
assert.Equal(t, "node-1", r.Record.Status.LastNode, "legacy fields are decoded")
|
||||
assert.Nil(t, r.Assignment, "sections of a newer format are not interpreted")
|
||||
|
||||
out, err := r.Encode()
|
||||
require.ErrorIs(t, err, state.ErrRecordFormatAhead)
|
||||
assert.Nil(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRecord_Rejections(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
doc string
|
||||
want error
|
||||
}{
|
||||
{"truncated", `{"metadata":`, state.ErrRecordMalformed},
|
||||
{"trailing comma", `{"metadata":{},}`, state.ErrRecordMalformed},
|
||||
{"empty input", ``, state.ErrRecordMalformed},
|
||||
{"invalid UTF-8", "{\"metadata\":{\"name\":\"\xff\"}}", state.ErrRecordMalformed},
|
||||
{"known field of the wrong type", `{"metadata":{"generation":"7"}}`, state.ErrRecordMalformed},
|
||||
{"array top level", `[]`, state.ErrRecordNotObject},
|
||||
{"string top level", `"i-1"`, state.ErrRecordNotObject},
|
||||
{"null top level", `null`, state.ErrRecordNotObject},
|
||||
{"second value", `{} {}`, state.ErrRecordTrailingData},
|
||||
{"trailing garbage", `{}x`, state.ErrRecordTrailingData},
|
||||
{"top-level duplicate", `{"spec":{},"spec":{}}`, state.ErrRecordDuplicateKey},
|
||||
{"nested known duplicate", `{"status":{"instance":{"InstanceId":"a","InstanceId":"b"}}}`, state.ErrRecordDuplicateKey},
|
||||
{"duplicate inside unknown member", `{"future":{"x":1,"x":2}}`, state.ErrRecordDuplicateKey},
|
||||
{"duplicate inside array element", `{"metadata":{"owner_refs":[{"kind":"a","kind":"b"}]}}`, state.ErrRecordDuplicateKey},
|
||||
{"duplicate format marker", `{"format":1,"format":2}`, state.ErrRecordDuplicateKey},
|
||||
{"top-level case alias", `{"Metadata":{}}`, state.ErrRecordCaseAliasKey},
|
||||
{"format marker case alias", `{"Format":2}`, state.ErrRecordCaseAliasKey},
|
||||
{"nested case alias", `{"spec":{"Instance_Type":"t3.micro"}}`, state.ErrRecordCaseAliasKey},
|
||||
{"case alias in AWS SDK struct", `{"status":{"instance":{"instanceId":"i-1"}}}`, state.ErrRecordCaseAliasKey},
|
||||
{"case alias in array element", `{"metadata":{"owner_refs":[{"Kind":"a"}]}}`, state.ErrRecordCaseAliasKey},
|
||||
{"Unicode fold alias", `{"ſpec":{}}`, state.ErrRecordCaseAliasKey},
|
||||
{"format zero", `{"format":0}`, state.ErrRecordFormatOutOfRange},
|
||||
{"format negative", `{"format":-1}`, state.ErrRecordFormatOutOfRange},
|
||||
{"format negative zero", `{"format":-0}`, state.ErrRecordFormatOutOfRange},
|
||||
{"format fraction", `{"format":1.5}`, state.ErrRecordFormatNotInteger},
|
||||
{"format with a fraction part", `{"format":2.0}`, state.ErrRecordFormatNotInteger},
|
||||
{"format exponent", `{"format":2e0}`, state.ErrRecordFormatNotInteger},
|
||||
{"format string", `{"format":"2"}`, state.ErrRecordFormatNotInteger},
|
||||
{"format null", `{"format":null}`, state.ErrRecordFormatNotInteger},
|
||||
{"format object", `{"format":{"v":2}}`, state.ErrRecordFormatNotInteger},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := state.DecodeTransitionRecord([]byte(tc.doc))
|
||||
require.ErrorIs(t, err, tc.want)
|
||||
assert.Nil(t, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown members whose names differ only in case from each other are both
|
||||
// kept: neither aliases a typed field.
|
||||
func TestTransitionRecord_UnknownMembersDifferingInCaseAreKept(t *testing.T) {
|
||||
doc := `{"metadata":{"name":"i-1"},"future":1,"Future":2}`
|
||||
assert.Equal(t, canonical(t, []byte(doc)), canonical(t, encode(t, decode(t, doc))))
|
||||
}
|
||||
|
||||
func TestTransitionRecord_LargeIntegersKeepPrecision(t *testing.T) {
|
||||
const big = 9007199254740993 // 2^53+1, not representable as float64
|
||||
doc := `{"metadata":{"name":"i-1","generation":9007199254740993},"future":123456789012345678901234567890.25}`
|
||||
|
||||
r := decode(t, doc)
|
||||
assert.Equal(t, int64(big), r.Record.Metadata.Generation)
|
||||
out := encode(t, r)
|
||||
assert.Equal(t, canonical(t, []byte(doc)), canonical(t, out))
|
||||
future, _ := member(t, out, "future")
|
||||
assert.Equal(t, "123456789012345678901234567890.25", string(future))
|
||||
|
||||
r.Record.Metadata.Generation = big + 2
|
||||
gen, _ := member(t, encode(t, r), "metadata", "generation")
|
||||
assert.Equal(t, "9007199254740995", string(gen))
|
||||
}
|
||||
|
||||
func TestTransitionRecord_ZeroValueEncodesNewFormatOneRecord(t *testing.T) {
|
||||
r := &state.TransitionRecord{}
|
||||
r.Record.Metadata.Name = "i-1"
|
||||
r.Record.Status.Status = vm.StateRunning
|
||||
plain, err := json.Marshal(&r.Record)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := encode(t, r)
|
||||
assert.Equal(t, canonical(t, plain), canonical(t, out))
|
||||
_, has := member(t, out, "format")
|
||||
assert.False(t, has)
|
||||
}
|
||||
|
||||
// The codec protects capable writers only. A binary already deployed decodes
|
||||
// into the typed record and drops what it does not know, which is why the
|
||||
// activation gate, not this codec, must exclude such binaries from writing.
|
||||
func TestTransitionRecord_CapableWriterPreservesWhatPlainTypedRoundTripStrips(t *testing.T) {
|
||||
newer := []byte(`{"metadata":{"name":"i-1","generation":4,"assignment_fence":"f-9"},` +
|
||||
`"spec":{"instance_type":"t3.micro","desired_generation":4},` +
|
||||
`"status":{"status":"running","last_node":"node-1","observed_assignment":3},` +
|
||||
`"assignment":{"node":"node-1","generation":3}}`)
|
||||
|
||||
var typed vm.InstanceRecord
|
||||
require.NoError(t, json.Unmarshal(newer, &typed))
|
||||
stripped, err := json.Marshal(&typed)
|
||||
require.NoError(t, err)
|
||||
_, has := member(t, stripped, "metadata", "assignment_fence")
|
||||
assert.False(t, has, "plain encoding/json typed round trip strips unknown members")
|
||||
|
||||
r, err := state.DecodeTransitionRecord(newer)
|
||||
require.NoError(t, err)
|
||||
r.Record.Status.LastNode = "node-2"
|
||||
kept := encode(t, r)
|
||||
for _, path := range [][]string{
|
||||
{"metadata", "assignment_fence"},
|
||||
{"spec", "desired_generation"},
|
||||
{"status", "observed_assignment"},
|
||||
{"assignment"},
|
||||
} {
|
||||
_, has := member(t, kept, path...)
|
||||
assert.True(t, has, "codec round trip keeps %v", path)
|
||||
}
|
||||
}
|
||||
|
||||
// An unmutated record cannot raise an extension conflict: that needs a
|
||||
// changed array, so every accepted, writable input must round-trip.
|
||||
func FuzzTransitionRecordRoundTrip(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
goldenV1, goldenV2,
|
||||
`{}`, `{"format":3}`, `{"format":1}`,
|
||||
`{"metadata":{"generation":9007199254740993}}`,
|
||||
`{"spec":{},"spec":{}}`, `{"Metadata":{}}`, `{"format":1.5}`, `{} {}`, `[]`,
|
||||
} {
|
||||
f.Add([]byte(seed))
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
r, err := state.DecodeTransitionRecord(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out, err := r.Encode()
|
||||
if r.ReadOnly() {
|
||||
require.ErrorIs(t, err, state.ErrRecordFormatAhead)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical(t, data), canonical(t, out))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user