a73x

7bf37937

release: one Feature shape for every version floor

a73x   2026-09-05 17:43

Commit message
release: one Feature shape for every version floor

Two floors were four copies each: a constant, a predicate, an admission
branch and a bespoke refusal. release.Feature carries the name, the tag
and what ignoring the floor costs; SupportedBy orders against it; and
api.refuseBelowFloor is the one 409 every floored feature writes. Whether
silence from a host refuses is the only thing that varies between
features, so it is the one parameter.

Ordering moves beside the stamp in internal/version, where both planes
can read it — the agent may not import the control plane.

Snapshot.min_agent_version closes the other half: an agent below the
floor fails the snapshot's VMs legibly instead of materialising a spec
it half-reads, asserts nothing about guests it did not inspect, and
keeps converging the exposures it fully understands.

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