707f1a44
feat: a host tells the fleet which subnet its guests are on
a73x 2026-08-06 09:12
Commit message
internal/agent/run/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -47,6 +47,7 @@ func hostRunner(ctx context.Context, name string, args ...string) (string, error | |||
| 47 | type Config struct { | 47 | type Config struct { |
| 48 | StateDir, CHBin, Firmware string | 48 | StateDir, CHBin, Firmware string |
| 49 | VfkitBin string | 49 | VfkitBin string |
| 50 | BridgeCIDR string | ||
| 50 | BootstrapURL string | 51 | BootstrapURL string |
| 51 | TombstoneGrace, VanishGrace time.Duration | 52 | TombstoneGrace, VanishGrace time.Duration |
| 52 | VMTimeout time.Duration | 53 | VMTimeout time.Duration |
| @@ -102,6 +103,7 @@ func parseConfig(args []string) (Config, []string, error) { | |||
| 102 | // Silicon and /usr/local/bin on Intel, so $PATH is the only answer that is | 103 | // Silicon and /usr/local/bin on Intel, so $PATH is the only answer that is |
| 103 | // right on both. | 104 | // right on both. |
| 104 | vfkitBin := fs.String("vfkit-bin", "vfkit", "path to the vfkit binary (macOS hosts)") | 105 | vfkitBin := fs.String("vfkit-bin", "vfkit", "path to the vfkit binary (macOS hosts)") |
| 106 | bridgeCIDR := fs.String("bridge-cidr", "", "the subnet this host's guests are on (Linux); empty adopts the control plane's suggestion at enrollment. Ignored where the host OS owns the guest network") | ||
| 105 | bootstrapURL := fs.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)") | 107 | bootstrapURL := fs.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)") |
| 106 | tombstoneGrace := fs.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") | 108 | tombstoneGrace := fs.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") |
| 107 | vanishGrace := fs.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") | 109 | vanishGrace := fs.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") |
| @@ -135,6 +137,7 @@ func parseConfig(args []string) (Config, []string, error) { | |||
| 135 | CHBin: *chBin, | 137 | CHBin: *chBin, |
| 136 | Firmware: *firmware, | 138 | Firmware: *firmware, |
| 137 | VfkitBin: *vfkitBin, | 139 | VfkitBin: *vfkitBin, |
| 140 | BridgeCIDR: *bridgeCIDR, | ||
| 138 | BootstrapURL: *bootstrapURL, | 141 | BootstrapURL: *bootstrapURL, |
| 139 | TombstoneGrace: *tombstoneGrace, | 142 | TombstoneGrace: *tombstoneGrace, |
| 140 | VanishGrace: *vanishGrace, | 143 | VanishGrace: *vanishGrace, |
| @@ -209,7 +212,7 @@ func serve(st *state.Store, cfg Config) error { | |||
| 209 | // from the live agent without bouncing the process. | 212 | // from the live agent without bouncing the process. |
| 210 | covsnap.Install(ctx) | 213 | covsnap.Install(ctx) |
| 211 | 214 | ||
| 212 | plat, err := newPlatform(ctx, cfg, st, id.BridgeCIDR) | 215 | plat, err := newPlatform(ctx, cfg, st) |
| 213 | if err != nil { | 216 | if err != nil { |
| 214 | return err | 217 | return err |
| 215 | } | 218 | } |
| @@ -276,6 +279,7 @@ func serve(st *state.Store, cfg Config) error { | |||
| 276 | Identity: id, | 279 | Identity: id, |
| 277 | StateDir: cfg.StateDir, | 280 | StateDir: cfg.StateDir, |
| 278 | Provisioner: platformProvisioner, | 281 | Provisioner: platformProvisioner, |
| 282 | GuestCIDR: plat.GuestCIDR, | ||
| 279 | Runner: hostRunner, | 283 | Runner: hostRunner, |
| 280 | Console: pumps, | 284 | Console: pumps, |
| 281 | MaxVCPUs: cfg.MaxVCPUs, | 285 | MaxVCPUs: cfg.MaxVCPUs, |
| @@ -283,7 +287,7 @@ func serve(st *state.Store, cfg Config) error { | |||
| 283 | MaxDiskGB: cfg.MaxDiskGB, | 287 | MaxDiskGB: cfg.MaxDiskGB, |
| 284 | } | 288 | } |
| 285 | 289 | ||
| 286 | slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) | 290 | slog.Info("agent started", "host_id", id.HostID, "guest_cidr", plat.GuestCIDR()) |
| 287 | client.Run(ctx) | 291 | client.Run(ctx) |
| 288 | 292 | ||
| 289 | // client.Run blocks until ctx is cancelled (SIGINT/SIGTERM) and only then | 293 | // client.Run blocks until ctx is cancelled (SIGINT/SIGTERM) and only then |
internal/agent/run/guestcidr.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,60 @@ | |||
| 1 | package run | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "log/slog" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/agent/state" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // resolveGuestCIDR decides which subnet this host's guests are on, and persists | ||
| 10 | // the answer the first time it produces one the identity lacks. | ||
| 11 | // | ||
| 12 | // Order, fixed: | ||
| 13 | // | ||
| 14 | // 1. Identity.BridgeCIDR — the durable record, and it wins whenever present. | ||
| 15 | // 2. --bridge-cidr — the operator's explicit opinion at first start. | ||
| 16 | // 3. suggestion — what the control plane offered at enrollment (empty at any | ||
| 17 | // other time; the suggestion only exists in the enroll response). | ||
| 18 | // | ||
| 19 | // Rule 1 outranking the flag is the whole of "re-homing an existing host is not | ||
| 20 | // something this makes easy". Letting a flag override a persisted identity | ||
| 21 | // would move the bridge under live guests, and every one of them would lose its | ||
| 22 | // address on the next restart. A deliberate re-home is an edit to identity.json. | ||
| 23 | // | ||
| 24 | // join and serve both call this, which is the point: they are separate | ||
| 25 | // processes that used to source the subnet differently — join never had one and | ||
| 26 | // serve read the identity — so `eitri-agent join --bridge-cidr X` followed by a | ||
| 27 | // plain `eitri-agent` could silently re-home the host on the next start. | ||
| 28 | // | ||
| 29 | // It returns "" when nothing has an opinion. That is a real answer on a | ||
| 30 | // platform whose OS owns the guest network and takes no suggestion; a platform | ||
| 31 | // that must BUILD one supplies its own last resort, because a fabricated subnet | ||
| 32 | // is only ever right where something is going to create it. | ||
| 33 | func resolveGuestCIDR(st *state.Store, cfg Config, suggestion string) string { | ||
| 34 | if id, ok := st.Identity(); ok && id.BridgeCIDR != "" { | ||
| 35 | return id.BridgeCIDR | ||
| 36 | } | ||
| 37 | for _, candidate := range []string{cfg.BridgeCIDR, suggestion} { | ||
| 38 | if candidate == "" { | ||
| 39 | continue | ||
| 40 | } | ||
| 41 | persistGuestCIDR(st, candidate) | ||
| 42 | return candidate | ||
| 43 | } | ||
| 44 | return "" | ||
| 45 | } | ||
| 46 | |||
| 47 | // persistGuestCIDR writes the resolved subnet into the durable identity so the | ||
| 48 | // next start reads it from rule 1 rather than re-deriving it. Best-effort: a | ||
| 49 | // failed write leaves the agent running on the value it resolved, and the next | ||
| 50 | // start resolves the same way from the same inputs. | ||
| 51 | func persistGuestCIDR(st *state.Store, cidr string) { | ||
| 52 | id, ok := st.Identity() | ||
| 53 | if !ok || id.BridgeCIDR == cidr { | ||
| 54 | return | ||
| 55 | } | ||
| 56 | id.BridgeCIDR = cidr | ||
| 57 | if err := st.SaveIdentity(id); err != nil { | ||
| 58 | slog.Warn("persist guest cidr", "cidr", cidr, "err", err) | ||
| 59 | } | ||
| 60 | } | ||
internal/agent/run/guestcidr_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,64 @@ | |||
| 1 | package run | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | |||
| 6 | "github.com/a73x/eitri/internal/agent/state" | ||
| 7 | "github.com/stretchr/testify/assert" | ||
| 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func storeWithIdentity(t *testing.T, cidr string) *state.Store { | ||
| 12 | t.Helper() | ||
| 13 | st, err := state.Open(t.TempDir()) | ||
| 14 | require.NoError(t, err) | ||
| 15 | require.NoError(t, st.SaveIdentity(state.Identity{HostID: "h1", BridgeCIDR: cidr})) | ||
| 16 | return st | ||
| 17 | } | ||
| 18 | |||
| 19 | // TestResolveGuestCIDRPrecedence pins D2's ladder, and in particular that a | ||
| 20 | // persisted identity outranks the flag. Letting a flag win would move the | ||
| 21 | // bridge under live guests, and every one of them would lose its address on the | ||
| 22 | // next restart — so a deliberate re-home is an edit to identity.json, not a | ||
| 23 | // flag. | ||
| 24 | func TestResolveGuestCIDRPrecedence(t *testing.T) { | ||
| 25 | t.Run("the durable record wins over everything", func(t *testing.T) { | ||
| 26 | st := storeWithIdentity(t, "10.20.0.0/24") | ||
| 27 | got := resolveGuestCIDR(st, Config{BridgeCIDR: "10.30.0.0/24"}, "10.40.0.0/24") | ||
| 28 | assert.Equal(t, "10.20.0.0/24", got) | ||
| 29 | }) | ||
| 30 | |||
| 31 | t.Run("the flag wins over the suggestion", func(t *testing.T) { | ||
| 32 | st := storeWithIdentity(t, "") | ||
| 33 | got := resolveGuestCIDR(st, Config{BridgeCIDR: "10.30.0.0/24"}, "10.40.0.0/24") | ||
| 34 | assert.Equal(t, "10.30.0.0/24", got) | ||
| 35 | }) | ||
| 36 | |||
| 37 | t.Run("the suggestion is taken when nothing else has an opinion", func(t *testing.T) { | ||
| 38 | st := storeWithIdentity(t, "") | ||
| 39 | assert.Equal(t, "10.40.0.0/24", resolveGuestCIDR(st, Config{}, "10.40.0.0/24")) | ||
| 40 | }) | ||
| 41 | |||
| 42 | // "" is a real answer, not a failure: a platform whose OS owns the guest | ||
| 43 | // network takes no suggestion and goes and looks instead. | ||
| 44 | t.Run("nothing has an opinion", func(t *testing.T) { | ||
| 45 | st := storeWithIdentity(t, "") | ||
| 46 | assert.Empty(t, resolveGuestCIDR(st, Config{}, "")) | ||
| 47 | }) | ||
| 48 | } | ||
| 49 | |||
| 50 | // TestResolveGuestCIDRPersists pins that join and serve cannot disagree. They | ||
| 51 | // are separate processes that used to source the subnet differently, so | ||
| 52 | // `eitri-agent join --bridge-cidr X` followed by a plain `eitri-agent` could | ||
| 53 | // re-home the host on its next start. | ||
| 54 | func TestResolveGuestCIDRPersists(t *testing.T) { | ||
| 55 | st := storeWithIdentity(t, "") | ||
| 56 | require.Equal(t, "10.30.0.0/24", resolveGuestCIDR(st, Config{BridgeCIDR: "10.30.0.0/24"}, "")) | ||
| 57 | |||
| 58 | id, ok := st.Identity() | ||
| 59 | require.True(t, ok) | ||
| 60 | assert.Equal(t, "10.30.0.0/24", id.BridgeCIDR, "the resolved subnet must be durable") | ||
| 61 | |||
| 62 | // A later start with no flag at all resolves the same, from rule 1. | ||
| 63 | assert.Equal(t, "10.30.0.0/24", resolveGuestCIDR(st, Config{}, "")) | ||
| 64 | } | ||
internal/agent/run/wire_darwin.go
| Old | New | ||
|---|---|---|---|
| @@ -21,6 +21,9 @@ const platformProvisioner = "vfkit" | |||
| 21 | type platform struct { | 21 | type platform struct { |
| 22 | Prov reconcile.Provisioner | 22 | Prov reconcile.Provisioner |
| 23 | Pumps *serialpump.Manager | 23 | Pumps *serialpump.Manager |
| 24 | // GuestCIDR reports the subnet this host's guests are on, asked once per | ||
| 25 | // report rather than resolved at startup. See the sync client's field. | ||
| 26 | GuestCIDR func() string | ||
| 24 | } | 27 | } |
| 25 | 28 | ||
| 26 | // newPlatform builds the macOS backend: vfkit over Apple's | 29 | // newPlatform builds the macOS backend: vfkit over Apple's |
| @@ -29,17 +32,20 @@ type platform struct { | |||
| 29 | // It does nothing before returning, and the two things it does not do are the | 32 | // It does nothing before returning, and the two things it does not do are the |
| 30 | // whole difference from Linux. There is no host networking to set up: vmnet's | 33 | // whole difference from Linux. There is no host networking to set up: vmnet's |
| 31 | // NAT and its bootpd are already running, own the subnet, and hand out the | 34 | // NAT and its bootpd are already running, own the subnet, and hand out the |
| 32 | // addresses — which is why bridgeCIDR, the CIDR the server assigned this host, | 35 | // addresses — so this host observes its guest network rather than building one. |
| 33 | // is accepted for signature parity and then ignored. And there is no runtime to | 36 | // And there is no runtime to bootstrap: cloud-hypervisor is a binary the agent |
| 34 | // bootstrap: cloud-hypervisor is a binary the agent can fetch and install, | 37 | // can fetch and install, while vfkit works only if it carries Apple's |
| 35 | // while vfkit works only if it carries Apple's virtualization entitlement, and | 38 | // virtualization entitlement, and an entitlement lives in a signature we cannot |
| 36 | // an entitlement lives in a signature we cannot produce. So a Mac without vfkit | 39 | // produce. So a Mac without vfkit is not one the agent can fix at startup — it |
| 37 | // is not one the agent can fix at startup — it is one whose provisioner refuses | 40 | // is one whose provisioner refuses at Preflight, in a sentence naming the |
| 38 | // at Preflight, in a sentence naming the install. | 41 | // install. |
| 39 | func newPlatform(_ context.Context, cfg Config, st *state.Store, _ string) (platform, error) { | 42 | func newPlatform(_ context.Context, cfg Config, st *state.Store) (platform, error) { |
| 40 | prov := vfkit.New(st, cfg.VfkitBin, hostRunner) | 43 | prov := vfkit.New(st, cfg.VfkitBin, hostRunner) |
| 41 | pumps := serialpump.NewManager(vfkit.NewConsoleSource(prov.SocketPath), st.SerialLogPath) | 44 | pumps := serialpump.NewManager(vfkit.NewConsoleSource(prov.SocketPath), st.SerialLogPath) |
| 42 | prov.Pumps = pumps | 45 | prov.Pumps = pumps |
| 43 | 46 | ||
| 44 | return platform{Prov: prov, Pumps: pumps}, nil | 47 | // Reading vmnet's subnet lands in a later commit; until then this host has |
| 48 | // no answer, which by the empty-means-no-answer rule leaves the fleet's | ||
| 49 | // record alone rather than erasing it. | ||
| 50 | return platform{Prov: prov, Pumps: pumps, GuestCIDR: func() string { return "" }}, nil | ||
| 45 | } | 51 | } |
internal/agent/run/wire_linux.go
| Old | New | ||
|---|---|---|---|
| @@ -40,15 +40,33 @@ const platformProvisioner = "cloudhv" | |||
| 40 | type platform struct { | 40 | type platform struct { |
| 41 | Prov reconcile.Provisioner | 41 | Prov reconcile.Provisioner |
| 42 | Pumps *serialpump.Manager | 42 | Pumps *serialpump.Manager |
| 43 | // GuestCIDR reports the subnet this host's guests are on, asked once per | ||
| 44 | // report. Linux genuinely knows at startup — it builds the bridge — so this | ||
| 45 | // returns a constant; the poll shape costs it nothing and lets a platform | ||
| 46 | // whose OS owns the network answer late, or not at all. | ||
| 47 | GuestCIDR func() string | ||
| 43 | } | 48 | } |
| 44 | 49 | ||
| 50 | // lastResortGuestCIDR is used only when nothing else produced one: no persisted | ||
| 51 | // identity, no flag, and no suggestion from the control plane. It is a last | ||
| 52 | // resort and not "the default" — a host that reaches it is a host the fleet | ||
| 53 | // never advised, and two of them on one network would collide. | ||
| 54 | // | ||
| 55 | // It lives here, not in the resolver, because a fabricated subnet is only ever | ||
| 56 | // right on a platform that is about to CREATE the network it names. A host | ||
| 57 | // whose OS already owns the guest network must answer "" and go and look. | ||
| 58 | const lastResortGuestCIDR = "10.77.1.0/24" | ||
| 59 | |||
| 45 | // newPlatform builds the Linux backend: cloud-hypervisor over a bridge and tap, | 60 | // newPlatform builds the Linux backend: cloud-hypervisor over a bridge and tap, |
| 46 | // with the guest console on cloud-hypervisor's serial socket. bridgeCIDR is the | 61 | // with the guest console on cloud-hypervisor's serial socket. A bare host that |
| 47 | // server-assigned CIDR from the enrolled identity (state.Store.Identity), not a | 62 | // just joined has neither the hypervisor nor its UEFI firmware, so newPlatform |
| 48 | // Config flag; a second platform's newPlatform must source it the same way. A | 63 | // installs them before anything can try to launch a VM. |
| 49 | // bare host that just joined has neither the hypervisor nor its UEFI firmware, | 64 | func newPlatform(ctx context.Context, cfg Config, st *state.Store) (platform, error) { |
| 50 | // so newPlatform installs them before anything can try to launch a VM. | 65 | bridgeCIDR := resolveGuestCIDR(st, cfg, "") |
| 51 | func newPlatform(ctx context.Context, cfg Config, st *state.Store, bridgeCIDR string) (platform, error) { | 66 | if bridgeCIDR == "" { |
| 67 | bridgeCIDR = lastResortGuestCIDR | ||
| 68 | persistGuestCIDR(st, bridgeCIDR) | ||
| 69 | } | ||
| 52 | net, err := netenv.New(hostRunner, bridgeCIDR) | 70 | net, err := netenv.New(hostRunner, bridgeCIDR) |
| 53 | if err != nil { | 71 | if err != nil { |
| 54 | return platform{}, fmt.Errorf("netenv init: %w", err) | 72 | return platform{}, fmt.Errorf("netenv init: %w", err) |
| @@ -79,5 +97,6 @@ func newPlatform(ctx context.Context, cfg Config, st *state.Store, bridgeCIDR st | |||
| 79 | pumps := serialpump.NewManager(cloudhv.ConsoleSource(st.SerialSocketPath), st.SerialLogPath) | 97 | pumps := serialpump.NewManager(cloudhv.ConsoleSource(st.SerialSocketPath), st.SerialLogPath) |
| 80 | prov.Pumps = pumps | 98 | prov.Pumps = pumps |
| 81 | 99 | ||
| 82 | return platform{Prov: prov, Pumps: pumps}, nil | 100 | return platform{Prov: prov, Pumps: pumps, |
| 101 | GuestCIDR: func() string { return bridgeCIDR }}, nil | ||
| 83 | } | 102 | } |
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -64,6 +64,10 @@ type Client struct { | |||
| 64 | // server stores comes from enroll, not from here. | 64 | // server stores comes from enroll, not from here. |
| 65 | Provisioner string | 65 | Provisioner string |
| 66 | 66 | ||
| 67 | // GuestCIDR reports the subnet this host's guests are on, asked once per | ||
| 68 | // report. Nil, or an empty return, means "no answer" — never "no network". | ||
| 69 | GuestCIDR func() string | ||
| 70 | |||
| 67 | // Runner executes host-introspection subprocesses (on Linux, | 71 | // Runner executes host-introspection subprocesses (on Linux, |
| 68 | // systemd-detect-virt via hostinfo). Injected so this data-plane package | 72 | // systemd-detect-virt via hostinfo). Injected so this data-plane package |
| 69 | // never imports os/exec (R6); nil is tolerated (virt reported as unknown). | 73 | // never imports os/exec (R6); nil is tolerated (virt reported as unknown). |
| @@ -293,7 +297,7 @@ func (c *Client) session(ctx context.Context) error { | |||
| 293 | hostname, _ := os.Hostname() | 297 | hostname, _ := os.Hostname() |
| 294 | hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ | 298 | hello := &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{ |
| 295 | HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, | 299 | HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, |
| 296 | Provisioner: c.Provisioner, BridgeCidr: c.Identity.BridgeCIDR, | 300 | Provisioner: c.Provisioner, |
| 297 | LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), | 301 | LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), |
| 298 | Facts: helloFacts(ctx, c.Runner), | 302 | Facts: helloFacts(ctx, c.Runner), |
| 299 | Credential: c.Identity.Credential, | 303 | Credential: c.Identity.Credential, |
| @@ -351,6 +355,13 @@ func (c *Client) session(ctx context.Context) error { | |||
| 351 | rep := c.Engine.Step(ctx, snap) | 355 | rep := c.Engine.Step(ctx, snap) |
| 352 | rep.Capacity = c.advertisedCapacity(stateDir) | 356 | rep.Capacity = c.advertisedCapacity(stateDir) |
| 353 | rep.Metrics = hostinfo.Metrics(stateDir) | 357 | rep.Metrics = hostinfo.Metrics(stateDir) |
| 358 | // Polled, not resolved once: a host's guest subnet can change while the | ||
| 359 | // agent stays connected, and on a platform whose OS owns the network it | ||
| 360 | // may not be knowable at connect time at all. Empty is "no answer" and | ||
| 361 | // leaves the fleet's record alone. | ||
| 362 | if c.GuestCIDR != nil { | ||
| 363 | rep.GuestCidr = c.GuestCIDR() | ||
| 364 | } | ||
| 354 | return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) | 365 | return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) |
| 355 | } | 366 | } |
| 356 | 367 | ||
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -234,17 +234,17 @@ func (*ServerMessage_ConsoleOpen) isServerMessage_Msg() {} | |||
| 234 | func (*ServerMessage_TcpOpen) isServerMessage_Msg() {} | 234 | func (*ServerMessage_TcpOpen) isServerMessage_Msg() {} |
| 235 | 235 | ||
| 236 | type Hello struct { | 236 | type Hello struct { |
| 237 | state protoimpl.MessageState `protogen:"open.v1"` | 237 | state protoimpl.MessageState `protogen:"open.v1"` |
| 238 | HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` | 238 | HostId string `protobuf:"bytes,1,opt,name=host_id,json=hostId,proto3" json:"host_id,omitempty"` |
| 239 | Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` | 239 | Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` |
| 240 | Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"` | 240 | Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"` |
| 241 | Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"` | 241 | Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"` |
| 242 | Provisioner string `protobuf:"bytes,5,opt,name=provisioner,proto3" json:"provisioner,omitempty"` // "cloudhv" | 242 | Provisioner string `protobuf:"bytes,5,opt,name=provisioner,proto3" json:"provisioner,omitempty"` // "cloudhv" |
| 243 | BridgeCidr string `protobuf:"bytes,6,opt,name=bridge_cidr,json=bridgeCidr,proto3" json:"bridge_cidr,omitempty"` // echo of server-assigned CIDR | 243 | // ActualStateReport.guest_cidr. |
| 244 | LastSeenEpoch uint64 `protobuf:"varint,7,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` // for the restore runbook | 244 | LastSeenEpoch uint64 `protobuf:"varint,7,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` // for the restore runbook |
| 245 | Capacity *Capacity `protobuf:"bytes,8,opt,name=capacity,proto3" json:"capacity,omitempty"` | 245 | Capacity *Capacity `protobuf:"bytes,8,opt,name=capacity,proto3" json:"capacity,omitempty"` |
| 246 | Credential string `protobuf:"bytes,9,opt,name=credential,proto3" json:"credential,omitempty"` // Bearer host credential, verified in first frame | 246 | Credential string `protobuf:"bytes,9,opt,name=credential,proto3" json:"credential,omitempty"` // Bearer host credential, verified in first frame |
| 247 | Facts *HostFacts `protobuf:"bytes,10,opt,name=facts,proto3" json:"facts,omitempty"` // best-effort static host identity; refreshed each Hello | 247 | Facts *HostFacts `protobuf:"bytes,10,opt,name=facts,proto3" json:"facts,omitempty"` // best-effort static host identity; refreshed each Hello |
| 248 | unknownFields protoimpl.UnknownFields | 248 | unknownFields protoimpl.UnknownFields |
| 249 | sizeCache protoimpl.SizeCache | 249 | sizeCache protoimpl.SizeCache |
| 250 | } | 250 | } |
| @@ -314,13 +314,6 @@ func (x *Hello) GetProvisioner() string { | |||
| 314 | return "" | 314 | return "" |
| 315 | } | 315 | } |
| 316 | 316 | ||
| 317 | func (x *Hello) GetBridgeCidr() string { | ||
| 318 | if x != nil { | ||
| 319 | return x.BridgeCidr | ||
| 320 | } | ||
| 321 | return "" | ||
| 322 | } | ||
| 323 | |||
| 324 | func (x *Hello) GetLastSeenEpoch() uint64 { | 317 | func (x *Hello) GetLastSeenEpoch() uint64 { |
| 325 | if x != nil { | 318 | if x != nil { |
| 326 | return x.LastSeenEpoch | 319 | return x.LastSeenEpoch |
| @@ -610,7 +603,7 @@ type ActualVM struct { | |||
| 610 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 603 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| 611 | Power string `protobuf:"bytes,2,opt,name=power,proto3" json:"power,omitempty"` // "running"|"stopped" | 604 | Power string `protobuf:"bytes,2,opt,name=power,proto3" json:"power,omitempty"` // "running"|"stopped" |
| 612 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" | 605 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" |
| 613 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // agent-allocated; server validates within bridge_cidr | 606 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // the address this guest has, however its host came by it |
| 614 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` | 607 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` |
| 615 | unknownFields protoimpl.UnknownFields | 608 | unknownFields protoimpl.UnknownFields |
| 616 | sizeCache protoimpl.SizeCache | 609 | sizeCache protoimpl.SizeCache |
| @@ -760,8 +753,17 @@ type ActualStateReport struct { | |||
| 760 | FenceViolation bool `protobuf:"varint,5,opt,name=fence_violation,json=fenceViolation,proto3" json:"fence_violation,omitempty"` | 753 | FenceViolation bool `protobuf:"varint,5,opt,name=fence_violation,json=fenceViolation,proto3" json:"fence_violation,omitempty"` |
| 761 | LastSeenEpoch uint64 `protobuf:"varint,6,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` | 754 | LastSeenEpoch uint64 `protobuf:"varint,6,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` |
| 762 | Metrics *HostMetrics `protobuf:"bytes,7,opt,name=metrics,proto3" json:"metrics,omitempty"` // live measured host utilization (heartbeat) | 755 | Metrics *HostMetrics `protobuf:"bytes,7,opt,name=metrics,proto3" json:"metrics,omitempty"` // live measured host utilization (heartbeat) |
| 763 | unknownFields protoimpl.UnknownFields | 756 | // The subnet this host's guests are on, as the HOST observes it. Empty means |
| 764 | sizeCache protoimpl.SizeCache | 757 | // "not yet known", never "no network" — a host that cannot answer leaves the |
| 758 | // fleet's record alone rather than erasing it. | ||
| 759 | // | ||
| 760 | // In the report rather than Hello because it can change while an agent stays | ||
| 761 | // connected (an admin edits the host's network), and on some platforms is not | ||
| 762 | // knowable at connect time at all. Capacity is in both messages for the same | ||
| 763 | // reason: an opening value, then the ongoing truth. | ||
| 764 | GuestCidr string `protobuf:"bytes,8,opt,name=guest_cidr,json=guestCidr,proto3" json:"guest_cidr,omitempty"` | ||
| 765 | unknownFields protoimpl.UnknownFields | ||
| 766 | sizeCache protoimpl.SizeCache | ||
| 765 | } | 767 | } |
| 766 | 768 | ||
| 767 | func (x *ActualStateReport) Reset() { | 769 | func (x *ActualStateReport) Reset() { |
| @@ -843,6 +845,13 @@ func (x *ActualStateReport) GetMetrics() *HostMetrics { | |||
| 843 | return nil | 845 | return nil |
| 844 | } | 846 | } |
| 845 | 847 | ||
| 848 | func (x *ActualStateReport) GetGuestCidr() string { | ||
| 849 | if x != nil { | ||
| 850 | return x.GuestCidr | ||
| 851 | } | ||
| 852 | return "" | ||
| 853 | } | ||
| 854 | |||
| 846 | type VMDesired struct { | 855 | type VMDesired struct { |
| 847 | state protoimpl.MessageState `protogen:"open.v1"` | 856 | state protoimpl.MessageState `protogen:"open.v1"` |
| 848 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 857 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| @@ -1351,22 +1360,20 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1351 | "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshot\x12:\n" + | 1360 | "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshot\x12:\n" + |
| 1352 | "\fconsole_open\x18\x02 \x01(\v2\x15.eitri.v1.ConsoleOpenH\x00R\vconsoleOpen\x12.\n" + | 1361 | "\fconsole_open\x18\x02 \x01(\v2\x15.eitri.v1.ConsoleOpenH\x00R\vconsoleOpen\x12.\n" + |
| 1353 | "\btcp_open\x18\x03 \x01(\v2\x11.eitri.v1.TCPOpenH\x00R\atcpOpenB\x05\n" + | 1362 | "\btcp_open\x18\x03 \x01(\v2\x11.eitri.v1.TCPOpenH\x00R\atcpOpenB\x05\n" + |
| 1354 | "\x03msg\"\xc6\x02\n" + | 1363 | "\x03msg\"\xb8\x02\n" + |
| 1355 | "\x05Hello\x12\x17\n" + | 1364 | "\x05Hello\x12\x17\n" + |
| 1356 | "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + | 1365 | "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + |
| 1357 | "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x0e\n" + | 1366 | "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x0e\n" + |
| 1358 | "\x02os\x18\x03 \x01(\tR\x02os\x12\x12\n" + | 1367 | "\x02os\x18\x03 \x01(\tR\x02os\x12\x12\n" + |
| 1359 | "\x04arch\x18\x04 \x01(\tR\x04arch\x12 \n" + | 1368 | "\x04arch\x18\x04 \x01(\tR\x04arch\x12 \n" + |
| 1360 | "\vprovisioner\x18\x05 \x01(\tR\vprovisioner\x12\x1f\n" + | 1369 | "\vprovisioner\x18\x05 \x01(\tR\vprovisioner\x12&\n" + |
| 1361 | "\vbridge_cidr\x18\x06 \x01(\tR\n" + | ||
| 1362 | "bridgeCidr\x12&\n" + | ||
| 1363 | "\x0flast_seen_epoch\x18\a \x01(\x04R\rlastSeenEpoch\x12.\n" + | 1370 | "\x0flast_seen_epoch\x18\a \x01(\x04R\rlastSeenEpoch\x12.\n" + |
| 1364 | "\bcapacity\x18\b \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12\x1e\n" + | 1371 | "\bcapacity\x18\b \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12\x1e\n" + |
| 1365 | "\n" + | 1372 | "\n" + |
| 1366 | "credential\x18\t \x01(\tR\n" + | 1373 | "credential\x18\t \x01(\tR\n" + |
| 1367 | "credential\x12)\n" + | 1374 | "credential\x12)\n" + |
| 1368 | "\x05facts\x18\n" + | 1375 | "\x05facts\x18\n" + |
| 1369 | " \x01(\v2\x13.eitri.v1.HostFactsR\x05facts\"P\n" + | 1376 | " \x01(\v2\x13.eitri.v1.HostFactsR\x05factsJ\x04\b\x06\x10\aR\vbridge_cidr\"P\n" + |
| 1370 | "\bCapacity\x12\x14\n" + | 1377 | "\bCapacity\x12\x14\n" + |
| 1371 | "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" + | 1378 | "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" + |
| 1372 | "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" + | 1379 | "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" + |
| @@ -1403,7 +1410,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1403 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + | 1410 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + |
| 1404 | "\vvmspec_json\x18\x03 \x01(\fR\n" + | 1411 | "\vvmspec_json\x18\x03 \x01(\fR\n" + |
| 1405 | "vmspecJson\x12&\n" + | 1412 | "vmspecJson\x12&\n" + |
| 1406 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xc4\x02\n" + | 1413 | "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xe3\x02\n" + |
| 1407 | "\x11ActualStateReport\x12$\n" + | 1414 | "\x11ActualStateReport\x12$\n" + |
| 1408 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" + | 1415 | "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" + |
| 1409 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + | 1416 | "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + |
| @@ -1411,7 +1418,9 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" + | |||
| 1411 | "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + | 1418 | "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + |
| 1412 | "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + | 1419 | "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + |
| 1413 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\x12/\n" + | 1420 | "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\x12/\n" + |
| 1414 | "\ametrics\x18\a \x01(\v2\x15.eitri.v1.HostMetricsR\ametrics\"\x85\x04\n" + | 1421 | "\ametrics\x18\a \x01(\v2\x15.eitri.v1.HostMetricsR\ametrics\x12\x1d\n" + |
| 1422 | "\n" + | ||
| 1423 | "guest_cidr\x18\b \x01(\tR\tguestCidr\"\x85\x04\n" + | ||
| 1415 | "\tVMDesired\x12\x13\n" + | 1424 | "\tVMDesired\x12\x13\n" + |
| 1416 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + | 1425 | "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + |
| 1417 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + | 1426 | "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -498,10 +498,30 @@ func (s *Store) GetHost(id string) (Host, error) { | |||
| 498 | return h, nil | 498 | return h, nil |
| 499 | } | 499 | } |
| 500 | 500 | ||
| 501 | // UpdateHostFacts overwrites the host's OS facts from its Hello. Callers treat | 501 | // RecordHostNetwork stores the subnet a host says its guests are on. The value |
| 502 | // failure as non-fatal (it must not drop the connection). Empty fields are | 502 | // is free text supplied by whoever claims to be that host, so it is validated |
| 503 | // written as-is: an agent that can no longer read a source clears the stale | 503 | // the same way netenv validates it before handing it to the kernel — parses as |
| 504 | // value rather than freezing it. | 504 | // a prefix, and IPv4 — applied on the receiving side so a garbage string cannot |
| 505 | // reach the console, an operator's eyes, or anything that reads the row. | ||
| 506 | // | ||
| 507 | // This is sanity, not topology: it asks whether the value is a network, never | ||
| 508 | // which network it ought to be. A host's guest subnet is that host's business. | ||
| 509 | // | ||
| 510 | // The WHERE clause makes a concurrent reconnect harmless and a repeat free at | ||
| 511 | // the storage layer; the caller still guards the round trip, because reports | ||
| 512 | // arrive every tick forever and the store runs on one connection. | ||
| 513 | func (s *Store) RecordHostNetwork(id, cidr string) error { | ||
| 514 | p, err := netip.ParsePrefix(cidr) | ||
| 515 | if err != nil { | ||
| 516 | return fmt.Errorf("guest cidr %q: %w", cidr, err) | ||
| 517 | } | ||
| 518 | if !p.Addr().Is4() { | ||
| 519 | return fmt.Errorf("guest cidr %q must be IPv4", cidr) | ||
| 520 | } | ||
| 521 | _, err = s.db.Exec(`UPDATE hosts SET bridge_cidr=? WHERE id=? AND bridge_cidr<>?`, cidr, id, cidr) | ||
| 522 | return err | ||
| 523 | } | ||
| 524 | |||
| 505 | // UpdateHostFacts refreshes what a host reports about itself. Every column | 525 | // UpdateHostFacts refreshes what a host reports about itself. Every column |
| 506 | // keeps its prior value when the reported one is EMPTY, because silence is not | 526 | // keeps its prior value when the reported one is EMPTY, because silence is not |
| 507 | // a statement: an agent too old to send a field, or a Hello carrying no facts | 527 | // a statement: an agent too old to send a field, or a Hello carrying no facts |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -844,3 +844,43 @@ func TestUpdateHostFactsEmptyNeverErases(t *testing.T) { | |||
| 844 | assert.Equal(t, "7.0.1-generic", got.Kernel) | 844 | assert.Equal(t, "7.0.1-generic", got.Kernel) |
| 845 | assert.Equal(t, "vfkit", got.Provisioner, "an unmentioned field keeps its value") | 845 | assert.Equal(t, "vfkit", got.Provisioner, "an unmentioned field keeps its value") |
| 846 | } | 846 | } |
| 847 | |||
| 848 | // TestRecordHostNetworkSanityChecksWithoutJudgingTopology pins what the fleet | ||
| 849 | // asks of a subnet a host reports: that it is a network, never which network it | ||
| 850 | // ought to be. A host's guest subnet is that host's business — a Mac's is | ||
| 851 | // vmnet's, and no fleet allocation will ever contain it. | ||
| 852 | func TestRecordHostNetworkSanityChecksWithoutJudgingTopology(t *testing.T) { | ||
| 853 | s := newStore(t) | ||
| 854 | h := enrollHost(t, s) // allocated 10.77.1.0/24 from the pool | ||
| 855 | |||
| 856 | for _, tc := range []struct { | ||
| 857 | name, cidr string | ||
| 858 | wantErr bool | ||
| 859 | }{ | ||
| 860 | {"the fleet's own allocation", "10.77.1.0/24", false}, | ||
| 861 | {"a subnet no allocation contains", "192.168.64.0/24", false}, | ||
| 862 | {"unparseable", "not-a-network", true}, | ||
| 863 | {"a bare address", "192.168.64.1", true}, | ||
| 864 | {"IPv6", "fd00::/64", true}, | ||
| 865 | {"empty", "", true}, | ||
| 866 | } { | ||
| 867 | t.Run(tc.name, func(t *testing.T) { | ||
| 868 | err := s.RecordHostNetwork(h.ID, tc.cidr) | ||
| 869 | if tc.wantErr { | ||
| 870 | require.Error(t, err) | ||
| 871 | return | ||
| 872 | } | ||
| 873 | require.NoError(t, err) | ||
| 874 | got, err := s.GetHost(h.ID) | ||
| 875 | require.NoError(t, err) | ||
| 876 | assert.Equal(t, tc.cidr, got.BridgeCIDR) | ||
| 877 | }) | ||
| 878 | } | ||
| 879 | |||
| 880 | // A rejected value must leave the last good one standing. | ||
| 881 | require.NoError(t, s.RecordHostNetwork(h.ID, "192.168.64.0/24")) | ||
| 882 | require.Error(t, s.RecordHostNetwork(h.ID, "rubbish")) | ||
| 883 | got, err := s.GetHost(h.ID) | ||
| 884 | require.NoError(t, err) | ||
| 885 | assert.Equal(t, "192.168.64.0/24", got.BridgeCIDR) | ||
| 886 | } | ||
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -47,6 +47,7 @@ type Service struct { | |||
| 47 | // changed. | 47 | // changed. |
| 48 | recorder vmStatusRecorder | 48 | recorder vmStatusRecorder |
| 49 | tracker *statusTracker | 49 | tracker *statusTracker |
| 50 | netTrack *netTracker | ||
| 50 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. | 51 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. |
| 51 | // Zero disables the age check (default: expiry without an auto-renewal | 52 | // Zero disables the age check (default: expiry without an auto-renewal |
| 52 | // channel would force periodic re-enrolls; per-host generation revocation | 53 | // channel would force periodic re-enrolls; per-host generation revocation |
| @@ -81,7 +82,7 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 81 | writeTimeout = defaultWriteTimeout | 82 | writeTimeout = defaultWriteTimeout |
| 82 | } | 83 | } |
| 83 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, | 84 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 84 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), | 85 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker(), netTrack: newNetTracker(), |
| 85 | offers: map[string]*pb.AgentUpgrade{}} | 86 | offers: map[string]*pb.AgentUpgrade{}} |
| 86 | } | 87 | } |
| 87 | 88 | ||
| @@ -201,6 +202,11 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 201 | delete(s.conns, hostID) | 202 | delete(s.conns, hostID) |
| 202 | } | 203 | } |
| 203 | s.consoleMu.Unlock() | 204 | s.consoleMu.Unlock() |
| 205 | // Forget this host's cached guest subnet, so the next connection writes | ||
| 206 | // it once rather than trusting a memory of a row that may have been | ||
| 207 | // removed, re-enrolled or edited while the host was away. Bounds the map | ||
| 208 | // to connected hosts, and costs one write per reconnect. | ||
| 209 | s.netTrack.forget(hostID) | ||
| 204 | }() | 210 | }() |
| 205 | 211 | ||
| 206 | // Single writer for the down-stream: the poke goroutine. | 212 | // Single writer for the down-stream: the poke goroutine. |
| @@ -394,6 +400,18 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 394 | } | 400 | } |
| 395 | } | 401 | } |
| 396 | 402 | ||
| 403 | // The subnet this host says its guests are on. Empty is "no answer", never | ||
| 404 | // "no network" — the same rule noteAddress follows for a VM's address, and | ||
| 405 | // for the same reason: a host that cannot see its own network yet must not | ||
| 406 | // erase what it told us when it could. | ||
| 407 | if cidr := rep.GetGuestCidr(); cidr != "" { | ||
| 408 | if err := s.netTrack.writeThrough(hostID, cidr, func() error { | ||
| 409 | return s.st.RecordHostNetwork(hostID, cidr) | ||
| 410 | }); err != nil { | ||
| 411 | slog.Warn("record host network", "host", hostID, "cidr", cidr, "err", err) | ||
| 412 | } | ||
| 413 | } | ||
| 414 | |||
| 397 | // Fence violation: log ERROR and point at the restore runbook. | 415 | // Fence violation: log ERROR and point at the restore runbook. |
| 398 | if rep.GetFenceViolation() { | 416 | if rep.GetFenceViolation() { |
| 399 | slog.Error("agent refused snapshot: epoch fence violation — see restore runbook", | 417 | slog.Error("agent refused snapshot: epoch fence violation — see restore runbook", |
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -875,3 +875,33 @@ func TestHelloRefreshesProvisioner(t *testing.T) { | |||
| 875 | return err == nil && h.Provisioner == "vfkit" | 875 | return err == nil && h.Provisioner == "vfkit" |
| 876 | }, 2*time.Second, 20*time.Millisecond, "the host row must follow what the agent reports") | 876 | }, 2*time.Second, 20*time.Millisecond, "the host row must follow what the agent reports") |
| 877 | } | 877 | } |
| 878 | |||
| 879 | // TestReportRecordsTheHostsGuestSubnet pins the inversion end to end: the fleet | ||
| 880 | // records the subnet the HOST says its guests are on, rather than the host | ||
| 881 | // echoing back an allocation the fleet made for it. A Mac's guests live on | ||
| 882 | // vmnet's subnet, which no fleet allocation will ever contain. | ||
| 883 | func TestReportRecordsTheHostsGuestSubnet(t *testing.T) { | ||
| 884 | f := setup(t) | ||
| 885 | before, err := f.st.GetHost(f.host.ID) | ||
| 886 | require.NoError(t, err) | ||
| 887 | require.NotEqual(t, "192.168.64.0/24", before.BridgeCIDR) | ||
| 888 | |||
| 889 | c := mustDial(t, f) | ||
| 890 | c.recv(t) | ||
| 891 | |||
| 892 | c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{ | ||
| 893 | Report: &pb.ActualStateReport{GuestCidr: "192.168.64.0/24"}}}) | ||
| 894 | require.Eventually(t, func() bool { | ||
| 895 | h, err := f.st.GetHost(f.host.ID) | ||
| 896 | return err == nil && h.BridgeCIDR == "192.168.64.0/24" | ||
| 897 | }, 2*time.Second, 20*time.Millisecond, "the fleet must record what the host reports") | ||
| 898 | |||
| 899 | // Empty is "no answer", never "no network": a host that cannot see its own | ||
| 900 | // network yet must not erase what it told us when it could. | ||
| 901 | c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{ | ||
| 902 | Report: &pb.ActualStateReport{GuestCidr: ""}}}) | ||
| 903 | require.Never(t, func() bool { | ||
| 904 | h, err := f.st.GetHost(f.host.ID) | ||
| 905 | return err == nil && h.BridgeCIDR != "192.168.64.0/24" | ||
| 906 | }, 500*time.Millisecond, 25*time.Millisecond, "an empty report must leave the record alone") | ||
| 907 | } | ||
internal/server/syncsvc/tracker.go
| Old | New | ||
|---|---|---|---|
| @@ -77,3 +77,44 @@ func (t *statusTracker) forget(vmID string) { | |||
| 77 | defer t.mu.Unlock() | 77 | defer t.mu.Unlock() |
| 78 | delete(t.last, vmID) | 78 | delete(t.last, vmID) |
| 79 | } | 79 | } |
| 80 | |||
| 81 | // netTracker remembers the guest subnet each host last had WRITTEN, so a report | ||
| 82 | // that repeats it costs nothing. The guard is not premature: every host reports | ||
| 83 | // every tick — ten seconds by default — forever, and the store runs on a single | ||
| 84 | // connection, so an unguarded write spends a round trip on that connection for | ||
| 85 | // every host for the life of the fleet. The SQL's own `WHERE bridge_cidr<>?` | ||
| 86 | // makes a concurrent reconnect harmless; this makes the steady state free. | ||
| 87 | // | ||
| 88 | // Keyed by hostID. Only a SUCCESSFUL write is remembered, so a rejected one is | ||
| 89 | // retried by the next report rather than suppressed — the same rule the status | ||
| 90 | // tracker follows, for the same reason. | ||
| 91 | type netTracker struct { | ||
| 92 | mu sync.Mutex | ||
| 93 | last map[string]string | ||
| 94 | } | ||
| 95 | |||
| 96 | func newNetTracker() *netTracker { return &netTracker{last: map[string]string{}} } | ||
| 97 | |||
| 98 | // writeThrough runs decide→write→commit for one host under a single lock, so | ||
| 99 | // two connections for the same host cannot interleave and leave the cache | ||
| 100 | // disagreeing with the row. | ||
| 101 | func (t *netTracker) writeThrough(hostID, cidr string, write func() error) error { | ||
| 102 | t.mu.Lock() | ||
| 103 | defer t.mu.Unlock() | ||
| 104 | if t.last[hostID] == cidr { | ||
| 105 | return nil | ||
| 106 | } | ||
| 107 | if err := write(); err != nil { | ||
| 108 | return err | ||
| 109 | } | ||
| 110 | t.last[hostID] = cidr | ||
| 111 | return nil | ||
| 112 | } | ||
| 113 | |||
| 114 | // forget drops a host's cached subnet, so a host that leaves and returns is | ||
| 115 | // written afresh rather than trusted to a memory of a row that may be gone. | ||
| 116 | func (t *netTracker) forget(hostID string) { | ||
| 117 | t.mu.Lock() | ||
| 118 | defer t.mu.Unlock() | ||
| 119 | delete(t.last, hostID) | ||
| 120 | } | ||
internal/server/syncsvc/tracker_test.go
| Old | New | ||
|---|---|---|---|
| @@ -194,3 +194,40 @@ func TestStatusTrackerPerVMKeys(t *testing.T) { | |||
| 194 | t.Fatal("vm1 unchanged must not write") | 194 | t.Fatal("vm1 unchanged must not write") |
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | |||
| 198 | // TestNetTrackerWritesOnceThenStaysQuiet pins the guard that makes the steady | ||
| 199 | // state free: every host reports its subnet every tick forever, and the store | ||
| 200 | // runs on one connection, so an unguarded write spends a round trip per host | ||
| 201 | // per tick for the life of the fleet. | ||
| 202 | func TestNetTrackerWritesOnceThenStaysQuiet(t *testing.T) { | ||
| 203 | tr := newNetTracker() | ||
| 204 | writes := 0 | ||
| 205 | report := func(host, cidr string) error { | ||
| 206 | return tr.writeThrough(host, cidr, func() error { writes++; return nil }) | ||
| 207 | } | ||
| 208 | |||
| 209 | require.NoError(t, report("h1", "10.77.1.0/24")) | ||
| 210 | require.Equal(t, 1, writes, "the first report writes") | ||
| 211 | require.NoError(t, report("h1", "10.77.1.0/24")) | ||
| 212 | require.Equal(t, 1, writes, "a repeat writes nothing") | ||
| 213 | require.NoError(t, report("h1", "192.168.64.0/24")) | ||
| 214 | require.Equal(t, 2, writes, "a changed subnet writes") | ||
| 215 | |||
| 216 | // Hosts are independent. | ||
| 217 | require.NoError(t, report("h2", "192.168.64.0/24")) | ||
| 218 | require.Equal(t, 3, writes, "a different host is a different key") | ||
| 219 | |||
| 220 | // A REJECTED write is not remembered, so the next report retries it rather | ||
| 221 | // than being suppressed by a value that never reached the row. | ||
| 222 | sentinel := errors.New("rejected") | ||
| 223 | require.ErrorIs(t, tr.writeThrough("h3", "junk", func() error { return sentinel }), sentinel) | ||
| 224 | attempted := false | ||
| 225 | require.NoError(t, tr.writeThrough("h3", "junk", func() error { attempted = true; return nil })) | ||
| 226 | require.True(t, attempted, "a failed write must not suppress the retry") | ||
| 227 | |||
| 228 | // Forgetting a host writes it afresh: the row may have changed while it was | ||
| 229 | // disconnected. | ||
| 230 | tr.forget("h1") | ||
| 231 | require.NoError(t, report("h1", "192.168.64.0/24")) | ||
| 232 | require.Equal(t, 4, writes) | ||
| 233 | } | ||
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -25,7 +25,9 @@ message Hello { | |||
| 25 | string os = 3; | 25 | string os = 3; |
| 26 | string arch = 4; | 26 | string arch = 4; |
| 27 | string provisioner = 5; // "cloudhv" | 27 | string provisioner = 5; // "cloudhv" |
| 28 | string bridge_cidr = 6; // echo of server-assigned CIDR | 28 | reserved 6; // was bridge_cidr: the fleet told the host its |
| 29 | reserved "bridge_cidr"; // subnet. The host reports it now — see | ||
| 30 | // ActualStateReport.guest_cidr. | ||
| 29 | uint64 last_seen_epoch = 7; // for the restore runbook | 31 | uint64 last_seen_epoch = 7; // for the restore runbook |
| 30 | Capacity capacity = 8; | 32 | Capacity capacity = 8; |
| 31 | string credential = 9; // Bearer host credential, verified in first frame | 33 | string credential = 9; // Bearer host credential, verified in first frame |
| @@ -67,7 +69,7 @@ message ActualVM { | |||
| 67 | string vm_id = 1; | 69 | string vm_id = 1; |
| 68 | string power = 2; // "running"|"stopped" | 70 | string power = 2; // "running"|"stopped" |
| 69 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" | 71 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" |
| 70 | string ip = 4; // agent-allocated; server validates within bridge_cidr | 72 | string ip = 4; // the address this guest has, however its host came by it |
| 71 | string last_error = 5; | 73 | string last_error = 5; |
| 72 | } | 74 | } |
| 73 | 75 | ||
| @@ -88,6 +90,15 @@ message ActualStateReport { | |||
| 88 | bool fence_violation = 5; | 90 | bool fence_violation = 5; |
| 89 | uint64 last_seen_epoch = 6; | 91 | uint64 last_seen_epoch = 6; |
| 90 | HostMetrics metrics = 7; // live measured host utilization (heartbeat) | 92 | HostMetrics metrics = 7; // live measured host utilization (heartbeat) |
| 93 | // The subnet this host's guests are on, as the HOST observes it. Empty means | ||
| 94 | // "not yet known", never "no network" — a host that cannot answer leaves the | ||
| 95 | // fleet's record alone rather than erasing it. | ||
| 96 | // | ||
| 97 | // In the report rather than Hello because it can change while an agent stays | ||
| 98 | // connected (an admin edits the host's network), and on some platforms is not | ||
| 99 | // knowable at connect time at all. Capacity is in both messages for the same | ||
| 100 | // reason: an opening value, then the ongoing truth. | ||
| 101 | string guest_cidr = 8; | ||
| 91 | } | 102 | } |
| 92 | 103 | ||
| 93 | message VMDesired { | 104 | message VMDesired { |