7bf37937
release: one Feature shape for every version floor
a73x 2026-09-05 17:43
Commit message
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -238,7 +238,8 @@ | |||
| 238 | "imports": [ | 238 | "imports": [ |
| 239 | "internal/agent/seed", | 239 | "internal/agent/seed", |
| 240 | "internal/agent/state", | 240 | "internal/agent/state", |
| 241 | "internal/pb" | 241 | "internal/pb", |
| 242 | "internal/version" | ||
| 242 | ] | 243 | ] |
| 243 | }, | 244 | }, |
| 244 | { | 245 | { |
| @@ -261,7 +262,8 @@ | |||
| 261 | "internal/agent/syncclient", | 262 | "internal/agent/syncclient", |
| 262 | "internal/covsnap", | 263 | "internal/covsnap", |
| 263 | "internal/joinblob", | 264 | "internal/joinblob", |
| 264 | "internal/names" | 265 | "internal/names", |
| 266 | "internal/version" | ||
| 265 | ] | 267 | ] |
| 266 | }, | 268 | }, |
| 267 | { | 269 | { |
| @@ -535,9 +537,10 @@ | |||
| 535 | { | 537 | { |
| 536 | "importPath": "internal/server/release", | 538 | "importPath": "internal/server/release", |
| 537 | "plane": "control", | 539 | "plane": "control", |
| 538 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", | 540 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and names the agent floors the control plane admits against.", |
| 539 | "imports": [ | 541 | "imports": [ |
| 540 | "internal/relmanifest" | 542 | "internal/relmanifest", |
| 543 | "internal/version" | ||
| 541 | ] | 544 | ] |
| 542 | }, | 545 | }, |
| 543 | { | 546 | { |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -187,7 +187,8 @@ | |||
| 187 | "imports": [ | 187 | "imports": [ |
| 188 | "internal/agent/seed", | 188 | "internal/agent/seed", |
| 189 | "internal/agent/state", | 189 | "internal/agent/state", |
| 190 | "internal/pb" | 190 | "internal/pb", |
| 191 | "internal/version" | ||
| 191 | ] | 192 | ] |
| 192 | }, | 193 | }, |
| 193 | { | 194 | { |
| @@ -210,7 +211,8 @@ | |||
| 210 | "internal/agent/syncclient", | 211 | "internal/agent/syncclient", |
| 211 | "internal/covsnap", | 212 | "internal/covsnap", |
| 212 | "internal/joinblob", | 213 | "internal/joinblob", |
| 213 | "internal/names" | 214 | "internal/names", |
| 215 | "internal/version" | ||
| 214 | ] | 216 | ] |
| 215 | }, | 217 | }, |
| 216 | { | 218 | { |
| @@ -484,9 +486,10 @@ | |||
| 484 | { | 486 | { |
| 485 | "importPath": "internal/server/release", | 487 | "importPath": "internal/server/release", |
| 486 | "plane": "control", | 488 | "plane": "control", |
| 487 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", | 489 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and names the agent floors the control plane admits against.", |
| 488 | "imports": [ | 490 | "imports": [ |
| 489 | "internal/relmanifest" | 491 | "internal/relmanifest", |
| 492 | "internal/version" | ||
| 490 | ] | 493 | ] |
| 491 | }, | 494 | }, |
| 492 | { | 495 | { |
internal/agent/reconcile/minversion_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,84 @@ | |||
| 1 | package reconcile | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // An agent below the snapshot's floor must not materialise anything from it: | ||
| 11 | // every live VM reports failed with a reason naming both versions, nothing is | ||
| 12 | // dispatched, and the epoch is still accepted so the report is not fenced. | ||
| 13 | func TestStepRefusesSnapshotAboveAgentVersion(t *testing.T) { | ||
| 14 | f := setup(t) | ||
| 15 | f.eng.AgentVersion = "v0.0.6" | ||
| 16 | s := snap(1, vm("vm1")) | ||
| 17 | s.MinAgentVersion = "v0.0.7" | ||
| 18 | |||
| 19 | rep := f.eng.Step(context.Background(), s) | ||
| 20 | |||
| 21 | // Settle any worker the guard failed to prevent, so the assertion below | ||
| 22 | // reads a finished tick rather than racing one. | ||
| 23 | f.eng.manager().waitIdle() | ||
| 24 | |||
| 25 | require.False(t, rep.FenceViolation) | ||
| 26 | require.EqualValues(t, 1, rep.LastSeenEpoch) | ||
| 27 | require.Len(t, rep.Vms, 1) | ||
| 28 | require.Equal(t, "failed", rep.Vms[0].Phase) | ||
| 29 | require.Contains(t, rep.Vms[0].LastError, "v0.0.7") | ||
| 30 | require.Contains(t, rep.Vms[0].LastError, "v0.0.6") | ||
| 31 | require.Empty(t, f.prov.prepared, "nothing is materialised from a snapshot the agent cannot read") | ||
| 32 | } | ||
| 33 | |||
| 34 | // The refusal asserts nothing it did not look at. It runs before any record is | ||
| 35 | // read, so it cannot know whether a guest is running — and the control plane | ||
| 36 | // PERSISTS what a report claims. An empty power state is the wire's "no | ||
| 37 | // observation"; "stopped" would be a fleet-wide lie about power, showing an | ||
| 38 | // operator healthy VMs as dark because their host is one release behind. | ||
| 39 | func TestRefusedSnapshotClaimsNoPowerState(t *testing.T) { | ||
| 40 | f := setup(t) | ||
| 41 | f.eng.AgentVersion = "v0.0.6" | ||
| 42 | s := snap(1, vm("vm1")) | ||
| 43 | s.MinAgentVersion = "v0.0.7" | ||
| 44 | |||
| 45 | rep := f.eng.Step(context.Background(), s) | ||
| 46 | f.eng.manager().waitIdle() | ||
| 47 | |||
| 48 | require.Len(t, rep.Vms, 1) | ||
| 49 | require.Empty(t, rep.Vms[0].PowerState, "a refusal that inspected nothing must claim nothing about power") | ||
| 50 | } | ||
| 51 | |||
| 52 | // A tombstoned VM is already deleted. Hanging an upgrade-the-agent error on it | ||
| 53 | // would leave a spurious failure on a VM on its way out, which no operator can | ||
| 54 | // clear — so the refusal passes over it entirely. | ||
| 55 | func TestRefusedSnapshotSkipsTombstonedVMs(t *testing.T) { | ||
| 56 | f := setup(t) | ||
| 57 | f.eng.AgentVersion = "v0.0.6" | ||
| 58 | s := snap(1, vm("vm1"), tombstoned(vm("vm2"))) | ||
| 59 | s.MinAgentVersion = "v0.0.7" | ||
| 60 | |||
| 61 | rep := f.eng.Step(context.Background(), s) | ||
| 62 | f.eng.manager().waitIdle() | ||
| 63 | |||
| 64 | require.Len(t, rep.Vms, 1, "the tombstoned VM earns no row") | ||
| 65 | require.Equal(t, "vm1", rep.Vms[0].VmId) | ||
| 66 | } | ||
| 67 | |||
| 68 | // An unstamped agent ("dev") is never floored: it is the build every developer | ||
| 69 | // runs, and version.Less leaves unparsable versions unordered. The VM must be | ||
| 70 | // materialised exactly as it would be with no floor at all — asserting on the | ||
| 71 | // work done, not merely on an empty report, which an unreached snapshot would | ||
| 72 | // also produce. | ||
| 73 | func TestStepDoesNotFloorUnstampedAgent(t *testing.T) { | ||
| 74 | f := setup(t) | ||
| 75 | f.eng.AgentVersion = "dev" | ||
| 76 | s := snap(1, vm("vm1")) | ||
| 77 | s.MinAgentVersion = "v0.0.7" | ||
| 78 | |||
| 79 | rep := f.eng.Step(context.Background(), s) | ||
| 80 | f.eng.manager().waitIdle() | ||
| 81 | |||
| 82 | require.False(t, rep.FenceViolation) | ||
| 83 | require.Equal(t, []string{"vm1"}, f.prov.prepared, "an unstamped agent converges the snapshot as if it named no floor") | ||
| 84 | } | ||
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | "github.com/a73x/eitri/internal/agent/seed" | 31 | "github.com/a73x/eitri/internal/agent/seed" |
| 32 | "github.com/a73x/eitri/internal/agent/state" | 32 | "github.com/a73x/eitri/internal/agent/state" |
| 33 | "github.com/a73x/eitri/internal/pb" | 33 | "github.com/a73x/eitri/internal/pb" |
| 34 | "github.com/a73x/eitri/internal/version" | ||
| 34 | ) | 35 | ) |
| 35 | 36 | ||
| 36 | // Provisioner is one VM's whole lifecycle on this host, networking included. | 37 | // Provisioner is one VM's whole lifecycle on this host, networking included. |
| @@ -124,6 +125,10 @@ type Engine struct { | |||
| 124 | // present the same key after an agent restart as before one. | 125 | // present the same key after an agent restart as before one. |
| 125 | HostKey func(path string) (state.HostKey, error) | 126 | HostKey func(path string) (state.HostKey, error) |
| 126 | 127 | ||
| 128 | // AgentVersion is this binary's stamped release, ordered against | ||
| 129 | // Snapshot.min_agent_version. "" or "dev" is unordered and never floored. | ||
| 130 | AgentVersion string | ||
| 131 | |||
| 127 | // BootID returns the current host boot identifier (e.g. /proc/sys/kernel/random/boot_id). | 132 | // BootID returns the current host boot identifier (e.g. /proc/sys/kernel/random/boot_id). |
| 128 | // Changes on reboot, enabling lost-VM detection. | 133 | // Changes on reboot, enabling lost-VM detection. |
| 129 | BootID func() string | 134 | BootID func() string |
| @@ -253,6 +258,15 @@ func (e *Engine) Step(ctx context.Context, snap *pb.Snapshot) *pb.Report { | |||
| 253 | // Advance epoch (equal is fine — same snapshot repeated). | 258 | // Advance epoch (equal is fine — same snapshot repeated). |
| 254 | _ = e.St.SaveEpoch(snap.Epoch) | 259 | _ = e.St.SaveEpoch(snap.Epoch) |
| 255 | 260 | ||
| 261 | // ── 2. Version floor ───────────────────────────────────────────────────── | ||
| 262 | // A snapshot this agent cannot fully read is not acted on: every VM in it | ||
| 263 | // fails legibly and nothing is dispatched. Checked AFTER the epoch is | ||
| 264 | // accepted so the report is not fenced and the control plane sees the | ||
| 265 | // refusal rather than a silent stall. | ||
| 266 | if floor := snap.GetMinAgentVersion(); floor != "" && version.Less(e.AgentVersion, floor) { | ||
| 267 | return e.refuseSnapshot(snap, floor) | ||
| 268 | } | ||
| 269 | |||
| 256 | // One record scan serves the whole tick: dispatch slices it into assignments | 270 | // One record scan serves the whole tick: dispatch slices it into assignments |
| 257 | // and aggregate acks destroys against it. Reading it twice cost a second | 271 | // and aggregate acks destroys against it. Reading it twice cost a second |
| 258 | // full state-dir scan on the one blocking path in Step, microseconds after | 272 | // full state-dir scan on the one blocking path in Step, microseconds after |
| @@ -306,6 +320,31 @@ func (e *Engine) aggregate(epoch uint64, recs map[string]state.Record) *pb.Repor | |||
| 306 | return rep | 320 | return rep |
| 307 | } | 321 | } |
| 308 | 322 | ||
| 323 | // refuseSnapshot reports every VM in snap as failed because this agent is | ||
| 324 | // below the snapshot's floor. No record is written and no worker is poked. | ||
| 325 | // | ||
| 326 | // The power state is deliberately EMPTY rather than "stopped". This path | ||
| 327 | // inspects nothing — it is refusing before it reads any record — and the | ||
| 328 | // control plane persists what a report claims: saying "stopped" about guests | ||
| 329 | // that may well be running would turn a version refusal into a fleet-wide lie | ||
| 330 | // about power, and an operator would see healthy VMs go dark. Empty is the | ||
| 331 | // wire's "no observation", which is exactly the truth here. | ||
| 332 | // | ||
| 333 | // Tombstoned VMs are skipped: they are already deleted, nothing will act on | ||
| 334 | // them again, and hanging an upgrade-the-agent error on a VM on its way out | ||
| 335 | // would leave a spurious failure the operator cannot clear. | ||
| 336 | func (e *Engine) refuseSnapshot(snap *pb.Snapshot, floor string) *pb.Report { | ||
| 337 | rep := &pb.Report{LastSeenEpoch: snap.Epoch} | ||
| 338 | reason := fmt.Sprintf("agent %s is below this snapshot's floor %s; upgrade the agent", e.AgentVersion, floor) | ||
| 339 | for _, d := range snap.GetVms() { | ||
| 340 | if d.GetTombstoned() { | ||
| 341 | continue | ||
| 342 | } | ||
| 343 | rep.Vms = append(rep.Vms, newVMStatus(d.GetVmId(), addrs{}, "", "failed", reason)) | ||
| 344 | } | ||
| 345 | return rep | ||
| 346 | } | ||
| 347 | |||
| 309 | // fenceReport is the read-only report returned for a stale snapshot: current | 348 | // fenceReport is the read-only report returned for a stale snapshot: current |
| 310 | // actual state, derived entirely from persisted records, with no mutation and | 349 | // actual state, derived entirely from persisted records, with no mutation and |
| 311 | // no dispatch. | 350 | // no dispatch. |
internal/agent/run/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,7 @@ import ( | |||
| 35 | "github.com/a73x/eitri/internal/covsnap" | 35 | "github.com/a73x/eitri/internal/covsnap" |
| 36 | "github.com/a73x/eitri/internal/joinblob" | 36 | "github.com/a73x/eitri/internal/joinblob" |
| 37 | "github.com/a73x/eitri/internal/names" | 37 | "github.com/a73x/eitri/internal/names" |
| 38 | "github.com/a73x/eitri/internal/version" | ||
| 38 | ) | 39 | ) |
| 39 | 40 | ||
| 40 | // hostRunner is the production one-shot command runner injected into the | 41 | // hostRunner is the production one-shot command runner injected into the |
| @@ -328,6 +329,7 @@ func serve(st *state.Store, cfg Config) error { | |||
| 328 | Images: cache.Ensure, | 329 | Images: cache.Ensure, |
| 329 | Seed: seed.Build, | 330 | Seed: seed.Build, |
| 330 | HostKey: state.LoadOrCreateHostKey, | 331 | HostKey: state.LoadOrCreateHostKey, |
| 332 | AgentVersion: version.Version, | ||
| 331 | BootID: hostinfo.BootID, | 333 | BootID: hostinfo.BootID, |
| 332 | Now: time.Now, | 334 | Now: time.Now, |
| 333 | TombstoneGrace: cfg.TombstoneGrace, | 335 | TombstoneGrace: cfg.TombstoneGrace, |
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -231,6 +231,16 @@ func (c *Client) convergeExposures(snap *pb.Snapshot) []*pb.ExposureStatus { | |||
| 231 | // fenced snapshot is one this host has already moved past, and driving | 231 | // fenced snapshot is one this host has already moved past, and driving |
| 232 | // listeners from it would re-open a port the fleet has since revoked — the | 232 | // listeners from it would re-open a port the fleet has since revoked — the |
| 233 | // same reason the fence path touches nothing else. | 233 | // same reason the fence path touches nothing else. |
| 234 | // | ||
| 235 | // There is a third state, and it deliberately DOES converge: a snapshot the | ||
| 236 | // engine refused for naming a min_agent_version above this build. Such a | ||
| 237 | // snapshot is current — it is not fenced — and an exposure spec is made of | ||
| 238 | // fields this agent already reads in full, so the floor tells us nothing about | ||
| 239 | // them. Tearing down live port-forwards because a NEWER field elsewhere in the | ||
| 240 | // snapshot is unreadable would take working published ports away from a fleet | ||
| 241 | // whose only fault is being one release behind, and help nobody. The engine | ||
| 242 | // signals this by returning FenceViolation false, which is what the check | ||
| 243 | // below keys on. | ||
| 234 | func (c *Client) reportExposures(snap *pb.Snapshot, rep *pb.Report) []*pb.ExposureStatus { | 244 | func (c *Client) reportExposures(snap *pb.Snapshot, rep *pb.Report) []*pb.ExposureStatus { |
| 235 | if rep.GetFenceViolation() { | 245 | if rep.GetFenceViolation() { |
| 236 | return nil | 246 | return nil |
internal/agent/syncclient/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -56,3 +56,29 @@ func TestConvergeExposuresRefusesAFencedSnapshot(t *testing.T) { | |||
| 56 | assert.Zero(t, fe.calls, "a snapshot the engine refused must not drive the listeners") | 56 | assert.Zero(t, fe.calls, "a snapshot the engine refused must not drive the listeners") |
| 57 | assert.Nil(t, got) | 57 | assert.Nil(t, got) |
| 58 | } | 58 | } |
| 59 | |||
| 60 | // The third state: the engine refused the snapshot for naming a floor above | ||
| 61 | // this build, but did NOT fence it. Exposures still converge — their fields are | ||
| 62 | // ones this agent reads in full — so a host one release behind keeps its | ||
| 63 | // published ports instead of dropping every live forward. This test is what | ||
| 64 | // stops a future refusal path from quietly setting FenceViolation and taking | ||
| 65 | // the port-forwards down with it. | ||
| 66 | func TestConvergeExposuresStillRunsForAVersionRefusedSnapshot(t *testing.T) { | ||
| 67 | fe := &fakeExposures{out: []*pb.ExposureStatus{{Id: "e1", State: "active"}}} | ||
| 68 | c := &Client{Exposures: fe} | ||
| 69 | |||
| 70 | // What Engine.Step returns when it refuses on min_agent_version: the epoch | ||
| 71 | // is accepted, no VM was acted on, and FenceViolation is false. | ||
| 72 | rep := &pb.Report{LastSeenEpoch: 1, FenceViolation: false, | ||
| 73 | Vms: []*pb.VMStatus{{VmId: "vm1", Phase: "failed", LastError: "agent v0.0.6 is below this snapshot's floor v0.0.7"}}} | ||
| 74 | |||
| 75 | got := c.reportExposures(&pb.Snapshot{ | ||
| 76 | MinAgentVersion: "v0.0.7", | ||
| 77 | Exposures: []*pb.ExposureSpec{{Id: "e1", VmId: "vm1", GuestPort: 8080, HostPort: 30080}}, | ||
| 78 | }, rep) | ||
| 79 | |||
| 80 | assert.Equal(t, 1, fe.calls, "a version-refused snapshot is current, so its exposures still converge") | ||
| 81 | assert.Equal(t, []string{"e1"}, fe.sawIDs) | ||
| 82 | require.Len(t, got, 1) | ||
| 83 | assert.Equal(t, "active", got[0].GetState()) | ||
| 84 | } | ||
internal/mcpserver/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -184,7 +184,9 @@ func TestFirstEligibleHostWithNothingOnline(t *testing.T) { | |||
| 184 | // would leave it with nothing to act on. | 184 | // would leave it with nothing to act on. |
| 185 | func TestVMCreateSurfacesThePreCSRRefusal(t *testing.T) { | 185 | func TestVMCreateSurfacesThePreCSRRefusal(t *testing.T) { |
| 186 | const refusal = "host mewtwo (h1) runs agent v0.0.3, which predates certified host keys (v0.0.4): " + | 186 | const refusal = "host mewtwo (h1) runs agent v0.0.3, which predates certified host keys (v0.0.4): " + |
| 187 | "a guest created there gets no certified host key. Upgrade that host's agent" | 187 | "a guest created there gets no certified host key, so nothing could verify it and it would be " + |
| 188 | "unreachable through the gate. Upgrade that host's agent — the console's upgrade button, or POST " + | ||
| 189 | "http://plane/api/v1/hosts/h1/upgrade-agent — then retry." | ||
| 188 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { | 190 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { |
| 189 | if r.URL.Path == "/api/v1/hosts" { | 191 | if r.URL.Path == "/api/v1/hosts" { |
| 190 | json.NewEncoder(w).Encode([]map[string]any{ | 192 | json.NewEncoder(w).Encode([]map[string]any{ |
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -1126,13 +1126,19 @@ func (x *VMSpec) GetNetwork() string { | |||
| 1126 | 1126 | ||
| 1127 | // Snapshot is the FULL spec for one host; the agent converges toward it. | 1127 | // Snapshot is the FULL spec for one host; the agent converges toward it. |
| 1128 | type Snapshot struct { | 1128 | type Snapshot struct { |
| 1129 | state protoimpl.MessageState `protogen:"open.v1"` | 1129 | state protoimpl.MessageState `protogen:"open.v1"` |
| 1130 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen | 1130 | Epoch uint64 `protobuf:"varint,1,opt,name=epoch,proto3" json:"epoch,omitempty"` // agents refuse epoch < highest seen |
| 1131 | Vms []*VMSpec `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned | 1131 | Vms []*VMSpec `protobuf:"bytes,2,rep,name=vms,proto3" json:"vms,omitempty"` // FULL set for this host, including tombstoned |
| 1132 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade | 1132 | AgentUpgrade *AgentUpgrade `protobuf:"bytes,3,opt,name=agent_upgrade,json=agentUpgrade,proto3" json:"agent_upgrade,omitempty"` // optional operator-initiated agent self-upgrade |
| 1133 | Exposures []*ExposureSpec `protobuf:"bytes,4,rep,name=exposures,proto3" json:"exposures,omitempty"` // FULL set for this host | 1133 | Exposures []*ExposureSpec `protobuf:"bytes,4,rep,name=exposures,proto3" json:"exposures,omitempty"` // FULL set for this host |
| 1134 | unknownFields protoimpl.UnknownFields | 1134 | // 5 is taken by volumes in the next change. |
| 1135 | sizeCache protoimpl.SizeCache | 1135 | // min_agent_version is the lowest agent release that understands every |
| 1136 | // field in this snapshot. An agent below it fails every VM here with a | ||
| 1137 | // legible reason rather than materialising a spec it only half-reads. | ||
| 1138 | // Empty means no floor. Agents from before this field ignore it. | ||
| 1139 | MinAgentVersion string `protobuf:"bytes,6,opt,name=min_agent_version,json=minAgentVersion,proto3" json:"min_agent_version,omitempty"` | ||
| 1140 | unknownFields protoimpl.UnknownFields | ||
| 1141 | sizeCache protoimpl.SizeCache | ||
| 1136 | } | 1142 | } |
| 1137 | 1143 | ||
| 1138 | func (x *Snapshot) Reset() { | 1144 | func (x *Snapshot) Reset() { |
| @@ -1193,6 +1199,13 @@ func (x *Snapshot) GetExposures() []*ExposureSpec { | |||
| 1193 | return nil | 1199 | return nil |
| 1194 | } | 1200 | } |
| 1195 | 1201 | ||
| 1202 | func (x *Snapshot) GetMinAgentVersion() string { | ||
| 1203 | if x != nil { | ||
| 1204 | return x.MinAgentVersion | ||
| 1205 | } | ||
| 1206 | return "" | ||
| 1207 | } | ||
| 1208 | |||
| 1196 | // AgentUpgrade asks the agent to replace its own binary: download url, verify | 1209 | // AgentUpgrade asks the agent to replace its own binary: download url, verify |
| 1197 | // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an | 1210 | // sha256, swap atomically (keeping .prev), re-exec. Present only on hosts an |
| 1198 | // operator explicitly clicked; absent otherwise. An agent already running | 1211 | // operator explicitly clicked; absent otherwise. An agent already running |
| @@ -1814,12 +1827,13 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1814 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + | 1827 | "\rssh_host_cert\x18\x11 \x01(\tR\vsshHostCert\x12<\n" + |
| 1815 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeys\x12,\n" + | 1828 | "\x1bssh_user_ca_authorized_keys\x18\x12 \x03(\tR\x17sshUserCaAuthorizedKeys\x12,\n" + |
| 1816 | "\x12host_cert_required\x18\x13 \x01(\bR\x10hostCertRequired\x12\x18\n" + | 1829 | "\x12host_cert_required\x18\x13 \x01(\bR\x10hostCertRequired\x12\x18\n" + |
| 1817 | "\anetwork\x18\x14 \x01(\tR\anetworkJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10J\x04\b\x10\x10\x11R\x10ssh_host_key_pem\"\xb7\x01\n" + | 1830 | "\anetwork\x18\x14 \x01(\tR\anetworkJ\x04\b\r\x10\x0eJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10J\x04\b\x10\x10\x11R\x10ssh_host_key_pem\"\xe3\x01\n" + |
| 1818 | "\bSnapshot\x12\x14\n" + | 1831 | "\bSnapshot\x12\x14\n" + |
| 1819 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12\"\n" + | 1832 | "\x05epoch\x18\x01 \x01(\x04R\x05epoch\x12\"\n" + |
| 1820 | "\x03vms\x18\x02 \x03(\v2\x10.eitri.v1.VMSpecR\x03vms\x12;\n" + | 1833 | "\x03vms\x18\x02 \x03(\v2\x10.eitri.v1.VMSpecR\x03vms\x12;\n" + |
| 1821 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\x124\n" + | 1834 | "\ragent_upgrade\x18\x03 \x01(\v2\x16.eitri.v1.AgentUpgradeR\fagentUpgrade\x124\n" + |
| 1822 | "\texposures\x18\x04 \x03(\v2\x16.eitri.v1.ExposureSpecR\texposures\"R\n" + | 1835 | "\texposures\x18\x04 \x03(\v2\x16.eitri.v1.ExposureSpecR\texposures\x12*\n" + |
| 1836 | "\x11min_agent_version\x18\x06 \x01(\tR\x0fminAgentVersion\"R\n" + | ||
| 1823 | "\fAgentUpgrade\x12\x18\n" + | 1837 | "\fAgentUpgrade\x12\x18\n" + |
| 1824 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + | 1838 | "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" + |
| 1825 | "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" + | 1839 | "\x03url\x18\x02 \x01(\tR\x03url\x12\x16\n" + |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | "github.com/a73x/eitri/internal/server/registry" | 26 | "github.com/a73x/eitri/internal/server/registry" |
| 27 | "github.com/a73x/eitri/internal/server/release" | 27 | "github.com/a73x/eitri/internal/server/release" |
| 28 | "github.com/a73x/eitri/internal/server/store" | 28 | "github.com/a73x/eitri/internal/server/store" |
| 29 | "github.com/a73x/eitri/internal/version" | ||
| 29 | ) | 30 | ) |
| 30 | 31 | ||
| 31 | // DefaultImage is the image applied to one-click VM creates. | 32 | // DefaultImage is the image applied to one-click VM creates. |
| @@ -530,7 +531,7 @@ func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Allo | |||
| 530 | rs := states[h.ID] | 531 | rs := states[h.ID] |
| 531 | out[i] = toHostResponse(h, rs.st, rs.ok, alloc[h.ID]) | 532 | out[i] = toHostResponse(h, rs.st, rs.ok, alloc[h.ID]) |
| 532 | out[i].AgentUpdateAvailable = latest != "" && out[i].Online && | 533 | out[i].AgentUpdateAvailable = latest != "" && out[i].Online && |
| 533 | out[i].AgentVersion != "" && release.Less(out[i].AgentVersion, latest) | 534 | out[i].AgentVersion != "" && version.Less(out[i].AgentVersion, latest) |
| 534 | // An offer the agent has not taken yet. The upgrader is the only place | 535 | // An offer the agent has not taken yet. The upgrader is the only place |
| 535 | // it exists — offers are held in memory beside the snapshot stream that | 536 | // it exists — offers are held in memory beside the snapshot stream that |
| 536 | // carries them, never written down — so a restarted server reports none | 537 | // carries them, never written down — so a restarted server reports none |
| @@ -862,21 +863,12 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 862 | // operator can still upgrade the host, rather than at connect time — by then | 863 | // operator can still upgrade the host, rather than at connect time — by then |
| 863 | // the only remedy is to recreate the VM (see vmssh, which refuses the dial). | 864 | // the only remedy is to recreate the VM (see vmssh, which refuses the dial). |
| 864 | // | 865 | // |
| 865 | // Only a host that is connected and reporting has told this server what it | 866 | // A silent host is not judged for this floor — the row is desired state and |
| 866 | // runs. The registry is in-memory and filled by the agent's Hello, so every | 867 | // a newer agent may materialize it, with vmssh refusing the dial if the |
| 867 | // host in the fleet is briefly absent from it after a server restart, and a | 868 | // guest it eventually boots carries no certificate. See refuseBelowFloor. |
| 868 | // host that has gone quiet holds whatever it last said; neither is evidence | ||
| 869 | // about the agent that will pick this VM up. Judging them would answer a | ||
| 870 | // roll with "upgrade this host" about a host that is perfectly current — a | ||
| 871 | // wrong diagnosis at the one moment operators are watching. So an offline | ||
| 872 | // host takes the create exactly as it does for any other reason it cannot | ||
| 873 | // serve one right now: the row is desired state, the agent that materializes | ||
| 874 | // it may well be a newer one, and vmssh still refuses the dial if the guest | ||
| 875 | // it eventually boots has no certificate. | ||
| 876 | hostState, hostHasSpoken := a.reg.Get(req.HostID) | 869 | hostState, hostHasSpoken := a.reg.Get(req.HostID) |
| 877 | hostHasSpoken = hostHasSpoken && hostState.Online | 870 | hostHasSpoken = hostHasSpoken && hostState.Online |
| 878 | if hostHasSpoken && !release.CertifiesGuestHostKeys(hostState.AgentVersion) { | 871 | if a.refuseBelowFloor(w, req.HostID, release.CertifiedHostKeys, false) { |
| 879 | http.Error(w, precsrRefusal(host.Name, req.HostID, hostState.AgentVersion, a.URL(upgradeAgentPath(req.HostID))), http.StatusConflict) | ||
| 880 | return | 872 | return |
| 881 | } | 873 | } |
| 882 | 874 | ||
internal/server/api/exposures.go
| Old | New | ||
|---|---|---|---|
| @@ -138,16 +138,9 @@ func (a *API) handleCreateExposure(w http.ResponseWriter, r *http.Request) { | |||
| 138 | // the operator can still upgrade the host — the exposure twin of the create's | 138 | // the operator can still upgrade the host — the exposure twin of the create's |
| 139 | // certified-host-key refusal. Only a connected, reporting host is judged: an | 139 | // certified-host-key refusal. Only a connected, reporting host is judged: an |
| 140 | // offline or silent one takes the grant exactly as it would for any other | 140 | // offline or silent one takes the grant exactly as it would for any other |
| 141 | // reason it cannot serve one this moment (see api.go's precsrRefusal note). | 141 | // reason it cannot serve one this moment (see refuseBelowFloor). |
| 142 | if protocol == "udp" { | 142 | if protocol == "udp" && a.refuseBelowFloor(w, vm.HostID, release.DatagramExposures, false) { |
| 143 | if hs, ok := a.reg.Get(vm.HostID); ok && hs.Online && !release.HonorsDatagramExposures(hs.AgentVersion) { | 143 | return |
| 144 | hostName := vm.HostID | ||
| 145 | if h, err := a.st.GetHost(vm.HostID); err == nil { | ||
| 146 | hostName = h.Name | ||
| 147 | } | ||
| 148 | http.Error(w, preDatagramRefusal(hostName, vm.HostID, hs.AgentVersion, a.URL(upgradeAgentPath(vm.HostID))), http.StatusConflict) | ||
| 149 | return | ||
| 150 | } | ||
| 151 | } | 144 | } |
| 152 | e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort, protocol) | 145 | e, err := a.st.CreateExposure(vm.ID, req.GuestPort, req.HostPort, protocol) |
| 153 | if err != nil { | 146 | if err != nil { |
internal/server/api/exposures_test.go
| Old | New | ||
|---|---|---|---|
| @@ -109,7 +109,7 @@ func TestCreateExposureDefaultsToTCPAndTakesUDP(t *testing.T) { | |||
| 109 | // reports the exposure active anyway, so the row would read published and carry | 109 | // reports the exposure active anyway, so the row would read published and carry |
| 110 | // no datagrams — refuse it here, where the host can still be upgraded. Only a | 110 | // no datagrams — refuse it here, where the host can still be upgraded. Only a |
| 111 | // connected, reporting host is judged, exactly as the certified-host-key create | 111 | // connected, reporting host is judged, exactly as the certified-host-key create |
| 112 | // refusal (precsrRefusal) judges its floor. | 112 | // refusal judges its floor — both go through refuseBelowFloor. |
| 113 | func TestCreateExposureFloorsUDPBelowFirstDatagramAgent(t *testing.T) { | 113 | func TestCreateExposureFloorsUDPBelowFirstDatagramAgent(t *testing.T) { |
| 114 | ts, _, _, reg, _ := newServer(t) | 114 | ts, _, _, reg, _ := newServer(t) |
| 115 | host := enroll(t, ts) | 115 | host := enroll(t, ts) |
| @@ -123,7 +123,7 @@ func TestCreateExposureFloorsUDPBelowFirstDatagramAgent(t *testing.T) { | |||
| 123 | 123 | ||
| 124 | // enroll leaves the host known-but-silent (no report), the state of every | 124 | // enroll leaves the host known-but-silent (no report), the state of every |
| 125 | // host for a moment after a server restart: nothing says what it runs, so — | 125 | // host for a moment after a server restart: nothing says what it runs, so — |
| 126 | // like precsrRefusal — it is not judged and the UDP grant is taken. | 126 | // with judgeOffline false — it is not judged and the UDP grant is taken. |
| 127 | require.Equal(t, 201, udp(50).StatusCode, "a silent host is not floored") | 127 | require.Equal(t, 201, udp(50).StatusCode, "a silent host is not floored") |
| 128 | 128 | ||
| 129 | // An online agent below the datagram floor is refused, naming the version and | 129 | // An online agent below the datagram floor is refused, naming the version and |
internal/server/api/upgrade.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "net/http" | 7 | "net/http" |
| 8 | 8 | ||
| 9 | "github.com/a73x/eitri/internal/server/release" | 9 | "github.com/a73x/eitri/internal/server/release" |
| 10 | "github.com/a73x/eitri/internal/version" | ||
| 10 | ) | 11 | ) |
| 11 | 12 | ||
| 12 | // upgradeAgentPath is the endpoint that offers one host's agent an upgrade — | 13 | // upgradeAgentPath is the endpoint that offers one host's agent an upgrade — |
| @@ -14,41 +15,55 @@ import ( | |||
| 14 | // is too old" refusal names. | 15 | // is too old" refusal names. |
| 15 | func upgradeAgentPath(hostID string) string { return "/api/v1/hosts/" + hostID + "/upgrade-agent" } | 16 | func upgradeAgentPath(hostID string) string { return "/api/v1/hosts/" + hostID + "/upgrade-agent" } |
| 16 | 17 | ||
| 17 | // precsrRefusal explains why a host cannot be given a VM: its agent predates | 18 | // refuseBelowFloor is the admission shape every version-floored feature |
| 18 | // certified guest host keys. The two readings — a version below the floor, and | 19 | // shares: only a host whose agent has proven it carries f may be handed work |
| 19 | // no reported version at all, which proves nothing either way — differ only in | 20 | // that needs f. It writes the 409 and returns true when the caller must stop. |
| 20 | // what is known, so they share the consequence and the fix. Both describe a | 21 | // |
| 21 | // host that is connected and reporting: silence from one that is up is an agent | 22 | // Only a connected, reporting host has told this server what it runs. The |
| 22 | // too old to name itself, which is why it reads the same as an old version. | 23 | // registry is in-memory and filled by the agent's Hello, so every host in the |
| 23 | func precsrRefusal(hostName, hostID, agentVersion, upgradeURL string) string { | 24 | // fleet is briefly silent after a server restart, and a host that has gone |
| 24 | known := fmt.Sprintf("host %s (%s) runs agent %s, which predates certified host keys (%s)", | 25 | // quiet holds whatever it last said. judgeOffline says what that silence |
| 25 | hostName, hostID, agentVersion, release.FirstCertifiedHostKeys) | 26 | // means. false: the host takes the request as desired state and a newer agent |
| 26 | if agentVersion == "" { | 27 | // may pick it up (certified keys — vmssh refuses the dial later; UDP — the |
| 27 | known = fmt.Sprintf("host %s (%s) has reported no agent version, so nothing says it is new enough for certified host keys (%s)", | 28 | // grant reads published-and-silent). true: silence refuses, because there is |
| 28 | hostName, hostID, release.FirstCertifiedHostKeys) | 29 | // no downstream net — an old agent ignoring the field would put user data |
| 30 | // somewhere it was not asked to. | ||
| 31 | // | ||
| 32 | // The three readings — not reporting, reporting no version, reporting a | ||
| 33 | // version below the floor — differ only in what is known, so they share the | ||
| 34 | // consequence (f.Consequence, omitted when the feature names none) and the | ||
| 35 | // fix. | ||
| 36 | func (a *API) refuseBelowFloor(w http.ResponseWriter, hostID string, f release.Feature, judgeOffline bool) bool { | ||
| 37 | hs, ok := a.reg.Get(hostID) | ||
| 38 | spoken := ok && hs.Online | ||
| 39 | if !spoken && !judgeOffline { | ||
| 40 | return false | ||
| 29 | } | 41 | } |
| 30 | return known + ": a guest created there gets no certified host key, so nothing could verify it and it would be " + | 42 | if spoken && f.SupportedBy(hs.AgentVersion) { |
| 31 | "unreachable through the gate. Upgrade that host's agent — the console's upgrade button, or POST " + | 43 | return false |
| 32 | upgradeURL + " — then create the VM." | 44 | } |
| 33 | } | 45 | hostName := hostID |
| 34 | 46 | if h, err := a.st.GetHost(hostID); err == nil { | |
| 35 | // preDatagramRefusal explains why a host cannot be granted a UDP exposure: its | 47 | hostName = h.Name |
| 36 | // agent predates datagram proxies. A UDP grant handed to an older agent is bound | 48 | } |
| 37 | // as TCP by a converge loop that listens unconditionally, and reported active | 49 | var known string |
| 38 | // regardless — the row would read published and carry no datagrams ever. As in | 50 | switch { |
| 39 | // precsrRefusal, the two readings — a version below the floor, and no reported | 51 | case !spoken: |
| 40 | // version at all — differ only in what is known, so they share the consequence | 52 | known = fmt.Sprintf("host %s (%s) is not reporting, so nothing says its agent carries %s (%s)", |
| 41 | // and the fix; both describe a host that is connected and reporting. | 53 | hostName, hostID, f.Name, f.Since) |
| 42 | func preDatagramRefusal(hostName, hostID, agentVersion, upgradeURL string) string { | 54 | case hs.AgentVersion == "": |
| 43 | known := fmt.Sprintf("host %s (%s) runs agent %s, which predates datagram exposures (%s)", | 55 | known = fmt.Sprintf("host %s (%s) has reported no agent version, so nothing says it is new enough for %s (%s)", |
| 44 | hostName, hostID, agentVersion, release.FirstDatagramExposures) | 56 | hostName, hostID, f.Name, f.Since) |
| 45 | if agentVersion == "" { | 57 | default: |
| 46 | known = fmt.Sprintf("host %s (%s) has reported no agent version, so nothing says it can carry a UDP exposure (%s)", | 58 | known = fmt.Sprintf("host %s (%s) runs agent %s, which predates %s (%s)", |
| 47 | hostName, hostID, release.FirstDatagramExposures) | 59 | hostName, hostID, hs.AgentVersion, f.Name, f.Since) |
| 60 | } | ||
| 61 | if f.Consequence != "" { | ||
| 62 | known += ": " + f.Consequence | ||
| 48 | } | 63 | } |
| 49 | return known + ": a UDP grant there would be bound as TCP and reported active, publishing a port that carries no " + | 64 | http.Error(w, known+". Upgrade that host's agent — the console's upgrade button, or POST "+ |
| 50 | "datagrams. Upgrade that host's agent — the console's upgrade button, or POST " + upgradeURL + | 65 | a.URL(upgradeAgentPath(hostID))+" — then retry.", http.StatusConflict) |
| 51 | " — then publish the port." | 66 | return true |
| 52 | } | 67 | } |
| 53 | 68 | ||
| 54 | // handleUpgradeAgent records a pending self-upgrade offer for one host's agent | 69 | // handleUpgradeAgent records a pending self-upgrade offer for one host's agent |
| @@ -84,7 +99,7 @@ func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) { | |||
| 84 | http.Error(w, "host is offline", http.StatusConflict) | 99 | http.Error(w, "host is offline", http.StatusConflict) |
| 85 | return | 100 | return |
| 86 | } | 101 | } |
| 87 | if st.AgentVersion == "" || !release.Less(st.AgentVersion, m.Version) { | 102 | if st.AgentVersion == "" || !version.Less(st.AgentVersion, m.Version) { |
| 88 | http.Error(w, "agent is not behind the latest release", http.StatusConflict) | 103 | http.Error(w, "agent is not behind the latest release", http.StatusConflict) |
| 89 | return | 104 | return |
| 90 | } | 105 | } |
internal/server/api/upgrade_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package api | |||
| 3 | import ( | 3 | import ( |
| 4 | "encoding/json" | 4 | "encoding/json" |
| 5 | "net/http" | 5 | "net/http" |
| 6 | "net/http/httptest" | ||
| 6 | "testing" | 7 | "testing" |
| 7 | "time" | 8 | "time" |
| 8 | 9 | ||
| @@ -277,3 +278,66 @@ func TestMarshalSnapshotCarriesVersions(t *testing.T) { | |||
| 277 | assert.Equal(t, version.Version, snap.ServerVersion) | 278 | assert.Equal(t, version.Version, snap.ServerVersion) |
| 278 | assert.Equal(t, "v0.0.2", snap.LatestVersion) | 279 | assert.Equal(t, "v0.0.2", snap.LatestVersion) |
| 279 | } | 280 | } |
| 281 | |||
| 282 | // refuseBelowFloor is the one admission shape every version-floored feature | ||
| 283 | // shares. judgeOffline is its only parameter: the shipped floors let a silent | ||
| 284 | // host through (a newer agent may pick the work up), volumes do not (an old | ||
| 285 | // agent would boot the guest bare and the data lands in the wrong place). | ||
| 286 | func TestRefuseBelowFloor(t *testing.T) { | ||
| 287 | ts, _, _, reg, a := newServer(t) | ||
| 288 | host := enroll(t, ts) | ||
| 289 | hostID := host["host_id"] | ||
| 290 | f := release.Feature{Name: "widgets", Since: "v0.0.5"} | ||
| 291 | |||
| 292 | refuse := func(judgeOffline bool) (bool, string) { | ||
| 293 | rec := httptest.NewRecorder() | ||
| 294 | stop := a.refuseBelowFloor(rec, hostID, f, judgeOffline) | ||
| 295 | return stop, rec.Body.String() | ||
| 296 | } | ||
| 297 | |||
| 298 | // Silent host: not judged unless asked to be. | ||
| 299 | stop, _ := refuse(false) | ||
| 300 | assert.False(t, stop, "a silent host is let through when judgeOffline is false") | ||
| 301 | stop, body := refuse(true) | ||
| 302 | assert.True(t, stop, "a silent host is refused when judgeOffline is true") | ||
| 303 | assert.Contains(t, body, "widgets") | ||
| 304 | assert.Contains(t, body, "not reporting") | ||
| 305 | assert.Contains(t, body, "upgrade-agent") | ||
| 306 | |||
| 307 | // Online, below floor: refused either way, naming version and floor. | ||
| 308 | reg.SetAgentVersion(hostID, "v0.0.4") | ||
| 309 | reg.UpdateReport(hostID, registry.Report{}) | ||
| 310 | stop, body = refuse(false) | ||
| 311 | assert.True(t, stop) | ||
| 312 | assert.Contains(t, body, "v0.0.4") | ||
| 313 | assert.Contains(t, body, "v0.0.5") | ||
| 314 | assert.Contains(t, body, "widgets") | ||
| 315 | assert.Contains(t, body, "(v0.0.5). Upgrade", "a feature naming no consequence skips the clause") | ||
| 316 | |||
| 317 | // Online, no version: the "reported no agent version" reading. | ||
| 318 | reg.SetAgentVersion(hostID, "") | ||
| 319 | reg.UpdateReport(hostID, registry.Report{}) | ||
| 320 | stop, body = refuse(false) | ||
| 321 | assert.True(t, stop) | ||
| 322 | assert.Contains(t, body, "has reported no agent version") | ||
| 323 | |||
| 324 | // At floor: admitted. | ||
| 325 | reg.SetAgentVersion(hostID, "v0.0.5") | ||
| 326 | reg.UpdateReport(hostID, registry.Report{}) | ||
| 327 | stop, _ = refuse(true) | ||
| 328 | assert.False(t, stop) | ||
| 329 | |||
| 330 | // A feature that says what ignoring it costs puts that sentence in the | ||
| 331 | // refusal, between what is known and the fix. Naming only the floor leaves | ||
| 332 | // the operator — and the model reading this back out of MCP — to guess why | ||
| 333 | // it matters. | ||
| 334 | costly := release.Feature{ | ||
| 335 | Name: "widgets", | ||
| 336 | Since: "v0.0.9", | ||
| 337 | Consequence: "the widget lands in the wrong drawer", | ||
| 338 | } | ||
| 339 | rec := httptest.NewRecorder() | ||
| 340 | assert.True(t, a.refuseBelowFloor(rec, hostID, costly, false)) | ||
| 341 | assert.Contains(t, rec.Body.String(), | ||
| 342 | "widgets (v0.0.9): the widget lands in the wrong drawer. Upgrade that host's agent") | ||
| 343 | } | ||
internal/server/release/release.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,9 @@ | |||
| 1 | // Package release discovers the latest eitri release from a manifest URL | 1 | // Package release discovers the latest eitri release from a manifest URL |
| 2 | // (eitri.sh) and orders versions. The manifest is the bootstrap contract: | 2 | // (eitri.sh) and names the agent floors the control plane admits against. The |
| 3 | // stable URLs + sha256 per artifact, fetchable by tooling and humans alike. | 3 | // manifest is the bootstrap contract: stable URLs + sha256 per artifact, |
| 4 | // fetchable by tooling and humans alike. Version ORDERING is not here — it is | ||
| 5 | // in internal/version, beside the stamp, because the agent needs it too and | ||
| 6 | // may not import a control-plane package (arch R1). | ||
| 4 | package release | 7 | package release |
| 5 | 8 | ||
| 6 | import ( | 9 | import ( |
| @@ -9,12 +12,11 @@ import ( | |||
| 9 | "fmt" | 12 | "fmt" |
| 10 | "io" | 13 | "io" |
| 11 | "net/http" | 14 | "net/http" |
| 12 | "strconv" | ||
| 13 | "strings" | ||
| 14 | "sync" | 15 | "sync" |
| 15 | "time" | 16 | "time" |
| 16 | 17 | ||
| 17 | "github.com/a73x/eitri/internal/relmanifest" | 18 | "github.com/a73x/eitri/internal/relmanifest" |
| 19 | "github.com/a73x/eitri/internal/version" | ||
| 18 | ) | 20 | ) |
| 19 | 21 | ||
| 20 | // Artifact and Manifest are the shared wire types; see internal/relmanifest. | 22 | // Artifact and Manifest are the shared wire types; see internal/relmanifest. |
| @@ -116,189 +118,60 @@ func (c *Client) Poll(ctx context.Context, every time.Duration, onErr func(error | |||
| 116 | } | 118 | } |
| 117 | } | 119 | } |
| 118 | 120 | ||
| 119 | // Less reports whether version a orders strictly before b. Versions are | 121 | // Feature is one agent-side capability and the release whose agent first |
| 120 | // eitri's own tags — releases ("v0.0.3"), pre-releases of them ("v0.0.4-pre.1") | 122 | // carried it. Admission asks SupportedBy before handing a host work an older |
| 121 | // — and the git-describe builds derived from either ("v0.0.3-5-gabc1234", | 123 | // agent would silently mishandle; see api.refuseBelowFloor. |
| 122 | // "v0.0.4-pre.1-3-gabc1234", N commits past that tag). The whole chain orders | 124 | type Feature struct { |
| 123 | // the way it reads: | 125 | Name string // how a refusal names it: "certified host keys" |
| 124 | // | 126 | Since string // the release tag that first carried it |
| 125 | // v0.0.4-pre.1 < v0.0.4-pre.1-3-gabc1234 < v0.0.4-pre.2 < v0.0.4 < v0.0.4-2-gabc1234 | 127 | // Consequence is one sentence on what goes wrong if the floor is ignored, |
| 126 | // | 128 | // written to follow "host h (id) runs agent v (…): ". A refusal that names |
| 127 | // So a hand-deployed "v0.0.2-2-g68f804d" orders before the "v0.0.3" release and | 129 | // only a version leaves the operator to guess why it matters, and leaves a |
| 128 | // takes the upgrade; a build ahead of the latest release never orders before it | 130 | // model reading it back through MCP with nothing to act on. |
| 129 | // and is never offered a downgrade; and an agent on a pre-release takes the next | 131 | Consequence string |
| 130 | // pre-release and then the release itself, which is what lets a staging plane | ||
| 131 | // rehearse the upgrade path on the same tags a release cycle produces. | ||
| 132 | // | ||
| 133 | // Anything unparsable — "dev", a "-dirty" working tree, a malformed tag — never | ||
| 134 | // orders before anything, so an unstamped build never sees an upgrade. | ||
| 135 | func Less(a, b string) bool { | ||
| 136 | pa, oka := parse(a) | ||
| 137 | pb, okb := parse(b) | ||
| 138 | if !oka || !okb { | ||
| 139 | return false | ||
| 140 | } | ||
| 141 | return before(pa, pb) | ||
| 142 | } | ||
| 143 | |||
| 144 | // FirstCertifiedHostKeys is the release whose agent began generating each | ||
| 145 | // guest's SSH host key and submitting the public half for the control plane to | ||
| 146 | // sign. A VM created by anything older carries no host certificate and can | ||
| 147 | // never be issued one — the key it would certify was never reported — so | ||
| 148 | // nothing can verify that guest at connect time. | ||
| 149 | const FirstCertifiedHostKeys = "v0.0.4" | ||
| 150 | |||
| 151 | // CertifiesGuestHostKeys reports whether an agent at version v certifies the | ||
| 152 | // host keys of the guests it creates, ordering v against | ||
| 153 | // FirstCertifiedHostKeys by the same rule Less publishes. | ||
| 154 | // | ||
| 155 | // An unparsable version — "dev", a "-dirty" tree, a malformed tag — does not: | ||
| 156 | // nothing can be proven about a version that cannot be read, and this is the | ||
| 157 | // same conservative reading the upgrade path takes when it never offers such a | ||
| 158 | // build an upgrade. So is the empty version a host reports before it has said | ||
| 159 | // anything at all. | ||
| 160 | func CertifiesGuestHostKeys(v string) bool { | ||
| 161 | p, ok := parse(v) | ||
| 162 | if !ok { | ||
| 163 | return false | ||
| 164 | } | ||
| 165 | // The floor is a release tag, so it parses by construction (pinned by test). | ||
| 166 | floor, _ := parse(FirstCertifiedHostKeys) | ||
| 167 | return !before(p, floor) | ||
| 168 | } | ||
| 169 | |||
| 170 | // FirstDatagramExposures is the release whose agent began honoring an | ||
| 171 | // exposure's protocol. A UDP grant handed to anything older is bound as TCP by | ||
| 172 | // a converge loop that calls net.Listen("tcp", …) unconditionally, and reported | ||
| 173 | // active regardless — a row that reads published and carries no datagrams ever. | ||
| 174 | // So a UDP exposure is refused for a host below this floor, as a certified-key | ||
| 175 | // VM create is refused below FirstCertifiedHostKeys. | ||
| 176 | const FirstDatagramExposures = "v0.0.5" | ||
| 177 | |||
| 178 | // HonorsDatagramExposures reports whether an agent at version v binds a UDP | ||
| 179 | // exposure as UDP, ordering v against FirstDatagramExposures by the same rule | ||
| 180 | // Less publishes. | ||
| 181 | // | ||
| 182 | // An unparsable version — "dev", a "-dirty" tree, a malformed tag — does not, | ||
| 183 | // and neither does the empty version a host reports before it has said | ||
| 184 | // anything: the same conservative reading CertifiesGuestHostKeys takes. | ||
| 185 | func HonorsDatagramExposures(v string) bool { | ||
| 186 | p, ok := parse(v) | ||
| 187 | if !ok { | ||
| 188 | return false | ||
| 189 | } | ||
| 190 | // The floor is a release tag, so it parses by construction (pinned by test). | ||
| 191 | floor, _ := parse(FirstDatagramExposures) | ||
| 192 | return !before(p, floor) | ||
| 193 | } | ||
| 194 | |||
| 195 | // before compares two ordering tuples, the one place their fields are ranked. | ||
| 196 | func before(a, b [6]int) bool { | ||
| 197 | for i := range a { | ||
| 198 | if a[i] != b[i] { | ||
| 199 | return a[i] < b[i] | ||
| 200 | } | ||
| 201 | } | ||
| 202 | return false | ||
| 203 | } | 132 | } |
| 204 | 133 | ||
| 205 | // parse reads a version into its ordering tuple. Four shapes are accepted: | 134 | var ( |
| 206 | // | 135 | // CertifiedHostKeys: the agent generates each guest's SSH host key and |
| 207 | // vX.Y.Z a release | 136 | // submits the public half for signing. A VM created by anything older |
| 208 | // vX.Y.Z-pre.N the Nth pre-release leading up to it | 137 | // carries no host certificate and can never be issued one — the key it |
| 209 | // vX.Y.Z-C-g<hex> C commits past the release (git describe) | 138 | // would certify was never reported. |
| 210 | // vX.Y.Z-pre.N-C-g<hex> C commits past the pre-release | 139 | CertifiedHostKeys = Feature{Name: "certified host keys", Since: "v0.0.4", |
| 211 | // | 140 | Consequence: "a guest created there gets no certified host key, so nothing could verify it " + |
| 212 | // The tuple is (X, Y, Z, final, pre, count). `final` is 1 for a release and 0 | 141 | "and it would be unreachable through the gate"} |
| 213 | // for a pre-release of it, which is the whole trick: it sinks every | 142 | // DatagramExposures: the agent honours an exposure's protocol. A UDP grant |
| 214 | // vX.Y.Z-pre.N below vX.Y.Z without disturbing how anything else sorts, and | 143 | // handed to anything older is bound as TCP by a converge loop that calls |
| 215 | // leaves builds derived from either in their own place. Both counters must be | 144 | // net.Listen("tcp", …) unconditionally, and reported active regardless. |
| 216 | // exactly a non-negative decimal, and a describe suffix must carry a | 145 | DatagramExposures = Feature{Name: "datagram exposures", Since: "v0.0.5", |
| 217 | // "g"-prefixed non-empty hex abbrev — a "-dirty" marker or any other trailing | 146 | Consequence: "a UDP grant there would be bound as TCP and reported active, " + |
| 218 | // text makes the version unparsable. | 147 | "publishing a port that carries no datagrams"} |
| 219 | func parse(v string) ([6]int, bool) { | 148 | // Volumes: the agent materialises VolumeSpec files and attaches them. An |
| 220 | var out [6]int | 149 | // older agent ignores volume_ids and boots the guest bare, so the data the |
| 221 | core, suffix, hasSuffix := strings.Cut(strings.TrimPrefix(v, "v"), "-") | 150 | // tenant meant for the volume lands on the root disk. Since is the first |
| 222 | 151 | // stamped build that carries it. | |
| 223 | out[3] = 1 // a release outranks every pre-release of itself | 152 | Volumes = Feature{Name: "volumes", Since: "v0.0.8-pre.1", |
| 224 | if hasSuffix { | 153 | Consequence: "a guest created there would boot without its volumes and write the data " + |
| 225 | if pre, isPre := strings.CutPrefix(suffix, "pre."); isPre { | 154 | "meant for them to its root disk"} |
| 226 | out[3] = 0 | 155 | ) |
| 227 | num, rest, hasRest := strings.Cut(pre, "-") | ||
| 228 | n, ok := decimal(num) | ||
| 229 | if !ok { | ||
| 230 | return out, false | ||
| 231 | } | ||
| 232 | out[4] = n | ||
| 233 | suffix, hasSuffix = rest, hasRest | ||
| 234 | } | ||
| 235 | } | ||
| 236 | if hasSuffix { | ||
| 237 | count, abbrev, found := strings.Cut(suffix, "-") | ||
| 238 | if !found || !isGitAbbrev(abbrev) { | ||
| 239 | return out, false | ||
| 240 | } | ||
| 241 | n, ok := decimal(count) | ||
| 242 | if !ok { | ||
| 243 | return out, false | ||
| 244 | } | ||
| 245 | out[5] = n | ||
| 246 | } | ||
| 247 | |||
| 248 | parts := strings.SplitN(core, ".", 3) | ||
| 249 | if len(parts) != 3 { | ||
| 250 | return out, false | ||
| 251 | } | ||
| 252 | for i, p := range parts { | ||
| 253 | n, ok := decimal(p) | ||
| 254 | if !ok { | ||
| 255 | return out, false | ||
| 256 | } | ||
| 257 | out[i] = n | ||
| 258 | } | ||
| 259 | return out, true | ||
| 260 | } | ||
| 261 | 156 | ||
| 262 | // decimal parses one non-negative decimal component, rejecting the sign Atoi | 157 | // The two shipped floors by their original names; every refusal quotes them. |
| 263 | // would otherwise accept and any value too large to hold. | 158 | const ( |
| 264 | func decimal(s string) (int, bool) { | 159 | FirstCertifiedHostKeys = "v0.0.4" |
| 265 | if !isDecimal(s) { | 160 | FirstDatagramExposures = "v0.0.5" |
| 266 | return 0, false | 161 | ) |
| 267 | } | ||
| 268 | n, err := strconv.Atoi(s) | ||
| 269 | if err != nil { | ||
| 270 | return 0, false | ||
| 271 | } | ||
| 272 | return n, true | ||
| 273 | } | ||
| 274 | 162 | ||
| 275 | // isDecimal reports whether s is a non-empty run of decimal digits — the sign | 163 | // SupportedBy reports whether an agent at version v carries f, ordering v |
| 276 | // Atoi would otherwise accept is not part of a version component. | 164 | // against f.Since by the same rule Less publishes. An unparsable version — |
| 277 | func isDecimal(s string) bool { | 165 | // "dev", a "-dirty" tree, the empty version a host reports before it has said |
| 278 | if s == "" { | 166 | // anything — does not: nothing can be proven about a version that cannot be |
| 167 | // read, the same conservative reading the upgrade path takes when it never | ||
| 168 | // offers such a build an upgrade. Neither does an unparsable floor. | ||
| 169 | func (f Feature) SupportedBy(v string) bool { | ||
| 170 | if !version.Ordered(v) || !version.Ordered(f.Since) { | ||
| 279 | return false | 171 | return false |
| 280 | } | 172 | } |
| 281 | for _, r := range s { | 173 | return !version.Less(v, f.Since) |
| 282 | if r < '0' || r > '9' { | ||
| 283 | return false | ||
| 284 | } | ||
| 285 | } | ||
| 286 | return true | ||
| 287 | } | 174 | } |
| 288 | 175 | ||
| 289 | // isGitAbbrev reports whether s is a "g"-prefixed non-empty hex object abbrev, | 176 | // CertifiesGuestHostKeys is CertifiedHostKeys.SupportedBy. |
| 290 | // the shape git describe appends after the commit count. | 177 | func CertifiesGuestHostKeys(v string) bool { return CertifiedHostKeys.SupportedBy(v) } |
| 291 | func isGitAbbrev(s string) bool { | ||
| 292 | hex, ok := strings.CutPrefix(s, "g") | ||
| 293 | if !ok || hex == "" { | ||
| 294 | return false | ||
| 295 | } | ||
| 296 | for _, r := range hex { | ||
| 297 | switch { | ||
| 298 | case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': | ||
| 299 | default: | ||
| 300 | return false | ||
| 301 | } | ||
| 302 | } | ||
| 303 | return true | ||
| 304 | } | ||
internal/server/release/release_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,135 +8,11 @@ import ( | |||
| 8 | "sync/atomic" | 8 | "sync/atomic" |
| 9 | "testing" | 9 | "testing" |
| 10 | "time" | 10 | "time" |
| 11 | ) | ||
| 12 | |||
| 13 | func TestLess(t *testing.T) { | ||
| 14 | cases := []struct { | ||
| 15 | a, b string | ||
| 16 | want bool | ||
| 17 | }{ | ||
| 18 | {"v0.0.1", "v0.0.2", true}, | ||
| 19 | {"v0.0.2", "v0.0.1", false}, | ||
| 20 | {"v0.0.2", "v0.0.2", false}, | ||
| 21 | {"v0.9.0", "v0.10.0", true}, // numeric, not lexicographic | ||
| 22 | {"dev", "v0.0.2", false}, // unparsable never upgrades | ||
| 23 | {"v0.0.1", "dev", false}, | ||
| 24 | {"", "v0.0.2", false}, | ||
| 25 | {"v0.0.2", "", false}, | ||
| 26 | } | ||
| 27 | for _, c := range cases { | ||
| 28 | if got := Less(c.a, c.b); got != c.want { | ||
| 29 | t.Errorf("Less(%q,%q) = %v, want %v", c.a, c.b, got, c.want) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | // TestLessOrdersGitDescribeBuilds pins the ordering of a git-describe build | ||
| 35 | // ("vX.Y.Z-N-g<hex>", N commits past tag vX.Y.Z) against a release tag: it | ||
| 36 | // sorts by (X, Y, Z, N), so a build derived from an older tag takes the | ||
| 37 | // upgrade while a build ahead of the latest release is never offered one. | ||
| 38 | func TestLessOrdersGitDescribeBuilds(t *testing.T) { | ||
| 39 | cases := []struct { | ||
| 40 | a, b string | ||
| 41 | want bool | ||
| 42 | }{ | ||
| 43 | // A hand-deployed agent two commits past v0.0.2 upgrades to v0.0.3. | ||
| 44 | {"v0.0.2-2-g68f804d", "v0.0.3", true}, | ||
| 45 | {"v0.0.3", "v0.0.2-2-g68f804d", false}, | ||
| 46 | // A dev build ahead of the latest release is never downgraded onto it. | ||
| 47 | {"v0.0.3-5-gabc1234", "v0.0.3", false}, | ||
| 48 | {"v0.0.3", "v0.0.3-5-gabc1234", true}, | ||
| 49 | // The commit count breaks ties within one tag. | ||
| 50 | {"v0.0.3-2-gabc1234", "v0.0.3-5-gabc1234", true}, | ||
| 51 | {"v0.0.3-5-gabc1234", "v0.0.3-2-gabc1234", false}, | ||
| 52 | {"v0.0.3-5-gabc1234", "v0.0.3-5-gabc1234", false}, | ||
| 53 | // A plain tag is the same build as zero commits past it. | ||
| 54 | {"v0.0.3-0-gabc1234", "v0.0.3", false}, | ||
| 55 | {"v0.0.3", "v0.0.3-0-gabc1234", false}, | ||
| 56 | // Release components still outrank the commit count. | ||
| 57 | {"v0.0.2-99-gabc1234", "v0.1.0", true}, | ||
| 58 | {"v0.1.0-1-gabc1234", "v0.0.2-99-gabc1234", false}, | ||
| 59 | // Uppercase hex is still a hex abbrev. | ||
| 60 | {"v0.0.2-2-gABC1234", "v0.0.3", true}, | ||
| 61 | } | ||
| 62 | for _, c := range cases { | ||
| 63 | if got := Less(c.a, c.b); got != c.want { | ||
| 64 | t.Errorf("Less(%q,%q) = %v, want %v", c.a, c.b, got, c.want) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | 11 | ||
| 69 | // TestLessOrdersPreReleases pins the chain a release cycle walks: each | 12 | "github.com/stretchr/testify/assert" |
| 70 | // pre-release, the builds derived from it, the next pre-release, the release | ||
| 71 | // itself, and builds past that. An agent on a pre-release takes the next one | ||
| 72 | // and eventually the release, which is what lets a staging plane rehearse the | ||
| 73 | // upgrade path on the same tags the cycle produces. | ||
| 74 | func TestLessOrdersPreReleases(t *testing.T) { | ||
| 75 | // Every entry orders strictly before every entry after it. | ||
| 76 | chain := []string{ | ||
| 77 | "v0.0.3", | ||
| 78 | "v0.0.4-pre.1", | ||
| 79 | "v0.0.4-pre.1-3-gabc1234", | ||
| 80 | "v0.0.4-pre.2", | ||
| 81 | "v0.0.4-pre.10", // numeric, not lexicographic | ||
| 82 | "v0.0.4", | ||
| 83 | "v0.0.4-2-gabc1234", | ||
| 84 | "v0.0.5-pre.1", | ||
| 85 | } | ||
| 86 | for i, a := range chain { | ||
| 87 | for j, b := range chain { | ||
| 88 | want := i < j | ||
| 89 | if got := Less(a, b); got != want { | ||
| 90 | t.Errorf("Less(%q,%q) = %v, want %v", a, b, got, want) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | 13 | ||
| 96 | // TestLessLeavesUnstampedBuildsUnordered pins the shapes that stay | 14 | "github.com/a73x/eitri/internal/version" |
| 97 | // unparsable: a dirty working tree or a malformed version never orders before | 15 | ) |
| 98 | // anything, in either direction, so it is never offered an upgrade. | ||
| 99 | func TestLessLeavesUnstampedBuildsUnordered(t *testing.T) { | ||
| 100 | unstamped := []string{ | ||
| 101 | "", | ||
| 102 | "dev", | ||
| 103 | "v0.0.3-dirty", | ||
| 104 | "v0.0.2-2-g68f804d-dirty", | ||
| 105 | "v0.0.4-pre", // no pre number | ||
| 106 | "v0.0.4-pre.", // empty pre number | ||
| 107 | "v0.0.4-pre.x", // pre number is not a number | ||
| 108 | "v0.0.4-pre.1.2", // pre number is not one component | ||
| 109 | "v0.0.4-pre.-1", // negative pre number | ||
| 110 | "v0.0.4-pre.1-dirty", // dirty past a pre-release | ||
| 111 | "v0.0.4-pre.1-3", // no g-abbrev past a pre-release | ||
| 112 | "v0.0.4-pre.1-g abc", // no commit count past a pre-release | ||
| 113 | "v0.0.4-prerelease.1", // not the pre. prefix | ||
| 114 | "v0.0.4-rc.1", // only "pre" is spelled this way | ||
| 115 | "v0.0.4-pre.99999999999999999999", // pre number out of range | ||
| 116 | "v0.0.2-x-g123", // commit count is not a number | ||
| 117 | "v0.0.2--2-g68f804d", // negative commit count | ||
| 118 | "v0.0.2-2", // no g-abbrev | ||
| 119 | "v0.0.2-2-g", // empty abbrev | ||
| 120 | "v0.0.2-2-gZZZ", // abbrev is not hex | ||
| 121 | "v0.0.2-2-68f804d", // abbrev missing its g | ||
| 122 | "v0.0.2-2-g68f804d-3", // trailing junk | ||
| 123 | "v-1.0.0", // negative major | ||
| 124 | "v0.-1.2", // negative minor | ||
| 125 | "v0.0", // too few components | ||
| 126 | "v0.0.2.1", // too many components | ||
| 127 | "v0.0.+2", // signed component | ||
| 128 | "v0.0.99999999999999999999", // component out of range | ||
| 129 | "v0.0.2-99999999999999999999-gabc", // commit count out of range | ||
| 130 | } | ||
| 131 | for _, u := range unstamped { | ||
| 132 | if Less(u, "v0.0.3") { | ||
| 133 | t.Errorf("Less(%q, %q) = true, want false", u, "v0.0.3") | ||
| 134 | } | ||
| 135 | if Less("v0.0.3", u) { | ||
| 136 | t.Errorf("Less(%q, %q) = true, want false", "v0.0.3", u) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | 16 | ||
| 141 | // TestCertifiesGuestHostKeys pins the capability question the create path asks | 17 | // TestCertifiesGuestHostKeys pins the capability question the create path asks |
| 142 | // of a host: does this agent generate a guest host key for the plane to sign? | 18 | // of a host: does this agent generate a guest host key for the plane to sign? |
| @@ -172,7 +48,7 @@ func TestCertifiesGuestHostKeysRefusesTheUnreadable(t *testing.T) { | |||
| 172 | // The floor is spelled as a version, and every refusal quotes it; a typo would | 48 | // The floor is spelled as a version, and every refusal quotes it; a typo would |
| 173 | // silently refuse (or admit) the whole fleet. | 49 | // silently refuse (or admit) the whole fleet. |
| 174 | func TestFirstCertifiedHostKeysIsAReleaseTag(t *testing.T) { | 50 | func TestFirstCertifiedHostKeysIsAReleaseTag(t *testing.T) { |
| 175 | if _, ok := parse(FirstCertifiedHostKeys); !ok { | 51 | if !version.Ordered(FirstCertifiedHostKeys) { |
| 176 | t.Fatalf("FirstCertifiedHostKeys = %q, which does not parse as a version", FirstCertifiedHostKeys) | 52 | t.Fatalf("FirstCertifiedHostKeys = %q, which does not parse as a version", FirstCertifiedHostKeys) |
| 177 | } | 53 | } |
| 178 | } | 54 | } |
| @@ -350,3 +226,55 @@ func TestPollWarmStartRetriesFastUntilFirstSuccess(t *testing.T) { | |||
| 350 | cancel() | 226 | cancel() |
| 351 | <-done | 227 | <-done |
| 352 | } | 228 | } |
| 229 | |||
| 230 | func TestFeatureSupportedBy(t *testing.T) { | ||
| 231 | f := Feature{Name: "thing", Since: "v0.0.4"} | ||
| 232 | for _, v := range []string{"v0.0.4", "v0.0.4-2-gabc1234", "v0.0.5", "v1.0.0"} { | ||
| 233 | assert.True(t, f.SupportedBy(v), v) | ||
| 234 | } | ||
| 235 | for _, v := range []string{"v0.0.3", "v0.0.4-pre.9", "v0.0.3-7-gabc1234", "", "dev", "v0.0.9-dirty", "latest"} { | ||
| 236 | assert.False(t, f.SupportedBy(v), v) | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | // A floor that cannot be read supports nothing: nothing can be proven | ||
| 241 | // against it. | ||
| 242 | func TestFeatureWithUnparsableFloorSupportsNothing(t *testing.T) { | ||
| 243 | f := Feature{Name: "pending", Since: "<next tag>"} | ||
| 244 | assert.False(t, f.SupportedBy("v9.9.9")) | ||
| 245 | } | ||
| 246 | |||
| 247 | // The named floor constants and the one surviving wrapper agree with the | ||
| 248 | // generic form. DatagramExposures has no wrapper left — refuseBelowFloor took | ||
| 249 | // over its only call site — so only its constant is checked here; the wrapper | ||
| 250 | // that used to be compared was removed rather than kept alive by this test. | ||
| 251 | func TestShippedFloorsAreFeatures(t *testing.T) { | ||
| 252 | assert.Equal(t, FirstCertifiedHostKeys, CertifiedHostKeys.Since) | ||
| 253 | assert.Equal(t, FirstDatagramExposures, DatagramExposures.Since) | ||
| 254 | for _, v := range []string{"", "v0.0.3", "v0.0.4", "v0.0.5", "v0.1.0"} { | ||
| 255 | assert.Equal(t, CertifiedHostKeys.SupportedBy(v), CertifiesGuestHostKeys(v), v) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | // Every feature's floor is spelled as a version; a typo would silently | ||
| 260 | // refuse (or admit) the whole fleet. | ||
| 261 | func TestFeatureFloorsParse(t *testing.T) { | ||
| 262 | for _, f := range shippedFeatures { | ||
| 263 | if !version.Ordered(f.Since) { | ||
| 264 | t.Errorf("%s floor %q does not parse", f.Name, f.Since) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 269 | // shippedFeatures is every floor the server admits against — the set each | ||
| 270 | // property below must hold for, so a new one cannot be added untested. | ||
| 271 | var shippedFeatures = []Feature{CertifiedHostKeys, DatagramExposures, Volumes} | ||
| 272 | |||
| 273 | // A refusal that only names a floor leaves the operator to guess why it | ||
| 274 | // matters, and the model on the other end of MCP with nothing to act on. Every | ||
| 275 | // shipped feature says what ignoring it costs. | ||
| 276 | func TestShippedFeaturesSayWhatIgnoringThemCosts(t *testing.T) { | ||
| 277 | for _, f := range shippedFeatures { | ||
| 278 | assert.NotEmpty(t, f.Consequence, "%s must say what happens if its floor is ignored", f.Name) | ||
| 279 | } | ||
| 280 | } | ||
internal/transport/fieldnumbers_test.go
| Old | New | ||
|---|---|---|---|
| @@ -128,10 +128,11 @@ var wireSchema = map[string]map[string]protoreflect.FieldNumber{ | |||
| 128 | "network": 20, | 128 | "network": 20, |
| 129 | }, | 129 | }, |
| 130 | "Snapshot": { | 130 | "Snapshot": { |
| 131 | "epoch": 1, | 131 | "epoch": 1, |
| 132 | "vms": 2, | 132 | "vms": 2, |
| 133 | "agent_upgrade": 3, | 133 | "agent_upgrade": 3, |
| 134 | "exposures": 4, | 134 | "exposures": 4, |
| 135 | "min_agent_version": 6, | ||
| 135 | }, | 136 | }, |
| 136 | "AgentUpgrade": { | 137 | "AgentUpgrade": { |
| 137 | "version": 1, | 138 | "version": 1, |
internal/version/order.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,158 @@ | |||
| 1 | package version | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strconv" | ||
| 5 | "strings" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // Less reports whether version a orders strictly before b. Versions are | ||
| 9 | // eitri's own tags — releases ("v0.0.3"), pre-releases of them ("v0.0.4-pre.1") | ||
| 10 | // — and the git-describe builds derived from either ("v0.0.3-5-gabc1234", | ||
| 11 | // "v0.0.4-pre.1-3-gabc1234", N commits past that tag). The whole chain orders | ||
| 12 | // the way it reads: | ||
| 13 | // | ||
| 14 | // v0.0.4-pre.1 < v0.0.4-pre.1-3-gabc1234 < v0.0.4-pre.2 < v0.0.4 < v0.0.4-2-gabc1234 | ||
| 15 | // | ||
| 16 | // So a hand-deployed "v0.0.2-2-g68f804d" orders before the "v0.0.3" release and | ||
| 17 | // takes the upgrade; a build ahead of the latest release never orders before it | ||
| 18 | // and is never offered a downgrade; and an agent on a pre-release takes the next | ||
| 19 | // pre-release and then the release itself, which is what lets a staging plane | ||
| 20 | // rehearse the upgrade path on the same tags a release cycle produces. | ||
| 21 | // | ||
| 22 | // Anything unparsable — "dev", a "-dirty" working tree, a malformed tag — never | ||
| 23 | // orders before anything, so an unstamped build never sees an upgrade, and an | ||
| 24 | // unstamped agent is never held below a version floor. | ||
| 25 | // | ||
| 26 | // Ordering lives here, beside the stamp it orders, because both planes need it: | ||
| 27 | // the control plane offers upgrades against it, and the agent measures itself | ||
| 28 | // against a snapshot's floor. Neither plane may import the other (arch R1), and | ||
| 29 | // this package is the shared leaf that already carries Version. | ||
| 30 | func Less(a, b string) bool { | ||
| 31 | pa, oka := parse(a) | ||
| 32 | pb, okb := parse(b) | ||
| 33 | if !oka || !okb { | ||
| 34 | return false | ||
| 35 | } | ||
| 36 | return before(pa, pb) | ||
| 37 | } | ||
| 38 | |||
| 39 | // Ordered reports whether v has a shape this ordering understands. It is the | ||
| 40 | // predicate behind Less's "unparsable orders before nothing" rule, exported so | ||
| 41 | // that a constant meant to BE a version — a feature floor, a release tag — can | ||
| 42 | // be asserted well-formed at the point it is written down rather than | ||
| 43 | // discovered wrong by a fleet that silently refuses or admits. | ||
| 44 | func Ordered(v string) bool { | ||
| 45 | _, ok := parse(v) | ||
| 46 | return ok | ||
| 47 | } | ||
| 48 | |||
| 49 | // before compares two ordering tuples component by component. | ||
| 50 | func before(a, b [6]int) bool { | ||
| 51 | for i := range a { | ||
| 52 | if a[i] != b[i] { | ||
| 53 | return a[i] < b[i] | ||
| 54 | } | ||
| 55 | } | ||
| 56 | return false | ||
| 57 | } | ||
| 58 | |||
| 59 | // parse reads a version into its ordering tuple. Four shapes are accepted: | ||
| 60 | // | ||
| 61 | // vX.Y.Z a release | ||
| 62 | // vX.Y.Z-pre.N the Nth pre-release leading up to it | ||
| 63 | // vX.Y.Z-C-g<hex> C commits past the release (git describe) | ||
| 64 | // vX.Y.Z-pre.N-C-g<hex> C commits past the pre-release | ||
| 65 | // | ||
| 66 | // The tuple is (X, Y, Z, final, pre, count). `final` is 1 for a release and 0 | ||
| 67 | // for a pre-release of it, which is the whole trick: it sinks every | ||
| 68 | // vX.Y.Z-pre.N below vX.Y.Z without disturbing how anything else sorts, and | ||
| 69 | // leaves builds derived from either in their own place. Both counters must be | ||
| 70 | // exactly a non-negative decimal, and a describe suffix must carry a | ||
| 71 | // "g"-prefixed non-empty hex abbrev — a "-dirty" marker or any other trailing | ||
| 72 | // text makes the version unparsable. | ||
| 73 | func parse(v string) ([6]int, bool) { | ||
| 74 | var out [6]int | ||
| 75 | core, suffix, hasSuffix := strings.Cut(strings.TrimPrefix(v, "v"), "-") | ||
| 76 | |||
| 77 | out[3] = 1 // a release outranks every pre-release of itself | ||
| 78 | if hasSuffix { | ||
| 79 | if pre, isPre := strings.CutPrefix(suffix, "pre."); isPre { | ||
| 80 | out[3] = 0 | ||
| 81 | num, rest, hasRest := strings.Cut(pre, "-") | ||
| 82 | n, ok := decimal(num) | ||
| 83 | if !ok { | ||
| 84 | return out, false | ||
| 85 | } | ||
| 86 | out[4] = n | ||
| 87 | suffix, hasSuffix = rest, hasRest | ||
| 88 | } | ||
| 89 | } | ||
| 90 | if hasSuffix { | ||
| 91 | count, abbrev, found := strings.Cut(suffix, "-") | ||
| 92 | if !found || !isGitAbbrev(abbrev) { | ||
| 93 | return out, false | ||
| 94 | } | ||
| 95 | n, ok := decimal(count) | ||
| 96 | if !ok { | ||
| 97 | return out, false | ||
| 98 | } | ||
| 99 | out[5] = n | ||
| 100 | } | ||
| 101 | |||
| 102 | parts := strings.SplitN(core, ".", 3) | ||
| 103 | if len(parts) != 3 { | ||
| 104 | return out, false | ||
| 105 | } | ||
| 106 | for i, p := range parts { | ||
| 107 | n, ok := decimal(p) | ||
| 108 | if !ok { | ||
| 109 | return out, false | ||
| 110 | } | ||
| 111 | out[i] = n | ||
| 112 | } | ||
| 113 | return out, true | ||
| 114 | } | ||
| 115 | |||
| 116 | // decimal parses one non-negative decimal component, rejecting the sign Atoi | ||
| 117 | // would otherwise accept and any value too large to hold. | ||
| 118 | func decimal(s string) (int, bool) { | ||
| 119 | if !isDecimal(s) { | ||
| 120 | return 0, false | ||
| 121 | } | ||
| 122 | n, err := strconv.Atoi(s) | ||
| 123 | if err != nil { | ||
| 124 | return 0, false | ||
| 125 | } | ||
| 126 | return n, true | ||
| 127 | } | ||
| 128 | |||
| 129 | // isDecimal reports whether s is a non-empty run of decimal digits — the sign | ||
| 130 | // Atoi would otherwise accept is not part of a version component. | ||
| 131 | func isDecimal(s string) bool { | ||
| 132 | if s == "" { | ||
| 133 | return false | ||
| 134 | } | ||
| 135 | for _, r := range s { | ||
| 136 | if r < '0' || r > '9' { | ||
| 137 | return false | ||
| 138 | } | ||
| 139 | } | ||
| 140 | return true | ||
| 141 | } | ||
| 142 | |||
| 143 | // isGitAbbrev reports whether s is a "g"-prefixed non-empty hex object abbrev, | ||
| 144 | // the shape git describe appends after the commit count. | ||
| 145 | func isGitAbbrev(s string) bool { | ||
| 146 | hex, ok := strings.CutPrefix(s, "g") | ||
| 147 | if !ok || hex == "" { | ||
| 148 | return false | ||
| 149 | } | ||
| 150 | for _, r := range hex { | ||
| 151 | switch { | ||
| 152 | case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': | ||
| 153 | default: | ||
| 154 | return false | ||
| 155 | } | ||
| 156 | } | ||
| 157 | return true | ||
| 158 | } | ||
internal/version/order_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,147 @@ | |||
| 1 | package version | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestLess(t *testing.T) { | ||
| 6 | cases := []struct { | ||
| 7 | a, b string | ||
| 8 | want bool | ||
| 9 | }{ | ||
| 10 | {"v0.0.1", "v0.0.2", true}, | ||
| 11 | {"v0.0.2", "v0.0.1", false}, | ||
| 12 | {"v0.0.2", "v0.0.2", false}, | ||
| 13 | {"v0.9.0", "v0.10.0", true}, // numeric, not lexicographic | ||
| 14 | {"dev", "v0.0.2", false}, // unparsable never upgrades | ||
| 15 | {"v0.0.1", "dev", false}, | ||
| 16 | {"", "v0.0.2", false}, | ||
| 17 | {"v0.0.2", "", false}, | ||
| 18 | } | ||
| 19 | for _, c := range cases { | ||
| 20 | if got := Less(c.a, c.b); got != c.want { | ||
| 21 | t.Errorf("Less(%q,%q) = %v, want %v", c.a, c.b, got, c.want) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | // TestLessOrdersGitDescribeBuilds pins the ordering of a git-describe build | ||
| 27 | // ("vX.Y.Z-N-g<hex>", N commits past tag vX.Y.Z) against a release tag: it | ||
| 28 | // sorts by (X, Y, Z, N), so a build derived from an older tag takes the | ||
| 29 | // upgrade while a build ahead of the latest release is never offered one. | ||
| 30 | func TestLessOrdersGitDescribeBuilds(t *testing.T) { | ||
| 31 | cases := []struct { | ||
| 32 | a, b string | ||
| 33 | want bool | ||
| 34 | }{ | ||
| 35 | // A hand-deployed agent two commits past v0.0.2 upgrades to v0.0.3. | ||
| 36 | {"v0.0.2-2-g68f804d", "v0.0.3", true}, | ||
| 37 | {"v0.0.3", "v0.0.2-2-g68f804d", false}, | ||
| 38 | // A dev build ahead of the latest release is never downgraded onto it. | ||
| 39 | {"v0.0.3-5-gabc1234", "v0.0.3", false}, | ||
| 40 | {"v0.0.3", "v0.0.3-5-gabc1234", true}, | ||
| 41 | // The commit count breaks ties within one tag. | ||
| 42 | {"v0.0.3-2-gabc1234", "v0.0.3-5-gabc1234", true}, | ||
| 43 | {"v0.0.3-5-gabc1234", "v0.0.3-2-gabc1234", false}, | ||
| 44 | {"v0.0.3-5-gabc1234", "v0.0.3-5-gabc1234", false}, | ||
| 45 | // A plain tag is the same build as zero commits past it. | ||
| 46 | {"v0.0.3-0-gabc1234", "v0.0.3", false}, | ||
| 47 | {"v0.0.3", "v0.0.3-0-gabc1234", false}, | ||
| 48 | // Release components still outrank the commit count. | ||
| 49 | {"v0.0.2-99-gabc1234", "v0.1.0", true}, | ||
| 50 | {"v0.1.0-1-gabc1234", "v0.0.2-99-gabc1234", false}, | ||
| 51 | // Uppercase hex is still a hex abbrev. | ||
| 52 | {"v0.0.2-2-gABC1234", "v0.0.3", true}, | ||
| 53 | } | ||
| 54 | for _, c := range cases { | ||
| 55 | if got := Less(c.a, c.b); got != c.want { | ||
| 56 | t.Errorf("Less(%q,%q) = %v, want %v", c.a, c.b, got, c.want) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | // TestLessOrdersPreReleases pins the chain a release cycle walks: each | ||
| 62 | // pre-release, the builds derived from it, the next pre-release, the release | ||
| 63 | // itself, and builds past that. An agent on a pre-release takes the next one | ||
| 64 | // and eventually the release, which is what lets a staging plane rehearse the | ||
| 65 | // upgrade path on the same tags the cycle produces. | ||
| 66 | func TestLessOrdersPreReleases(t *testing.T) { | ||
| 67 | // Every entry orders strictly before every entry after it. | ||
| 68 | chain := []string{ | ||
| 69 | "v0.0.3", | ||
| 70 | "v0.0.4-pre.1", | ||
| 71 | "v0.0.4-pre.1-3-gabc1234", | ||
| 72 | "v0.0.4-pre.2", | ||
| 73 | "v0.0.4-pre.10", // numeric, not lexicographic | ||
| 74 | "v0.0.4", | ||
| 75 | "v0.0.4-2-gabc1234", | ||
| 76 | "v0.0.5-pre.1", | ||
| 77 | } | ||
| 78 | for i, a := range chain { | ||
| 79 | for j, b := range chain { | ||
| 80 | want := i < j | ||
| 81 | if got := Less(a, b); got != want { | ||
| 82 | t.Errorf("Less(%q,%q) = %v, want %v", a, b, got, want) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | // TestLessLeavesUnstampedBuildsUnordered pins the shapes that stay | ||
| 89 | // unparsable: a dirty working tree or a malformed version never orders before | ||
| 90 | // anything, in either direction, so it is never offered an upgrade. | ||
| 91 | func TestLessLeavesUnstampedBuildsUnordered(t *testing.T) { | ||
| 92 | unstamped := []string{ | ||
| 93 | "", | ||
| 94 | "dev", | ||
| 95 | "v0.0.3-dirty", | ||
| 96 | "v0.0.2-2-g68f804d-dirty", | ||
| 97 | "v0.0.4-pre", // no pre number | ||
| 98 | "v0.0.4-pre.", // empty pre number | ||
| 99 | "v0.0.4-pre.x", // pre number is not a number | ||
| 100 | "v0.0.4-pre.1.2", // pre number is not one component | ||
| 101 | "v0.0.4-pre.-1", // negative pre number | ||
| 102 | "v0.0.4-pre.1-dirty", // dirty past a pre-release | ||
| 103 | "v0.0.4-pre.1-3", // no g-abbrev past a pre-release | ||
| 104 | "v0.0.4-pre.1-g abc", // no commit count past a pre-release | ||
| 105 | "v0.0.4-prerelease.1", // not the pre. prefix | ||
| 106 | "v0.0.4-rc.1", // only "pre" is spelled this way | ||
| 107 | "v0.0.4-pre.99999999999999999999", // pre number out of range | ||
| 108 | "v0.0.2-x-g123", // commit count is not a number | ||
| 109 | "v0.0.2--2-g68f804d", // negative commit count | ||
| 110 | "v0.0.2-2", // no g-abbrev | ||
| 111 | "v0.0.2-2-g", // empty abbrev | ||
| 112 | "v0.0.2-2-gZZZ", // abbrev is not hex | ||
| 113 | "v0.0.2-2-68f804d", // abbrev missing its g | ||
| 114 | "v0.0.2-2-g68f804d-3", // trailing junk | ||
| 115 | "v-1.0.0", // negative major | ||
| 116 | "v0.-1.2", // negative minor | ||
| 117 | "v0.0", // too few components | ||
| 118 | "v0.0.2.1", // too many components | ||
| 119 | "v0.0.+2", // signed component | ||
| 120 | "v0.0.99999999999999999999", // component out of range | ||
| 121 | "v0.0.2-99999999999999999999-gabc", // commit count out of range | ||
| 122 | } | ||
| 123 | for _, u := range unstamped { | ||
| 124 | if Less(u, "v0.0.3") { | ||
| 125 | t.Errorf("Less(%q, %q) = true, want false", u, "v0.0.3") | ||
| 126 | } | ||
| 127 | if Less("v0.0.3", u) { | ||
| 128 | t.Errorf("Less(%q, %q) = true, want false", "v0.0.3", u) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | // Ordered is the predicate behind Less's unparsable rule: it must agree with | ||
| 134 | // what Less can and cannot order, so a constant asserted well-formed through it | ||
| 135 | // is one Less will really rank. | ||
| 136 | func TestOrderedAgreesWithLess(t *testing.T) { | ||
| 137 | for _, v := range []string{"v0.0.3", "v0.0.4-pre.1", "v0.0.2-2-g68f804d"} { | ||
| 138 | if !Ordered(v) { | ||
| 139 | t.Errorf("Ordered(%q) = false, want true", v) | ||
| 140 | } | ||
| 141 | } | ||
| 142 | for _, v := range []string{"", "dev", "v0.0.3-dirty", "v0.0.4-rc.1"} { | ||
| 143 | if Ordered(v) { | ||
| 144 | t.Errorf("Ordered(%q) = true, want false", v) | ||
| 145 | } | ||
| 146 | } | ||
| 147 | } | ||
internal/version/version.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,12 @@ | |||
| 1 | // Package version carries the build-stamped eitri version, set via | 1 | // Package version carries the build-stamped eitri version, set via |
| 2 | // -ldflags "-X github.com/a73x/eitri/internal/version.Version=v0.0.2". | 2 | // -ldflags "-X github.com/a73x/eitri/internal/version.Version=v0.0.2". |
| 3 | // Unstamped builds report "dev" and never consider themselves upgradable. | 3 | // Unstamped builds report "dev" and never consider themselves upgradable. |
| 4 | // | ||
| 5 | // It also owns the ORDERING both planes rank versions against (see order.go): | ||
| 6 | // the control plane to decide whether a host is behind the latest release, the | ||
| 7 | // agent to decide whether it is below a snapshot's floor. Neither plane may | ||
| 8 | // import the other (arch R1), so the rule lives here, beside the stamp it | ||
| 9 | // ranks, and there is exactly one of it. | ||
| 4 | package version | 10 | package version |
| 5 | 11 | ||
| 6 | // Version is the running binary's version ("dev" when built without stamping). | 12 | // Version is the running binary's version ("dev" when built without stamping). |
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -200,6 +200,12 @@ message Snapshot { | |||
| 200 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned | 200 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned |
| 201 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade | 201 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade |
| 202 | repeated ExposureSpec exposures = 4; // FULL set for this host | 202 | repeated ExposureSpec exposures = 4; // FULL set for this host |
| 203 | // 5 is taken by volumes in the next change. | ||
| 204 | // min_agent_version is the lowest agent release that understands every | ||
| 205 | // field in this snapshot. An agent below it fails every VM here with a | ||
| 206 | // legible reason rather than materialising a spec it only half-reads. | ||
| 207 | // Empty means no floor. Agents from before this field ignore it. | ||
| 208 | string min_agent_version = 6; | ||
| 203 | } | 209 | } |
| 204 | 210 | ||
| 205 | // AgentUpgrade asks the agent to replace its own binary: download url, verify | 211 | // AgentUpgrade asks the agent to replace its own binary: download url, verify |
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -93,6 +93,9 @@ declare -A FLOOR=( | |||
| 93 | [internal/joinblob]=96 | 93 | [internal/joinblob]=96 |
| 94 | [internal/gateclient]=56 | 94 | [internal/gateclient]=56 |
| 95 | [internal/names]=74 | 95 | [internal/names]=74 |
| 96 | # internal/version carries the build stamp and the ordering both planes read | ||
| 97 | # it with; the ordering is pure and exhaustively table-tested. | ||
| 98 | [internal/version]=100 | ||
| 96 | # internal/random has no tests of its own: it is exercised only through the | 99 | # internal/random has no tests of its own: it is exercised only through the |
| 97 | # packages that call it, so it reports 0.0% here. A real floor would be a | 100 | # packages that call it, so it reports 0.0% here. A real floor would be a |
| 98 | # lie. The 0 floor keeps it COUNTED — so the renamed/removed-package guard | 101 | # lie. The 0 floor keeps it COUNTED — so the renamed/removed-package guard |