a73x

d3f4ed21

feat(server,agent): report host OS facts and live metrics per fleet host

a73x   2026-07-25 18:02

Commit message
feat(server,agent): report host OS facts and live metrics per fleet host

Per fleet host, the control plane surfaces:

  - Facts (persisted): distro (os-release), kernel, CPU model, and
    virtualization type. Written to the hosts table and refreshed from the
    agent's Hello on every connect, so a host that kernel-upgrades and
    reboots self-heals its reported kernel — the report path is authoritative.
  - Live metrics (measured): uptime, memory used/available, load average,
    and disk used/free. Ride the ~10s heartbeat into the in-memory registry,
    exposed in the API only while the host is Online and never persisted —
    the same lifecycle as capacity. These are measured off the box and are
    deliberately kept distinct, in the API and the UI, from the existing
    allocated-vs-capacity bars (which are VM-quota bookkeeping).

The web UI surfaces both: the host detail page shows OS/kernel/CPU/virt and a
measured-metrics block, and the fleet overview host list gains an OS column
and a normalized-load pressure gauge (load1 ÷ vCPUs) so identity and health
read at a glance without drilling in.

Collection is best-effort throughout: any unreadable source yields an
empty/zero value and is never allowed to block the agent's connection or
heartbeat; a failed facts write is logged, not fatal. Virtualization
detection runs systemd-detect-virt through the injected exec.Runner so the
data plane keeps off os/exec (arch rule R6). Collection and heartbeat
emission share a path; decoupling them so a hung Statfs cannot stall a
report is deliberately left for later.

Layers: proto (HostFacts on Hello, HostMetrics on ActualStateReport),
agent/hostinfo gatherers, store columns + UpdateHostFacts, registry.Metrics,
syncsvc ingest, API host response, and the web UI (fleet overview list +
host detail page).

cmd/eitri-agent/main.go
Old New
@@ -224,6 +224,7 @@ func runAgent(st *state.Store, cfg agentConfig) {
224 St: st, 224 St: st,
225 Identity: id, 225 Identity: id,
226 StateDir: cfg.StateDir, 226 StateDir: cfg.StateDir,
227 Runner: realRunner,
227 Console: pumps, 228 Console: pumps,
228 MaxVCPUs: cfg.MaxVCPUs, 229 MaxVCPUs: cfg.MaxVCPUs,
229 MaxMemMB: cfg.MaxMemMB, 230 MaxMemMB: cfg.MaxMemMB,
docs/openapi.json
Old New
@@ -163,6 +163,9 @@
163 "capacity": { 163 "capacity": {
164 "$ref": "#/components/schemas/Capacity" 164 "$ref": "#/components/schemas/Capacity"
165 }, 165 },
166 "cpu_model": {
167 "type": "string"
168 },
166 "enrolled_at": { 169 "enrolled_at": {
167 "format": "date-time", 170 "format": "date-time",
168 "type": "string" 171 "type": "string"
@@ -170,6 +173,9 @@
170 "id": { 173 "id": {
171 "type": "string" 174 "type": "string"
172 }, 175 },
176 "kernel": {
177 "type": "string"
178 },
173 "last_seen": { 179 "last_seen": {
174 "format": "date-time", 180 "format": "date-time",
175 "type": [ 181 "type": [
@@ -177,6 +183,16 @@
177 "null" 183 "null"
178 ] 184 ]
179 }, 185 },
186 "metrics": {
187 "anyOf": [
188 {
189 "$ref": "#/components/schemas/Metrics"
190 },
191 {
192 "type": "null"
193 }
194 ]
195 },
180 "name": { 196 "name": {
181 "type": "string" 197 "type": "string"
182 }, 198 },
@@ -186,6 +202,15 @@
186 "os": { 202 "os": {
187 "type": "string" 203 "type": "string"
188 }, 204 },
205 "os_id": {
206 "type": "string"
207 },
208 "os_pretty": {
209 "type": "string"
210 },
211 "os_version": {
212 "type": "string"
213 },
189 "provisioner": { 214 "provisioner": {
190 "type": "string" 215 "type": "string"
191 }, 216 },
@@ -203,6 +228,9 @@
203 }, 228 },
204 "status": { 229 "status": {
205 "type": "string" 230 "type": "string"
231 },
232 "virt": {
233 "type": "string"
206 } 234 }
207 }, 235 },
208 "required": [ 236 "required": [
@@ -210,15 +238,60 @@
210 "arch", 238 "arch",
211 "bridge_cidr", 239 "bridge_cidr",
212 "capacity", 240 "capacity",
241 "cpu_model",
213 "enrolled_at", 242 "enrolled_at",
214 "id", 243 "id",
244 "kernel",
215 "name", 245 "name",
216 "online", 246 "online",
217 "os", 247 "os",
248 "os_id",
249 "os_pretty",
250 "os_version",
218 "provisioner", 251 "provisioner",
219 "sessions", 252 "sessions",
220 "stale", 253 "stale",
221 "status" 254 "status",
255 "virt"
256 ],
257 "type": "object"
258 },
259 "Metrics": {
260 "properties": {
261 "disk_free_gb": {
262 "type": "integer"
263 },
264 "disk_used_gb": {
265 "type": "integer"
266 },
267 "load1": {
268 "type": "number"
269 },
270 "load15": {
271 "type": "number"
272 },
273 "load5": {
274 "type": "number"
275 },
276 "mem_available_mb": {
277 "type": "integer"
278 },
279 "mem_used_mb": {
280 "type": "integer"
281 },
282 "uptime_s": {
283 "type": "integer"
284 }
285 },
286 "required": [
287 "disk_free_gb",
288 "disk_used_gb",
289 "load1",
290 "load15",
291 "load5",
292 "mem_available_mb",
293 "mem_used_mb",
294 "uptime_s"
222 ], 295 ],
223 "type": "object" 296 "type": "object"
224 }, 297 },
docs/shape.html
Old New
@@ -141,6 +141,15 @@
141 "imports": [] 141 "imports": []
142 }, 142 },
143 { 143 {
144 "importPath": "internal/agent/hostinfo",
145 "plane": "data",
146 "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.",
147 "imports": [
148 "internal/agent/exec",
149 "internal/pb"
150 ]
151 },
152 {
144 "importPath": "internal/agent/imagecache", 153 "importPath": "internal/agent/imagecache",
145 "plane": "data", 154 "plane": "data",
146 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", 155 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).",
@@ -198,6 +207,8 @@
198 "plane": "data", 207 "plane": "data",
199 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", 208 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.",
200 "imports": [ 209 "imports": [
210 "internal/agent/exec",
211 "internal/agent/hostinfo",
201 "internal/agent/reconcile", 212 "internal/agent/reconcile",
202 "internal/agent/state", 213 "internal/agent/state",
203 "internal/pb", 214 "internal/pb",
docs/shape.json
Old New
@@ -90,6 +90,15 @@
90 "imports": [] 90 "imports": []
91 }, 91 },
92 { 92 {
93 "importPath": "internal/agent/hostinfo",
94 "plane": "data",
95 "synopsis": "Package hostinfo gathers best-effort facts and live metrics about the fleet host the agent runs on.",
96 "imports": [
97 "internal/agent/exec",
98 "internal/pb"
99 ]
100 },
101 {
93 "importPath": "internal/agent/imagecache", 102 "importPath": "internal/agent/imagecache",
94 "plane": "data", 103 "plane": "data",
95 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).", 104 "synopsis": "Package imagecache downloads and verifies content-addressed base images (raw-converted via qemu-img, LRU-evicted beyond MaxBytes).",
@@ -147,6 +156,8 @@
147 "plane": "data", 156 "plane": "data",
148 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.", 157 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.",
149 "imports": [ 158 "imports": [
159 "internal/agent/exec",
160 "internal/agent/hostinfo",
150 "internal/agent/reconcile", 161 "internal/agent/reconcile",
151 "internal/agent/state", 162 "internal/agent/state",
152 "internal/pb", 163 "internal/pb",
internal/agent/hostinfo/hostinfo.go
Old New
@@ -0,0 +1,160 @@
1 // Package hostinfo gathers best-effort facts and live metrics about the fleet
2 // host the agent runs on. Every reader is best-effort: a failing read yields an
3 // empty string or zero, never a blocking error, so host introspection can never
4 // stall the agent's connection or heartbeat.
5 package hostinfo
6
7 import (
8 "context"
9 "os"
10 "strconv"
11 "strings"
12 "syscall"
13 "time"
14
15 agentexec "github.com/a73x/eitri/internal/agent/exec"
16 "github.com/a73x/eitri/internal/pb"
17 )
18
19 // Seams — package-level vars so tests can point them at fixtures/stubs.
20 var (
21 osReleasePath = "/etc/os-release"
22 cpuInfoPath = "/proc/cpuinfo"
23 memInfoPath = "/proc/meminfo"
24 kernelPath = "/proc/sys/kernel/osrelease"
25 sysinfoFn = syscall.Sysinfo
26 statfsFn = syscall.Statfs
27 )
28
29 const (
30 loadScale = 65536.0 // Sysinfo.Loads fixed-point: units of 1<<SI_LOAD_SHIFT
31 mb = 1024 * 1024
32 gb = 1024 * 1024 * 1024
33 )
34
35 // Facts returns slow-changing host identity for the once-per-session Hello.
36 // Every field is empty when its source can't be read. run executes
37 // systemd-detect-virt (nil → virt unknown); it stays injected so hostinfo does
38 // not import os/exec (data-plane arch rule R6). ctx bounds the virt subprocess.
39 func Facts(ctx context.Context, run agentexec.Runner) *pb.HostFacts {
40 id, pretty, version := parseOSRelease(readFile(osReleasePath))
41 return &pb.HostFacts{
42 OsId: id,
43 OsPretty: pretty,
44 OsVersion: version,
45 Kernel: strings.TrimSpace(readFile(kernelPath)),
46 CpuModel: parseCPUModel(readFile(cpuInfoPath)),
47 Virt: detectVirt(ctx, run),
48 }
49 }
50
51 // Metrics returns live host utilization for the heartbeat. stateDir is the
52 // filesystem whose usage is reported (the agent's state dir, matching capacity).
53 func Metrics(stateDir string) *pb.HostMetrics {
54 m := &pb.HostMetrics{}
55
56 var si syscall.Sysinfo_t
57 if err := sysinfoFn(&si); err == nil {
58 m.UptimeS = si.Uptime
59 m.Load1 = float64(si.Loads[0]) / loadScale
60 m.Load5 = float64(si.Loads[1]) / loadScale
61 m.Load15 = float64(si.Loads[2]) / loadScale
62 totalMB := int64(si.Totalram) * int64(si.Unit) / mb
63 availMB := memAvailableMB(&si)
64 m.MemAvailableMb = availMB
65 if used := totalMB - availMB; used > 0 {
66 m.MemUsedMb = used
67 }
68 }
69
70 var fs syscall.Statfs_t
71 if err := statfsFn(stateDir, &fs); err == nil {
72 bsize := int64(fs.Bsize)
73 m.DiskFreeGb = int64(fs.Bavail) * bsize / gb
74 m.DiskUsedGb = (int64(fs.Blocks) - int64(fs.Bfree)) * bsize / gb
75 }
76 return m
77 }
78
79 // memAvailableMB prefers /proc/meminfo MemAvailable (accounts for reclaimable
80 // cache — the honest headroom number); falls back to Sysinfo Free+Buffer, which
81 // undercounts cache, when meminfo is unreadable.
82 func memAvailableMB(si *syscall.Sysinfo_t) int64 {
83 if kb, ok := parseMemAvailableKB(readFile(memInfoPath)); ok {
84 return kb / 1024
85 }
86 return int64(si.Freeram+si.Bufferram) * int64(si.Unit) / mb
87 }
88
89 // parseOSRelease extracts ID, PRETTY_NAME, VERSION_ID from /etc/os-release
90 // (KEY=VALUE lines, values optionally double-quoted).
91 func parseOSRelease(s string) (id, pretty, version string) {
92 for _, line := range strings.Split(s, "\n") {
93 k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
94 if !ok {
95 continue
96 }
97 v = strings.Trim(v, `"`)
98 switch k {
99 case "ID":
100 id = v
101 case "PRETTY_NAME":
102 pretty = v
103 case "VERSION_ID":
104 version = v
105 }
106 }
107 return id, pretty, version
108 }
109
110 // parseCPUModel returns the first "model name" value from /proc/cpuinfo, or ""
111 // (e.g. on arm, which uses different fields).
112 func parseCPUModel(s string) string {
113 for _, line := range strings.Split(s, "\n") {
114 if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "model name" {
115 return strings.TrimSpace(v)
116 }
117 }
118 return ""
119 }
120
121 // parseMemAvailableKB returns the MemAvailable value in kB and whether it was
122 // present.
123 func parseMemAvailableKB(s string) (int64, bool) {
124 for _, line := range strings.Split(s, "\n") {
125 if !strings.HasPrefix(line, "MemAvailable:") {
126 continue
127 }
128 fields := strings.Fields(line) // ["MemAvailable:", "N", "kB"]
129 if len(fields) >= 2 {
130 if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
131 return n, true
132 }
133 }
134 }
135 return 0, false
136 }
137
138 // readFile is a best-effort file read: "" on any error.
139 func readFile(path string) string {
140 b, err := os.ReadFile(path)
141 if err != nil {
142 return ""
143 }
144 return string(b)
145 }
146
147 // detectVirt runs systemd-detect-virt through the injected Runner (nil → ""),
148 // keeping hostinfo off os/exec per the data-plane arch rule. The tool prints
149 // "none" (and exits non-zero) on bare metal and a name like "kvm" under
150 // virtualization; the output is used regardless of exit code, and empty output
151 // (binary absent) means unknown.
152 func detectVirt(ctx context.Context, run agentexec.Runner) string {
153 if run == nil {
154 return ""
155 }
156 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
157 defer cancel()
158 out, _ := run(ctx, "systemd-detect-virt")
159 return strings.TrimSpace(out)
160 }
internal/agent/hostinfo/hostinfo_test.go
Old New
@@ -0,0 +1,116 @@
1 package hostinfo
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "syscall"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12 )
13
14 func writeFixture(t *testing.T, name, content string) string {
15 t.Helper()
16 p := filepath.Join(t.TempDir(), name)
17 require.NoError(t, os.WriteFile(p, []byte(content), 0o644))
18 return p
19 }
20
21 func TestParseOSRelease(t *testing.T) {
22 id, pretty, version := parseOSRelease(
23 "PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nID=debian\nVERSION_ID=\"12\"\nHOME_URL=\"x\"\n")
24 assert.Equal(t, "debian", id)
25 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", pretty)
26 assert.Equal(t, "12", version)
27 }
28
29 func TestParseOSReleaseEmpty(t *testing.T) {
30 id, pretty, version := parseOSRelease("")
31 assert.Empty(t, id)
32 assert.Empty(t, pretty)
33 assert.Empty(t, version)
34 }
35
36 func TestParseCPUModel(t *testing.T) {
37 assert.Equal(t, "AMD EPYC 7302P 16-Core Processor",
38 parseCPUModel("processor\t: 0\nmodel name\t: AMD EPYC 7302P 16-Core Processor\nflags\t: fpu\n"))
39 // arm-style cpuinfo has no "model name" line → empty, not a crash.
40 assert.Empty(t, parseCPUModel("processor\t: 0\nCPU implementer\t: 0x41\n"))
41 }
42
43 func TestParseMemAvailableKB(t *testing.T) {
44 kb, ok := parseMemAvailableKB("MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n")
45 require.True(t, ok)
46 assert.Equal(t, int64(4194304), kb)
47
48 _, ok = parseMemAvailableKB("MemTotal: 8388608 kB\n")
49 assert.False(t, ok)
50 }
51
52 func TestFactsReadsFixtures(t *testing.T) {
53 oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath
54 defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }()
55
56 osReleasePath = writeFixture(t, "os-release",
57 "ID=debian\nPRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nVERSION_ID=\"12\"\n")
58 cpuInfoPath = writeFixture(t, "cpuinfo", "model name\t: AMD EPYC 7302P 16-Core Processor\n")
59 kernelPath = writeFixture(t, "osrelease", "6.1.0-18-amd64\n")
60
61 f := Facts(context.Background(), func(_ context.Context, _ string, _ ...string) (string, error) { return "kvm", nil })
62 assert.Equal(t, "debian", f.GetOsId())
63 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", f.GetOsPretty())
64 assert.Equal(t, "12", f.GetOsVersion())
65 assert.Equal(t, "6.1.0-18-amd64", f.GetKernel())
66 assert.Equal(t, "AMD EPYC 7302P 16-Core Processor", f.GetCpuModel())
67 assert.Equal(t, "kvm", f.GetVirt())
68 }
69
70 func TestFactsBestEffortOnMissingFiles(t *testing.T) {
71 oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath
72 defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }()
73
74 osReleasePath = "/nonexistent/os-release"
75 cpuInfoPath = "/nonexistent/cpuinfo"
76 kernelPath = "/nonexistent/osrelease"
77
78 f := Facts(context.Background(), nil) // must not panic; nil Runner → virt ""
79 assert.Empty(t, f.GetOsId())
80 assert.Empty(t, f.GetKernel())
81 assert.Empty(t, f.GetCpuModel())
82 }
83
84 func TestMetricsComputes(t *testing.T) {
85 oldSys, oldStat, oldMem := sysinfoFn, statfsFn, memInfoPath
86 defer func() { sysinfoFn, statfsFn, memInfoPath = oldSys, oldStat, oldMem }()
87
88 sysinfoFn = func(si *syscall.Sysinfo_t) error {
89 *si = syscall.Sysinfo_t{
90 Uptime: 3600,
91 Loads: [3]uint64{65536, 32768, 0}, // 1.0, 0.5, 0.0
92 Totalram: 8 * 1024 * 1024 * 1024, // 8 GiB
93 Unit: 1,
94 }
95 return nil
96 }
97 memInfoPath = writeFixture(t, "meminfo", "MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n") // 4 GiB avail
98 statfsFn = func(_ string, fs *syscall.Statfs_t) error {
99 *fs = syscall.Statfs_t{
100 Bsize: 4096,
101 Blocks: 26214400, // 100 GiB total
102 Bfree: 13107200, // 50 GiB free (incl reserved)
103 Bavail: 10485760, // 40 GiB avail to non-root
104 }
105 return nil
106 }
107
108 m := Metrics("/whatever")
109 assert.Equal(t, int64(3600), m.GetUptimeS())
110 assert.InDelta(t, 1.0, m.GetLoad1(), 0.001)
111 assert.InDelta(t, 0.5, m.GetLoad5(), 0.001)
112 assert.Equal(t, int64(4096), m.GetMemAvailableMb())
113 assert.Equal(t, int64(4096), m.GetMemUsedMb()) // 8192 total − 4096 avail
114 assert.Equal(t, int64(40), m.GetDiskFreeGb())
115 assert.Equal(t, int64(50), m.GetDiskUsedGb()) // (Blocks − Bfree) × Bsize
116 }
internal/agent/syncclient/client.go
Old New
@@ -15,6 +15,8 @@ import (
15 "syscall" 15 "syscall"
16 "time" 16 "time"
17 17
18 agentexec "github.com/a73x/eitri/internal/agent/exec"
19 "github.com/a73x/eitri/internal/agent/hostinfo"
18 "github.com/a73x/eitri/internal/agent/reconcile" 20 "github.com/a73x/eitri/internal/agent/reconcile"
19 "github.com/a73x/eitri/internal/agent/state" 21 "github.com/a73x/eitri/internal/agent/state"
20 "github.com/a73x/eitri/internal/pb" 22 "github.com/a73x/eitri/internal/pb"
@@ -88,6 +90,11 @@ type Client struct {
88 Identity state.Identity 90 Identity state.Identity
89 StateDir string 91 StateDir string
90 92
93 // Runner executes host-introspection subprocesses (systemd-detect-virt via
94 // hostinfo). Injected so this data-plane package never imports os/exec (R6);
95 // nil is tolerated (virt reported as unknown).
96 Runner agentexec.Runner
97
91 // Console handles server-opened console streams (nil refuses them all). 98 // Console handles server-opened console streams (nil refuses them all).
92 Console Console 99 Console Console
93 100
@@ -260,6 +267,7 @@ func (c *Client) session(ctx context.Context) error {
260 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH, 267 HostId: c.Identity.HostID, Hostname: hostname, Os: runtime.GOOS, Arch: runtime.GOARCH,
261 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR, 268 Provisioner: "cloudhv", BridgeCidr: c.Identity.BridgeCIDR,
262 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir), 269 LastSeenEpoch: c.St.Epoch(), Capacity: c.advertisedCapacity(stateDir),
270 Facts: hostinfo.Facts(ctx, c.Runner),
263 Credential: c.Identity.Credential, 271 Credential: c.Identity.Credential,
264 }}} 272 }}}
265 if err := transport.WriteMsg(up, hello); err != nil { 273 if err := transport.WriteMsg(up, hello); err != nil {
@@ -314,6 +322,7 @@ func (c *Client) session(ctx context.Context) error {
314 } 322 }
315 rep := c.Engine.Step(ctx, snap) 323 rep := c.Engine.Step(ctx, snap)
316 rep.Capacity = c.advertisedCapacity(stateDir) 324 rep.Capacity = c.advertisedCapacity(stateDir)
325 rep.Metrics = hostinfo.Metrics(stateDir)
317 return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}}) 326 return transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: rep}})
318 } 327 }
319 328
internal/pb/sync.pb.go
Old New
@@ -244,6 +244,7 @@ type Hello struct {
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 unknownFields protoimpl.UnknownFields 248 unknownFields protoimpl.UnknownFields
248 sizeCache protoimpl.SizeCache 249 sizeCache protoimpl.SizeCache
249 } 250 }
@@ -341,6 +342,13 @@ func (x *Hello) GetCredential() string {
341 return "" 342 return ""
342 } 343 }
343 344
345 func (x *Hello) GetFacts() *HostFacts {
346 if x != nil {
347 return x.Facts
348 }
349 return nil
350 }
351
344 type Capacity struct { 352 type Capacity struct {
345 state protoimpl.MessageState `protogen:"open.v1"` 353 state protoimpl.MessageState `protogen:"open.v1"`
346 Vcpus int64 `protobuf:"varint,1,opt,name=vcpus,proto3" json:"vcpus,omitempty"` 354 Vcpus int64 `protobuf:"varint,1,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
@@ -401,6 +409,194 @@ func (x *Capacity) GetDiskGb() int64 {
401 return 0 409 return 0
402 } 410 }
403 411
412 // HostFacts is slow-changing host identity, gathered best-effort by the agent.
413 // Every field is empty when its source can't be read.
414 type HostFacts struct {
415 state protoimpl.MessageState `protogen:"open.v1"`
416 OsId string `protobuf:"bytes,1,opt,name=os_id,json=osId,proto3" json:"os_id,omitempty"` // /etc/os-release ID, e.g. "debian"
417 OsPretty string `protobuf:"bytes,2,opt,name=os_pretty,json=osPretty,proto3" json:"os_pretty,omitempty"` // PRETTY_NAME, e.g. "Debian GNU/Linux 12 (bookworm)"
418 OsVersion string `protobuf:"bytes,3,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"` // VERSION_ID, e.g. "12"
419 Kernel string `protobuf:"bytes,4,opt,name=kernel,proto3" json:"kernel,omitempty"` // kernel release, e.g. "6.1.0-18-amd64"
420 CpuModel string `protobuf:"bytes,5,opt,name=cpu_model,json=cpuModel,proto3" json:"cpu_model,omitempty"` // /proc/cpuinfo model name
421 Virt string `protobuf:"bytes,6,opt,name=virt,proto3" json:"virt,omitempty"` // systemd-detect-virt: "kvm" | "none" | "" (unknown)
422 unknownFields protoimpl.UnknownFields
423 sizeCache protoimpl.SizeCache
424 }
425
426 func (x *HostFacts) Reset() {
427 *x = HostFacts{}
428 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4]
429 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
430 ms.StoreMessageInfo(mi)
431 }
432
433 func (x *HostFacts) String() string {
434 return protoimpl.X.MessageStringOf(x)
435 }
436
437 func (*HostFacts) ProtoMessage() {}
438
439 func (x *HostFacts) ProtoReflect() protoreflect.Message {
440 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4]
441 if x != nil {
442 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
443 if ms.LoadMessageInfo() == nil {
444 ms.StoreMessageInfo(mi)
445 }
446 return ms
447 }
448 return mi.MessageOf(x)
449 }
450
451 // Deprecated: Use HostFacts.ProtoReflect.Descriptor instead.
452 func (*HostFacts) Descriptor() ([]byte, []int) {
453 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{4}
454 }
455
456 func (x *HostFacts) GetOsId() string {
457 if x != nil {
458 return x.OsId
459 }
460 return ""
461 }
462
463 func (x *HostFacts) GetOsPretty() string {
464 if x != nil {
465 return x.OsPretty
466 }
467 return ""
468 }
469
470 func (x *HostFacts) GetOsVersion() string {
471 if x != nil {
472 return x.OsVersion
473 }
474 return ""
475 }
476
477 func (x *HostFacts) GetKernel() string {
478 if x != nil {
479 return x.Kernel
480 }
481 return ""
482 }
483
484 func (x *HostFacts) GetCpuModel() string {
485 if x != nil {
486 return x.CpuModel
487 }
488 return ""
489 }
490
491 func (x *HostFacts) GetVirt() string {
492 if x != nil {
493 return x.Virt
494 }
495 return ""
496 }
497
498 // HostMetrics is live measured host utilization, refreshed each report. It is
499 // NOT persisted — it lives only in the registry while the host is online.
500 type HostMetrics struct {
501 state protoimpl.MessageState `protogen:"open.v1"`
502 UptimeS int64 `protobuf:"varint,1,opt,name=uptime_s,json=uptimeS,proto3" json:"uptime_s,omitempty"`
503 MemUsedMb int64 `protobuf:"varint,2,opt,name=mem_used_mb,json=memUsedMb,proto3" json:"mem_used_mb,omitempty"`
504 MemAvailableMb int64 `protobuf:"varint,3,opt,name=mem_available_mb,json=memAvailableMb,proto3" json:"mem_available_mb,omitempty"`
505 Load1 float64 `protobuf:"fixed64,4,opt,name=load1,proto3" json:"load1,omitempty"` // 1-min load average (whole machine)
506 Load5 float64 `protobuf:"fixed64,5,opt,name=load5,proto3" json:"load5,omitempty"`
507 Load15 float64 `protobuf:"fixed64,6,opt,name=load15,proto3" json:"load15,omitempty"`
508 DiskUsedGb int64 `protobuf:"varint,7,opt,name=disk_used_gb,json=diskUsedGb,proto3" json:"disk_used_gb,omitempty"`
509 DiskFreeGb int64 `protobuf:"varint,8,opt,name=disk_free_gb,json=diskFreeGb,proto3" json:"disk_free_gb,omitempty"`
510 unknownFields protoimpl.UnknownFields
511 sizeCache protoimpl.SizeCache
512 }
513
514 func (x *HostMetrics) Reset() {
515 *x = HostMetrics{}
516 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5]
517 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
518 ms.StoreMessageInfo(mi)
519 }
520
521 func (x *HostMetrics) String() string {
522 return protoimpl.X.MessageStringOf(x)
523 }
524
525 func (*HostMetrics) ProtoMessage() {}
526
527 func (x *HostMetrics) ProtoReflect() protoreflect.Message {
528 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5]
529 if x != nil {
530 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
531 if ms.LoadMessageInfo() == nil {
532 ms.StoreMessageInfo(mi)
533 }
534 return ms
535 }
536 return mi.MessageOf(x)
537 }
538
539 // Deprecated: Use HostMetrics.ProtoReflect.Descriptor instead.
540 func (*HostMetrics) Descriptor() ([]byte, []int) {
541 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{5}
542 }
543
544 func (x *HostMetrics) GetUptimeS() int64 {
545 if x != nil {
546 return x.UptimeS
547 }
548 return 0
549 }
550
551 func (x *HostMetrics) GetMemUsedMb() int64 {
552 if x != nil {
553 return x.MemUsedMb
554 }
555 return 0
556 }
557
558 func (x *HostMetrics) GetMemAvailableMb() int64 {
559 if x != nil {
560 return x.MemAvailableMb
561 }
562 return 0
563 }
564
565 func (x *HostMetrics) GetLoad1() float64 {
566 if x != nil {
567 return x.Load1
568 }
569 return 0
570 }
571
572 func (x *HostMetrics) GetLoad5() float64 {
573 if x != nil {
574 return x.Load5
575 }
576 return 0
577 }
578
579 func (x *HostMetrics) GetLoad15() float64 {
580 if x != nil {
581 return x.Load15
582 }
583 return 0
584 }
585
586 func (x *HostMetrics) GetDiskUsedGb() int64 {
587 if x != nil {
588 return x.DiskUsedGb
589 }
590 return 0
591 }
592
593 func (x *HostMetrics) GetDiskFreeGb() int64 {
594 if x != nil {
595 return x.DiskFreeGb
596 }
597 return 0
598 }
599
404 type ActualVM struct { 600 type ActualVM struct {
405 state protoimpl.MessageState `protogen:"open.v1"` 601 state protoimpl.MessageState `protogen:"open.v1"`
406 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` 602 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
@@ -414,7 +610,7 @@ type ActualVM struct {
414 610
415 func (x *ActualVM) Reset() { 611 func (x *ActualVM) Reset() {
416 *x = ActualVM{} 612 *x = ActualVM{}
417 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4] 613 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6]
418 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 614 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419 ms.StoreMessageInfo(mi) 615 ms.StoreMessageInfo(mi)
420 } 616 }
@@ -426,7 +622,7 @@ func (x *ActualVM) String() string {
426 func (*ActualVM) ProtoMessage() {} 622 func (*ActualVM) ProtoMessage() {}
427 623
428 func (x *ActualVM) ProtoReflect() protoreflect.Message { 624 func (x *ActualVM) ProtoReflect() protoreflect.Message {
429 mi := &file_proto_eitri_v1_sync_proto_msgTypes[4] 625 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6]
430 if x != nil { 626 if x != nil {
431 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 627 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
432 if ms.LoadMessageInfo() == nil { 628 if ms.LoadMessageInfo() == nil {
@@ -439,7 +635,7 @@ func (x *ActualVM) ProtoReflect() protoreflect.Message {
439 635
440 // Deprecated: Use ActualVM.ProtoReflect.Descriptor instead. 636 // Deprecated: Use ActualVM.ProtoReflect.Descriptor instead.
441 func (*ActualVM) Descriptor() ([]byte, []int) { 637 func (*ActualVM) Descriptor() ([]byte, []int) {
442 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{4} 638 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{6}
443 } 639 }
444 640
445 func (x *ActualVM) GetVmId() string { 641 func (x *ActualVM) GetVmId() string {
@@ -489,7 +685,7 @@ type QuarantinedVM struct {
489 685
490 func (x *QuarantinedVM) Reset() { 686 func (x *QuarantinedVM) Reset() {
491 *x = QuarantinedVM{} 687 *x = QuarantinedVM{}
492 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5] 688 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7]
493 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 689 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
494 ms.StoreMessageInfo(mi) 690 ms.StoreMessageInfo(mi)
495 } 691 }
@@ -501,7 +697,7 @@ func (x *QuarantinedVM) String() string {
501 func (*QuarantinedVM) ProtoMessage() {} 697 func (*QuarantinedVM) ProtoMessage() {}
502 698
503 func (x *QuarantinedVM) ProtoReflect() protoreflect.Message { 699 func (x *QuarantinedVM) ProtoReflect() protoreflect.Message {
504 mi := &file_proto_eitri_v1_sync_proto_msgTypes[5] 700 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7]
505 if x != nil { 701 if x != nil {
506 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 702 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
507 if ms.LoadMessageInfo() == nil { 703 if ms.LoadMessageInfo() == nil {
@@ -514,7 +710,7 @@ func (x *QuarantinedVM) ProtoReflect() protoreflect.Message {
514 710
515 // Deprecated: Use QuarantinedVM.ProtoReflect.Descriptor instead. 711 // Deprecated: Use QuarantinedVM.ProtoReflect.Descriptor instead.
516 func (*QuarantinedVM) Descriptor() ([]byte, []int) { 712 func (*QuarantinedVM) Descriptor() ([]byte, []int) {
517 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{5} 713 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{7}
518 } 714 }
519 715
520 func (x *QuarantinedVM) GetVmId() string { 716 func (x *QuarantinedVM) GetVmId() string {
@@ -555,13 +751,14 @@ type ActualStateReport struct {
555 Capacity *Capacity `protobuf:"bytes,4,opt,name=capacity,proto3" json:"capacity,omitempty"` 751 Capacity *Capacity `protobuf:"bytes,4,opt,name=capacity,proto3" json:"capacity,omitempty"`
556 FenceViolation bool `protobuf:"varint,5,opt,name=fence_violation,json=fenceViolation,proto3" json:"fence_violation,omitempty"` 752 FenceViolation bool `protobuf:"varint,5,opt,name=fence_violation,json=fenceViolation,proto3" json:"fence_violation,omitempty"`
557 LastSeenEpoch uint64 `protobuf:"varint,6,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"` 753 LastSeenEpoch uint64 `protobuf:"varint,6,opt,name=last_seen_epoch,json=lastSeenEpoch,proto3" json:"last_seen_epoch,omitempty"`
754 Metrics *HostMetrics `protobuf:"bytes,7,opt,name=metrics,proto3" json:"metrics,omitempty"` // live measured host utilization (heartbeat)
558 unknownFields protoimpl.UnknownFields 755 unknownFields protoimpl.UnknownFields
559 sizeCache protoimpl.SizeCache 756 sizeCache protoimpl.SizeCache
560 } 757 }
561 758
562 func (x *ActualStateReport) Reset() { 759 func (x *ActualStateReport) Reset() {
563 *x = ActualStateReport{} 760 *x = ActualStateReport{}
564 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6] 761 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8]
565 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 762 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
566 ms.StoreMessageInfo(mi) 763 ms.StoreMessageInfo(mi)
567 } 764 }
@@ -573,7 +770,7 @@ func (x *ActualStateReport) String() string {
573 func (*ActualStateReport) ProtoMessage() {} 770 func (*ActualStateReport) ProtoMessage() {}
574 771
575 func (x *ActualStateReport) ProtoReflect() protoreflect.Message { 772 func (x *ActualStateReport) ProtoReflect() protoreflect.Message {
576 mi := &file_proto_eitri_v1_sync_proto_msgTypes[6] 773 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8]
577 if x != nil { 774 if x != nil {
578 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 775 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
579 if ms.LoadMessageInfo() == nil { 776 if ms.LoadMessageInfo() == nil {
@@ -586,7 +783,7 @@ func (x *ActualStateReport) ProtoReflect() protoreflect.Message {
586 783
587 // Deprecated: Use ActualStateReport.ProtoReflect.Descriptor instead. 784 // Deprecated: Use ActualStateReport.ProtoReflect.Descriptor instead.
588 func (*ActualStateReport) Descriptor() ([]byte, []int) { 785 func (*ActualStateReport) Descriptor() ([]byte, []int) {
589 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{6} 786 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{8}
590 } 787 }
591 788
592 func (x *ActualStateReport) GetVms() []*ActualVM { 789 func (x *ActualStateReport) GetVms() []*ActualVM {
@@ -631,6 +828,13 @@ func (x *ActualStateReport) GetLastSeenEpoch() uint64 {
631 return 0 828 return 0
632 } 829 }
633 830
831 func (x *ActualStateReport) GetMetrics() *HostMetrics {
832 if x != nil {
833 return x.Metrics
834 }
835 return nil
836 }
837
634 type VMDesired struct { 838 type VMDesired struct {
635 state protoimpl.MessageState `protogen:"open.v1"` 839 state protoimpl.MessageState `protogen:"open.v1"`
636 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` 840 VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"`
@@ -654,7 +858,7 @@ type VMDesired struct {
654 858
655 func (x *VMDesired) Reset() { 859 func (x *VMDesired) Reset() {
656 *x = VMDesired{} 860 *x = VMDesired{}
657 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7] 861 mi := &file_proto_eitri_v1_sync_proto_msgTypes[9]
658 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 862 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
659 ms.StoreMessageInfo(mi) 863 ms.StoreMessageInfo(mi)
660 } 864 }
@@ -666,7 +870,7 @@ func (x *VMDesired) String() string {
666 func (*VMDesired) ProtoMessage() {} 870 func (*VMDesired) ProtoMessage() {}
667 871
668 func (x *VMDesired) ProtoReflect() protoreflect.Message { 872 func (x *VMDesired) ProtoReflect() protoreflect.Message {
669 mi := &file_proto_eitri_v1_sync_proto_msgTypes[7] 873 mi := &file_proto_eitri_v1_sync_proto_msgTypes[9]
670 if x != nil { 874 if x != nil {
671 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 875 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
672 if ms.LoadMessageInfo() == nil { 876 if ms.LoadMessageInfo() == nil {
@@ -679,7 +883,7 @@ func (x *VMDesired) ProtoReflect() protoreflect.Message {
679 883
680 // Deprecated: Use VMDesired.ProtoReflect.Descriptor instead. 884 // Deprecated: Use VMDesired.ProtoReflect.Descriptor instead.
681 func (*VMDesired) Descriptor() ([]byte, []int) { 885 func (*VMDesired) Descriptor() ([]byte, []int) {
682 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{7} 886 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{9}
683 } 887 }
684 888
685 func (x *VMDesired) GetVmId() string { 889 func (x *VMDesired) GetVmId() string {
@@ -797,7 +1001,7 @@ type DesiredStateSnapshot struct {
797 1001
798 func (x *DesiredStateSnapshot) Reset() { 1002 func (x *DesiredStateSnapshot) Reset() {
799 *x = DesiredStateSnapshot{} 1003 *x = DesiredStateSnapshot{}
800 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8] 1004 mi := &file_proto_eitri_v1_sync_proto_msgTypes[10]
801 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1005 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
802 ms.StoreMessageInfo(mi) 1006 ms.StoreMessageInfo(mi)
803 } 1007 }
@@ -809,7 +1013,7 @@ func (x *DesiredStateSnapshot) String() string {
809 func (*DesiredStateSnapshot) ProtoMessage() {} 1013 func (*DesiredStateSnapshot) ProtoMessage() {}
810 1014
811 func (x *DesiredStateSnapshot) ProtoReflect() protoreflect.Message { 1015 func (x *DesiredStateSnapshot) ProtoReflect() protoreflect.Message {
812 mi := &file_proto_eitri_v1_sync_proto_msgTypes[8] 1016 mi := &file_proto_eitri_v1_sync_proto_msgTypes[10]
813 if x != nil { 1017 if x != nil {
814 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1018 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
815 if ms.LoadMessageInfo() == nil { 1019 if ms.LoadMessageInfo() == nil {
@@ -822,7 +1026,7 @@ func (x *DesiredStateSnapshot) ProtoReflect() protoreflect.Message {
822 1026
823 // Deprecated: Use DesiredStateSnapshot.ProtoReflect.Descriptor instead. 1027 // Deprecated: Use DesiredStateSnapshot.ProtoReflect.Descriptor instead.
824 func (*DesiredStateSnapshot) Descriptor() ([]byte, []int) { 1028 func (*DesiredStateSnapshot) Descriptor() ([]byte, []int) {
825 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{8} 1029 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{10}
826 } 1030 }
827 1031
828 func (x *DesiredStateSnapshot) GetEpoch() uint64 { 1032 func (x *DesiredStateSnapshot) GetEpoch() uint64 {
@@ -851,7 +1055,7 @@ type ConsoleOpen struct {
851 1055
852 func (x *ConsoleOpen) Reset() { 1056 func (x *ConsoleOpen) Reset() {
853 *x = ConsoleOpen{} 1057 *x = ConsoleOpen{}
854 mi := &file_proto_eitri_v1_sync_proto_msgTypes[9] 1058 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11]
855 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1059 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
856 ms.StoreMessageInfo(mi) 1060 ms.StoreMessageInfo(mi)
857 } 1061 }
@@ -863,7 +1067,7 @@ func (x *ConsoleOpen) String() string {
863 func (*ConsoleOpen) ProtoMessage() {} 1067 func (*ConsoleOpen) ProtoMessage() {}
864 1068
865 func (x *ConsoleOpen) ProtoReflect() protoreflect.Message { 1069 func (x *ConsoleOpen) ProtoReflect() protoreflect.Message {
866 mi := &file_proto_eitri_v1_sync_proto_msgTypes[9] 1070 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11]
867 if x != nil { 1071 if x != nil {
868 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1072 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
869 if ms.LoadMessageInfo() == nil { 1073 if ms.LoadMessageInfo() == nil {
@@ -876,7 +1080,7 @@ func (x *ConsoleOpen) ProtoReflect() protoreflect.Message {
876 1080
877 // Deprecated: Use ConsoleOpen.ProtoReflect.Descriptor instead. 1081 // Deprecated: Use ConsoleOpen.ProtoReflect.Descriptor instead.
878 func (*ConsoleOpen) Descriptor() ([]byte, []int) { 1082 func (*ConsoleOpen) Descriptor() ([]byte, []int) {
879 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{9} 1083 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{11}
880 } 1084 }
881 1085
882 func (x *ConsoleOpen) GetVmId() string { 1086 func (x *ConsoleOpen) GetVmId() string {
@@ -899,7 +1103,7 @@ type ConsoleOpened struct {
899 1103
900 func (x *ConsoleOpened) Reset() { 1104 func (x *ConsoleOpened) Reset() {
901 *x = ConsoleOpened{} 1105 *x = ConsoleOpened{}
902 mi := &file_proto_eitri_v1_sync_proto_msgTypes[10] 1106 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12]
903 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1107 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
904 ms.StoreMessageInfo(mi) 1108 ms.StoreMessageInfo(mi)
905 } 1109 }
@@ -911,7 +1115,7 @@ func (x *ConsoleOpened) String() string {
911 func (*ConsoleOpened) ProtoMessage() {} 1115 func (*ConsoleOpened) ProtoMessage() {}
912 1116
913 func (x *ConsoleOpened) ProtoReflect() protoreflect.Message { 1117 func (x *ConsoleOpened) ProtoReflect() protoreflect.Message {
914 mi := &file_proto_eitri_v1_sync_proto_msgTypes[10] 1118 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12]
915 if x != nil { 1119 if x != nil {
916 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1120 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
917 if ms.LoadMessageInfo() == nil { 1121 if ms.LoadMessageInfo() == nil {
@@ -924,7 +1128,7 @@ func (x *ConsoleOpened) ProtoReflect() protoreflect.Message {
924 1128
925 // Deprecated: Use ConsoleOpened.ProtoReflect.Descriptor instead. 1129 // Deprecated: Use ConsoleOpened.ProtoReflect.Descriptor instead.
926 func (*ConsoleOpened) Descriptor() ([]byte, []int) { 1130 func (*ConsoleOpened) Descriptor() ([]byte, []int) {
927 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{10} 1131 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{12}
928 } 1132 }
929 1133
930 func (x *ConsoleOpened) GetOk() bool { 1134 func (x *ConsoleOpened) GetOk() bool {
@@ -954,7 +1158,7 @@ type TCPOpen struct {
954 1158
955 func (x *TCPOpen) Reset() { 1159 func (x *TCPOpen) Reset() {
956 *x = TCPOpen{} 1160 *x = TCPOpen{}
957 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] 1161 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13]
958 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1162 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
959 ms.StoreMessageInfo(mi) 1163 ms.StoreMessageInfo(mi)
960 } 1164 }
@@ -966,7 +1170,7 @@ func (x *TCPOpen) String() string {
966 func (*TCPOpen) ProtoMessage() {} 1170 func (*TCPOpen) ProtoMessage() {}
967 1171
968 func (x *TCPOpen) ProtoReflect() protoreflect.Message { 1172 func (x *TCPOpen) ProtoReflect() protoreflect.Message {
969 mi := &file_proto_eitri_v1_sync_proto_msgTypes[11] 1173 mi := &file_proto_eitri_v1_sync_proto_msgTypes[13]
970 if x != nil { 1174 if x != nil {
971 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1175 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
972 if ms.LoadMessageInfo() == nil { 1176 if ms.LoadMessageInfo() == nil {
@@ -979,7 +1183,7 @@ func (x *TCPOpen) ProtoReflect() protoreflect.Message {
979 1183
980 // Deprecated: Use TCPOpen.ProtoReflect.Descriptor instead. 1184 // Deprecated: Use TCPOpen.ProtoReflect.Descriptor instead.
981 func (*TCPOpen) Descriptor() ([]byte, []int) { 1185 func (*TCPOpen) Descriptor() ([]byte, []int) {
982 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{11} 1186 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{13}
983 } 1187 }
984 1188
985 func (x *TCPOpen) GetVmId() string { 1189 func (x *TCPOpen) GetVmId() string {
@@ -1009,7 +1213,7 @@ type TCPOpened struct {
1009 1213
1010 func (x *TCPOpened) Reset() { 1214 func (x *TCPOpened) Reset() {
1011 *x = TCPOpened{} 1215 *x = TCPOpened{}
1012 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] 1216 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14]
1013 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1217 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1014 ms.StoreMessageInfo(mi) 1218 ms.StoreMessageInfo(mi)
1015 } 1219 }
@@ -1021,7 +1225,7 @@ func (x *TCPOpened) String() string {
1021 func (*TCPOpened) ProtoMessage() {} 1225 func (*TCPOpened) ProtoMessage() {}
1022 1226
1023 func (x *TCPOpened) ProtoReflect() protoreflect.Message { 1227 func (x *TCPOpened) ProtoReflect() protoreflect.Message {
1024 mi := &file_proto_eitri_v1_sync_proto_msgTypes[12] 1228 mi := &file_proto_eitri_v1_sync_proto_msgTypes[14]
1025 if x != nil { 1229 if x != nil {
1026 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 1230 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1027 if ms.LoadMessageInfo() == nil { 1231 if ms.LoadMessageInfo() == nil {
@@ -1034,7 +1238,7 @@ func (x *TCPOpened) ProtoReflect() protoreflect.Message {
1034 1238
1035 // Deprecated: Use TCPOpened.ProtoReflect.Descriptor instead. 1239 // Deprecated: Use TCPOpened.ProtoReflect.Descriptor instead.
1036 func (*TCPOpened) Descriptor() ([]byte, []int) { 1240 func (*TCPOpened) Descriptor() ([]byte, []int) {
1037 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{12} 1241 return file_proto_eitri_v1_sync_proto_rawDescGZIP(), []int{14}
1038 } 1242 }
1039 1243
1040 func (x *TCPOpened) GetOk() bool { 1244 func (x *TCPOpened) GetOk() bool {
@@ -1067,7 +1271,7 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1067 "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshot\x12:\n" + 1271 "\bsnapshot\x18\x01 \x01(\v2\x1e.eitri.v1.DesiredStateSnapshotH\x00R\bsnapshot\x12:\n" +
1068 "\fconsole_open\x18\x02 \x01(\v2\x15.eitri.v1.ConsoleOpenH\x00R\vconsoleOpen\x12.\n" + 1272 "\fconsole_open\x18\x02 \x01(\v2\x15.eitri.v1.ConsoleOpenH\x00R\vconsoleOpen\x12.\n" +
1069 "\btcp_open\x18\x03 \x01(\v2\x11.eitri.v1.TCPOpenH\x00R\atcpOpenB\x05\n" + 1273 "\btcp_open\x18\x03 \x01(\v2\x11.eitri.v1.TCPOpenH\x00R\atcpOpenB\x05\n" +
1070 "\x03msg\"\x9b\x02\n" + 1274 "\x03msg\"\xc6\x02\n" +
1071 "\x05Hello\x12\x17\n" + 1275 "\x05Hello\x12\x17\n" +
1072 "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" + 1276 "\ahost_id\x18\x01 \x01(\tR\x06hostId\x12\x1a\n" +
1073 "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x0e\n" + 1277 "\bhostname\x18\x02 \x01(\tR\bhostname\x12\x0e\n" +
@@ -1080,11 +1284,32 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1080 "\bcapacity\x18\b \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12\x1e\n" + 1284 "\bcapacity\x18\b \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12\x1e\n" +
1081 "\n" + 1285 "\n" +
1082 "credential\x18\t \x01(\tR\n" + 1286 "credential\x18\t \x01(\tR\n" +
1083 "credential\"P\n" + 1287 "credential\x12)\n" +
1288 "\x05facts\x18\n" +
1289 " \x01(\v2\x13.eitri.v1.HostFactsR\x05facts\"P\n" +
1084 "\bCapacity\x12\x14\n" + 1290 "\bCapacity\x12\x14\n" +
1085 "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" + 1291 "\x05vcpus\x18\x01 \x01(\x03R\x05vcpus\x12\x15\n" +
1086 "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" + 1292 "\x06mem_mb\x18\x02 \x01(\x03R\x05memMb\x12\x17\n" +
1087 "\adisk_gb\x18\x03 \x01(\x03R\x06diskGb\"z\n" + 1293 "\adisk_gb\x18\x03 \x01(\x03R\x06diskGb\"\xa5\x01\n" +
1294 "\tHostFacts\x12\x13\n" +
1295 "\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1b\n" +
1296 "\tos_pretty\x18\x02 \x01(\tR\bosPretty\x12\x1d\n" +
1297 "\n" +
1298 "os_version\x18\x03 \x01(\tR\tosVersion\x12\x16\n" +
1299 "\x06kernel\x18\x04 \x01(\tR\x06kernel\x12\x1b\n" +
1300 "\tcpu_model\x18\x05 \x01(\tR\bcpuModel\x12\x12\n" +
1301 "\x04virt\x18\x06 \x01(\tR\x04virt\"\xfa\x01\n" +
1302 "\vHostMetrics\x12\x19\n" +
1303 "\buptime_s\x18\x01 \x01(\x03R\auptimeS\x12\x1e\n" +
1304 "\vmem_used_mb\x18\x02 \x01(\x03R\tmemUsedMb\x12(\n" +
1305 "\x10mem_available_mb\x18\x03 \x01(\x03R\x0ememAvailableMb\x12\x14\n" +
1306 "\x05load1\x18\x04 \x01(\x01R\x05load1\x12\x14\n" +
1307 "\x05load5\x18\x05 \x01(\x01R\x05load5\x12\x16\n" +
1308 "\x06load15\x18\x06 \x01(\x01R\x06load15\x12 \n" +
1309 "\fdisk_used_gb\x18\a \x01(\x03R\n" +
1310 "diskUsedGb\x12 \n" +
1311 "\fdisk_free_gb\x18\b \x01(\x03R\n" +
1312 "diskFreeGb\"z\n" +
1088 "\bActualVM\x12\x13\n" + 1313 "\bActualVM\x12\x13\n" +
1089 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" + 1314 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x14\n" +
1090 "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" + 1315 "\x05power\x18\x02 \x01(\tR\x05power\x12\x14\n" +
@@ -1097,14 +1322,15 @@ const file_proto_eitri_v1_sync_proto_rawDesc = "" +
1097 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" + 1322 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1f\n" +
1098 "\vvmspec_json\x18\x03 \x01(\fR\n" + 1323 "\vvmspec_json\x18\x03 \x01(\fR\n" +
1099 "vmspecJson\x12&\n" + 1324 "vmspecJson\x12&\n" +
1100 "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\x93\x02\n" + 1325 "\x0fdestroy_at_unix\x18\x04 \x01(\x03R\rdestroyAtUnix\"\xc4\x02\n" +
1101 "\x11ActualStateReport\x12$\n" + 1326 "\x11ActualStateReport\x12$\n" +
1102 "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" + 1327 "\x03vms\x18\x01 \x03(\v2\x12.eitri.v1.ActualVMR\x03vms\x12\x1c\n" +
1103 "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" + 1328 "\tdestroyed\x18\x02 \x03(\tR\tdestroyed\x129\n" +
1104 "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" + 1329 "\vquarantined\x18\x03 \x03(\v2\x17.eitri.v1.QuarantinedVMR\vquarantined\x12.\n" +
1105 "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" + 1330 "\bcapacity\x18\x04 \x01(\v2\x12.eitri.v1.CapacityR\bcapacity\x12'\n" +
1106 "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" + 1331 "\x0ffence_violation\x18\x05 \x01(\bR\x0efenceViolation\x12&\n" +
1107 "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\"\x85\x04\n" + 1332 "\x0flast_seen_epoch\x18\x06 \x01(\x04R\rlastSeenEpoch\x12/\n" +
1333 "\ametrics\x18\a \x01(\v2\x15.eitri.v1.HostMetricsR\ametrics\"\x85\x04\n" +
1108 "\tVMDesired\x12\x13\n" + 1334 "\tVMDesired\x12\x13\n" +
1109 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" + 1335 "\x05vm_id\x18\x01 \x01(\tR\x04vmId\x12\x12\n" +
1110 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + 1336 "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@@ -1155,40 +1381,44 @@ func file_proto_eitri_v1_sync_proto_rawDescGZIP() []byte {
1155 return file_proto_eitri_v1_sync_proto_rawDescData 1381 return file_proto_eitri_v1_sync_proto_rawDescData
1156 } 1382 }
1157 1383
1158 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 13) 1384 var file_proto_eitri_v1_sync_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
1159 var file_proto_eitri_v1_sync_proto_goTypes = []any{ 1385 var file_proto_eitri_v1_sync_proto_goTypes = []any{
1160 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage 1386 (*AgentMessage)(nil), // 0: eitri.v1.AgentMessage
1161 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage 1387 (*ServerMessage)(nil), // 1: eitri.v1.ServerMessage
1162 (*Hello)(nil), // 2: eitri.v1.Hello 1388 (*Hello)(nil), // 2: eitri.v1.Hello
1163 (*Capacity)(nil), // 3: eitri.v1.Capacity 1389 (*Capacity)(nil), // 3: eitri.v1.Capacity
1164 (*ActualVM)(nil), // 4: eitri.v1.ActualVM 1390 (*HostFacts)(nil), // 4: eitri.v1.HostFacts
1165 (*QuarantinedVM)(nil), // 5: eitri.v1.QuarantinedVM 1391 (*HostMetrics)(nil), // 5: eitri.v1.HostMetrics
1166 (*ActualStateReport)(nil), // 6: eitri.v1.ActualStateReport 1392 (*ActualVM)(nil), // 6: eitri.v1.ActualVM
1167 (*VMDesired)(nil), // 7: eitri.v1.VMDesired 1393 (*QuarantinedVM)(nil), // 7: eitri.v1.QuarantinedVM
1168 (*DesiredStateSnapshot)(nil), // 8: eitri.v1.DesiredStateSnapshot 1394 (*ActualStateReport)(nil), // 8: eitri.v1.ActualStateReport
1169 (*ConsoleOpen)(nil), // 9: eitri.v1.ConsoleOpen 1395 (*VMDesired)(nil), // 9: eitri.v1.VMDesired
1170 (*ConsoleOpened)(nil), // 10: eitri.v1.ConsoleOpened 1396 (*DesiredStateSnapshot)(nil), // 10: eitri.v1.DesiredStateSnapshot
1171 (*TCPOpen)(nil), // 11: eitri.v1.TCPOpen 1397 (*ConsoleOpen)(nil), // 11: eitri.v1.ConsoleOpen
1172 (*TCPOpened)(nil), // 12: eitri.v1.TCPOpened 1398 (*ConsoleOpened)(nil), // 12: eitri.v1.ConsoleOpened
1399 (*TCPOpen)(nil), // 13: eitri.v1.TCPOpen
1400 (*TCPOpened)(nil), // 14: eitri.v1.TCPOpened
1173 } 1401 }
1174 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{ 1402 var file_proto_eitri_v1_sync_proto_depIdxs = []int32{
1175 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello 1403 2, // 0: eitri.v1.AgentMessage.hello:type_name -> eitri.v1.Hello
1176 6, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport 1404 8, // 1: eitri.v1.AgentMessage.report:type_name -> eitri.v1.ActualStateReport
1177 10, // 2: eitri.v1.AgentMessage.console_opened:type_name -> eitri.v1.ConsoleOpened 1405 12, // 2: eitri.v1.AgentMessage.console_opened:type_name -> eitri.v1.ConsoleOpened
1178 12, // 3: eitri.v1.AgentMessage.tcp_opened:type_name -> eitri.v1.TCPOpened 1406 14, // 3: eitri.v1.AgentMessage.tcp_opened:type_name -> eitri.v1.TCPOpened
1179 8, // 4: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot 1407 10, // 4: eitri.v1.ServerMessage.snapshot:type_name -> eitri.v1.DesiredStateSnapshot
1180 9, // 5: eitri.v1.ServerMessage.console_open:type_name -> eitri.v1.ConsoleOpen 1408 11, // 5: eitri.v1.ServerMessage.console_open:type_name -> eitri.v1.ConsoleOpen
1181 11, // 6: eitri.v1.ServerMessage.tcp_open:type_name -> eitri.v1.TCPOpen 1409 13, // 6: eitri.v1.ServerMessage.tcp_open:type_name -> eitri.v1.TCPOpen
1182 3, // 7: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity 1410 3, // 7: eitri.v1.Hello.capacity:type_name -> eitri.v1.Capacity
1183 4, // 8: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM 1411 4, // 8: eitri.v1.Hello.facts:type_name -> eitri.v1.HostFacts
1184 5, // 9: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM 1412 6, // 9: eitri.v1.ActualStateReport.vms:type_name -> eitri.v1.ActualVM
1185 3, // 10: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity 1413 7, // 10: eitri.v1.ActualStateReport.quarantined:type_name -> eitri.v1.QuarantinedVM
1186 7, // 11: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired 1414 3, // 11: eitri.v1.ActualStateReport.capacity:type_name -> eitri.v1.Capacity
1187 12, // [12:12] is the sub-list for method output_type 1415 5, // 12: eitri.v1.ActualStateReport.metrics:type_name -> eitri.v1.HostMetrics
1188 12, // [12:12] is the sub-list for method input_type 1416 9, // 13: eitri.v1.DesiredStateSnapshot.vms:type_name -> eitri.v1.VMDesired
1189 12, // [12:12] is the sub-list for extension type_name 1417 14, // [14:14] is the sub-list for method output_type
1190 12, // [12:12] is the sub-list for extension extendee 1418 14, // [14:14] is the sub-list for method input_type
1191 0, // [0:12] is the sub-list for field type_name 1419 14, // [14:14] is the sub-list for extension type_name
1420 14, // [14:14] is the sub-list for extension extendee
1421 0, // [0:14] is the sub-list for field type_name
1192 } 1422 }
1193 1423
1194 func init() { file_proto_eitri_v1_sync_proto_init() } 1424 func init() { file_proto_eitri_v1_sync_proto_init() }
@@ -1213,7 +1443,7 @@ func file_proto_eitri_v1_sync_proto_init() {
1213 GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 1443 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
1214 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)), 1444 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_eitri_v1_sync_proto_rawDesc), len(file_proto_eitri_v1_sync_proto_rawDesc)),
1215 NumEnums: 0, 1445 NumEnums: 0,
1216 NumMessages: 13, 1446 NumMessages: 15,
1217 NumExtensions: 0, 1447 NumExtensions: 0,
1218 NumServices: 0, 1448 NumServices: 0,
1219 }, 1449 },
internal/server/api/api.go
Old New
@@ -284,6 +284,12 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al
284 Status: h.Status, 284 Status: h.Status,
285 EnrolledAt: h.EnrolledAt, 285 EnrolledAt: h.EnrolledAt,
286 Allocated: types.Capacity{VCPUs: alloc.VCPUs, MemMB: alloc.MemMB, DiskGB: alloc.DiskGB}, 286 Allocated: types.Capacity{VCPUs: alloc.VCPUs, MemMB: alloc.MemMB, DiskGB: alloc.DiskGB},
287 OSID: h.OSID,
288 OSPretty: h.OSPretty,
289 OSVersion: h.OSVersion,
290 Kernel: h.Kernel,
291 CPUModel: h.CPUModel,
292 Virt: h.Virt,
287 } 293 }
288 if ok { 294 if ok {
289 hr.Online = st.Online 295 hr.Online = st.Online
@@ -302,6 +308,23 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al
302 MemMB: st.Capacity.MemMB, 308 MemMB: st.Capacity.MemMB,
303 DiskGB: st.Capacity.DiskGB, 309 DiskGB: st.Capacity.DiskGB,
304 } 310 }
311 // Metrics are live/measured, so only emit them while the host is
312 // actually Online — a host with stale registry state (reported, then
313 // went silent) would otherwise serve stale utilization. Capacity above
314 // intentionally stays on the `ok` gate: totals are ~static, so a last
315 // known value is still useful; per-second metrics are not.
316 if st.Online {
317 hr.Metrics = &types.Metrics{
318 UptimeS: st.Metrics.UptimeS,
319 MemUsedMB: st.Metrics.MemUsedMB,
320 MemAvailableMB: st.Metrics.MemAvailableMB,
321 Load1: st.Metrics.Load1,
322 Load5: st.Metrics.Load5,
323 Load15: st.Metrics.Load15,
324 DiskUsedGB: st.Metrics.DiskUsedGB,
325 DiskFreeGB: st.Metrics.DiskFreeGB,
326 }
327 }
305 } 328 }
306 return hr 329 return hr
307 } 330 }
internal/server/api/hostinfo_api_test.go
Old New
@@ -0,0 +1,59 @@
1 package api
2
3 import (
4 "testing"
5
6 "github.com/a73x/eitri/internal/server/registry"
7 "github.com/a73x/eitri/internal/server/store"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 )
11
12 // Facts are persisted, so the HTTP host response carries them regardless of
13 // online state.
14 func TestHostResponseIncludesFacts(t *testing.T) {
15 ts, _, st := apiServer(t)
16 out := enroll(t, ts)
17 hostID := out["host_id"]
18
19 require.NoError(t, st.UpdateHostFacts(hostID, store.HostFacts{
20 OSID: "debian", OSPretty: "Debian GNU/Linux 12 (bookworm)", OSVersion: "12",
21 Kernel: "6.1.0-18-amd64", CPUModel: "AMD EPYC 7302P", Virt: "kvm",
22 }))
23
24 hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil))
25 require.Len(t, hosts, 1)
26 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", hosts[0]["os_pretty"])
27 assert.Equal(t, "6.1.0-18-amd64", hosts[0]["kernel"])
28 assert.Equal(t, "kvm", hosts[0]["virt"])
29 }
30
31 // Metrics are registry-only: present when online, nil when the host has no
32 // live state.
33 func TestToHostResponseMetricsOnlyWhenOnline(t *testing.T) {
34 h := store.Host{ID: "h1", Name: "host-a", OSPretty: "Debian GNU/Linux 12 (bookworm)"}
35
36 online := registry.HostState{
37 Report: registry.Report{Metrics: registry.Metrics{UptimeS: 3600, MemUsedMB: 2048, Load1: 1.5, DiskFreeGB: 80}},
38 Online: true,
39 }
40 hr := toHostResponse(h, online, true, store.Alloc{})
41 require.NotNil(t, hr.Metrics)
42 assert.Equal(t, int64(3600), hr.Metrics.UptimeS)
43 assert.Equal(t, int64(2048), hr.Metrics.MemUsedMB)
44 assert.InDelta(t, 1.5, hr.Metrics.Load1, 0.001)
45 // Facts ride along even for a value-built response.
46 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", hr.OSPretty)
47
48 offline := toHostResponse(h, registry.HostState{}, false, store.Alloc{})
49 assert.Nil(t, offline.Metrics, "metrics omitted when the host is not online")
50
51 // Stale registry state (reported once, then went silent): ok=true but
52 // Online=false. Metrics must NOT be served stale — gated on Online, not ok.
53 stale := registry.HostState{
54 Report: registry.Report{Metrics: registry.Metrics{UptimeS: 3600, Load1: 1.5}},
55 Online: false,
56 }
57 hr = toHostResponse(h, stale, true, store.Alloc{})
58 assert.Nil(t, hr.Metrics, "stale (offline-but-known) host must not serve live metrics")
59 }
internal/server/api/testdata/host.golden.json
Old New
@@ -21,5 +21,21 @@
21 "vcpus": 4, 21 "vcpus": 4,
22 "mem_mb": 8192, 22 "mem_mb": 8192,
23 "disk_gb": 100 23 "disk_gb": 100
24 },
25 "os_id": "arch",
26 "os_pretty": "Arch Linux",
27 "os_version": "rolling",
28 "kernel": "6.15.4-arch1-1",
29 "cpu_model": "AMD Ryzen 9 7950X",
30 "virt": "kvm",
31 "metrics": {
32 "uptime_s": 86400,
33 "mem_used_mb": 12000,
34 "mem_available_mb": 20768,
35 "load1": 1.5,
36 "load5": 2.5,
37 "load15": 3.5,
38 "disk_used_gb": 200,
39 "disk_free_gb": 312
24 } 40 }
25 } 41 }
internal/server/api/testdata/snapshot.golden.json
Old New
@@ -23,6 +23,22 @@
23 "vcpus": 4, 23 "vcpus": 4,
24 "mem_mb": 8192, 24 "mem_mb": 8192,
25 "disk_gb": 100 25 "disk_gb": 100
26 },
27 "os_id": "arch",
28 "os_pretty": "Arch Linux",
29 "os_version": "rolling",
30 "kernel": "6.15.4-arch1-1",
31 "cpu_model": "AMD Ryzen 9 7950X",
32 "virt": "kvm",
33 "metrics": {
34 "uptime_s": 86400,
35 "mem_used_mb": 12000,
36 "mem_available_mb": 20768,
37 "load1": 1.5,
38 "load5": 2.5,
39 "load15": 3.5,
40 "disk_used_gb": 200,
41 "disk_free_gb": 312
26 } 42 }
27 } 43 }
28 ], 44 ],
internal/server/api/types/types.go
Old New
@@ -19,6 +19,20 @@ type Capacity struct {
19 DiskGB int64 `json:"disk_gb"` 19 DiskGB int64 `json:"disk_gb"`
20 } 20 }
21 21
22 // Metrics is live MEASURED host utilization, nested in Host for
23 // GET /api/v1/hosts and the SSE snapshot — distinct from Capacity (totals)
24 // and the allocated bookkeeping.
25 type Metrics struct {
26 UptimeS int64 `json:"uptime_s"`
27 MemUsedMB int64 `json:"mem_used_mb"`
28 MemAvailableMB int64 `json:"mem_available_mb"`
29 Load1 float64 `json:"load1"`
30 Load5 float64 `json:"load5"`
31 Load15 float64 `json:"load15"`
32 DiskUsedGB int64 `json:"disk_used_gb"`
33 DiskFreeGB int64 `json:"disk_free_gb"`
34 }
35
22 // Host is the explicit snake_case wire representation of a host, served by 36 // Host is the explicit snake_case wire representation of a host, served by
23 // GET /api/v1/hosts and the SSE snapshot. Every field is spelled out — no 37 // GET /api/v1/hosts and the SSE snapshot. Every field is spelled out — no
24 // struct embedding — to prevent PascalCase field leakage. 38 // struct embedding — to prevent PascalCase field leakage.
@@ -43,6 +57,16 @@ type Host struct {
43 Sessions int `json:"sessions"` 57 Sessions int `json:"sessions"`
44 Capacity Capacity `json:"capacity"` // host TOTALS (when online) 58 Capacity Capacity `json:"capacity"` // host TOTALS (when online)
45 Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed) 59 Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed)
60 // Host OS facts (persisted; refreshed from each Hello).
61 OSID string `json:"os_id"`
62 OSPretty string `json:"os_pretty"`
63 OSVersion string `json:"os_version"`
64 Kernel string `json:"kernel"`
65 CPUModel string `json:"cpu_model"`
66 Virt string `json:"virt"`
67 // Metrics is live MEASURED utilization, present only when online. Distinct
68 // from Allocated (control-plane bookkeeping / VM quotas).
69 Metrics *Metrics `json:"metrics"`
46 } 70 }
47 71
48 // VM is the explicit snake_case wire representation of a VM, served by 72 // VM is the explicit snake_case wire representation of a VM, served by
internal/server/api/wire_golden_test.go
Old New
@@ -61,6 +61,22 @@ func TestWireGolden(t *testing.T) {
61 Online: true, 61 Online: true,
62 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512}, 62 Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512},
63 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100}, 63 Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100},
64 OSID: "arch",
65 OSPretty: "Arch Linux",
66 OSVersion: "rolling",
67 Kernel: "6.15.4-arch1-1",
68 CPUModel: "AMD Ryzen 9 7950X",
69 Virt: "kvm",
70 Metrics: &types.Metrics{
71 UptimeS: 86400,
72 MemUsedMB: 12000,
73 MemAvailableMB: 20768,
74 Load1: 1.5,
75 Load5: 2.5,
76 Load15: 3.5,
77 DiskUsedGB: 200,
78 DiskFreeGB: 312,
79 },
64 } 80 }
65 goldenCheck(t, "host", host) 81 goldenCheck(t, "host", host)
66 82
internal/server/registry/registry.go
Old New
@@ -26,6 +26,16 @@ const StaleWindow = OnlineWindow / 2
26 26
27 type Capacity struct{ VCPUs, MemMB, DiskGB int64 } 27 type Capacity struct{ VCPUs, MemMB, DiskGB int64 }
28 28
29 // Metrics is live, measured host utilization (never persisted). It rides each
30 // report into the registry and is dropped when the host goes offline. Distinct
31 // from Capacity (host totals) and from allocation (control-plane bookkeeping).
32 type Metrics struct {
33 UptimeS int64
34 MemUsedMB, MemAvailableMB int64
35 Load1, Load5, Load15 float64
36 DiskUsedGB, DiskFreeGB int64
37 }
38
29 type ActualVM struct { 39 type ActualVM struct {
30 VMID, Power, Phase, IP, LastError string 40 VMID, Power, Phase, IP, LastError string
31 } 41 }
@@ -40,6 +50,7 @@ type Report struct {
40 VMs []ActualVM 50 VMs []ActualVM
41 Quarantined []QuarantinedVM 51 Quarantined []QuarantinedVM
42 Capacity Capacity 52 Capacity Capacity
53 Metrics Metrics
43 FenceViolation bool 54 FenceViolation bool
44 LastSeenEpoch uint64 55 LastSeenEpoch uint64
45 } 56 }
internal/server/registry/registry_test.go
Old New
@@ -5,6 +5,7 @@ import (
5 "time" 5 "time"
6 6
7 "github.com/stretchr/testify/assert" 7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
8 ) 9 )
9 10
10 func TestGetReturnsDefensiveCopy(t *testing.T) { 11 func TestGetReturnsDefensiveCopy(t *testing.T) {
@@ -78,6 +79,19 @@ func TestStalenessDerivation(t *testing.T) {
78 } 79 }
79 } 80 }
80 81
82 func TestReportCarriesMetrics(t *testing.T) {
83 r := New(time.Now)
84 r.UpdateReport("h1", Report{
85 Metrics: Metrics{UptimeS: 3600, MemUsedMB: 2048, Load1: 1.5, DiskFreeGB: 80},
86 })
87 st, ok := r.Get("h1")
88 require.True(t, ok)
89 assert.Equal(t, int64(3600), st.Metrics.UptimeS)
90 assert.Equal(t, int64(2048), st.Metrics.MemUsedMB)
91 assert.InDelta(t, 1.5, st.Metrics.Load1, 0.001)
92 assert.Equal(t, int64(80), st.Metrics.DiskFreeGB)
93 }
94
81 // TestSessionsCountAndReportPreservation pins that RecordConnect counts agent 95 // TestSessionsCountAndReportPreservation pins that RecordConnect counts agent
82 // (re)connects, that a report preserves the counter (flapping stays visible), 96 // (re)connects, that a report preserves the counter (flapping stays visible),
83 // and that a connect before any report leaves the host offline with no age. 97 // and that a connect before any report leaves the host offline with no age.
internal/server/store/store.go
Old New
@@ -54,6 +54,14 @@ type Host struct {
54 EnrolledAt time.Time 54 EnrolledAt time.Time
55 // Tenant is the owning tenant (partition key); derived from the enroll token. 55 // Tenant is the owning tenant (partition key); derived from the enroll token.
56 Tenant string 56 Tenant string
57 // Host OS facts, best-effort, refreshed from the agent's Hello each connect.
58 OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string
59 }
60
61 // HostFacts is the write-side shape for UpdateHostFacts (store stays pb-free;
62 // syncsvc maps pb.HostFacts → store.HostFacts).
63 type HostFacts struct {
64 OSID, OSPretty, OSVersion, Kernel, CPUModel, Virt string
57 } 65 }
58 66
59 type VM struct { 67 type VM struct {
@@ -102,7 +110,13 @@ CREATE TABLE IF NOT EXISTS hosts (
102 status TEXT NOT NULL DEFAULT 'enrolled', 110 status TEXT NOT NULL DEFAULT 'enrolled',
103 enrolled_at DATETIME NOT NULL, 111 enrolled_at DATETIME NOT NULL,
104 cred_generation INTEGER NOT NULL DEFAULT 1, 112 cred_generation INTEGER NOT NULL DEFAULT 1,
105 tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id) 113 tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id),
114 os_id TEXT NOT NULL DEFAULT '',
115 os_pretty TEXT NOT NULL DEFAULT '',
116 os_version TEXT NOT NULL DEFAULT '',
117 kernel TEXT NOT NULL DEFAULT '',
118 cpu_model TEXT NOT NULL DEFAULT '',
119 virt TEXT NOT NULL DEFAULT ''
106 ); 120 );
107 121
108 CREATE TABLE IF NOT EXISTS enrollment_tokens ( 122 CREATE TABLE IF NOT EXISTS enrollment_tokens (
@@ -405,8 +419,8 @@ func (s *Store) GetHost(id string) (Host, error) {
405 var h Host 419 var h Host
406 var enrolledAt string 420 var enrolledAt string
407 err := s.db.QueryRow( 421 err := s.db.QueryRow(
408 `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts WHERE id=?`, id, 422 `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt FROM hosts WHERE id=?`, id,
409 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant) 423 ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt)
410 if err != nil { 424 if err != nil {
411 return Host{}, err 425 return Host{}, err
412 } 426 }
@@ -414,6 +428,17 @@ func (s *Store) GetHost(id string) (Host, error) {
414 return h, nil 428 return h, nil
415 } 429 }
416 430
431 // UpdateHostFacts overwrites the host's OS facts from its Hello. Callers treat
432 // failure as non-fatal (it must not drop the connection). Empty fields are
433 // written as-is: an agent that can no longer read a source clears the stale
434 // value rather than freezing it.
435 func (s *Store) UpdateHostFacts(id string, f HostFacts) error {
436 _, err := s.db.Exec(
437 `UPDATE hosts SET os_id=?, os_pretty=?, os_version=?, kernel=?, cpu_model=?, virt=? WHERE id=?`,
438 f.OSID, f.OSPretty, f.OSVersion, f.Kernel, f.CPUModel, f.Virt, id)
439 return err
440 }
441
417 // BumpCredGeneration increments the host's credential generation, revoking 442 // BumpCredGeneration increments the host's credential generation, revoking
418 // every credential minted at the previous generation, and returns the new 443 // every credential minted at the previous generation, and returns the new
419 // value. The host.credential.revoke audit row is written in the SAME 444 // value. The host.credential.revoke audit row is written in the SAME
@@ -450,7 +475,7 @@ type querier interface {
450 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } 475 func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) }
451 476
452 func listHosts(q querier) ([]Host, error) { 477 func listHosts(q querier) ([]Host, error) {
453 rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts`) 478 rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt FROM hosts`)
454 if err != nil { 479 if err != nil {
455 return nil, err 480 return nil, err
456 } 481 }
@@ -459,7 +484,7 @@ func listHosts(q querier) ([]Host, error) {
459 for rows.Next() { 484 for rows.Next() {
460 var h Host 485 var h Host
461 var enrolledAt string 486 var enrolledAt string
462 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant); err != nil { 487 if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt); err != nil {
463 return nil, err 488 return nil, err
464 } 489 }
465 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) 490 h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt)
internal/server/store/store_test.go
Old New
@@ -689,3 +689,28 @@ func TestCreateEnrollmentTokenRejectsUnknownTenant(t *testing.T) {
689 _, err := s.CreateEnrollmentToken("no-such-tenant") 689 _, err := s.CreateEnrollmentToken("no-such-tenant")
690 require.ErrorContains(t, err, "unknown tenant", "minting for a nonexistent tenant must fail at mint, not at redeem") 690 require.ErrorContains(t, err, "unknown tenant", "minting for a nonexistent tenant must fail at mint, not at redeem")
691 } 691 }
692
693 func TestUpdateHostFactsRoundTrips(t *testing.T) {
694 s := newStore(t)
695 h := enrollHost(t, s)
696
697 require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{
698 OSID: "debian", OSPretty: "Debian GNU/Linux 12 (bookworm)", OSVersion: "12",
699 Kernel: "6.1.0-18-amd64", CPUModel: "AMD EPYC 7302P", Virt: "kvm",
700 }))
701
702 got, err := s.GetHost(h.ID)
703 require.NoError(t, err)
704 assert.Equal(t, "debian", got.OSID)
705 assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", got.OSPretty)
706 assert.Equal(t, "12", got.OSVersion)
707 assert.Equal(t, "6.1.0-18-amd64", got.Kernel)
708 assert.Equal(t, "AMD EPYC 7302P", got.CPUModel)
709 assert.Equal(t, "kvm", got.Virt)
710
711 // listHosts must scan the same columns.
712 hosts, err := s.ListHosts()
713 require.NoError(t, err)
714 require.Len(t, hosts, 1)
715 assert.Equal(t, "6.1.0-18-amd64", hosts[0].Kernel)
716 }
internal/server/syncsvc/syncsvc.go
Old New
@@ -150,6 +150,12 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
150 s.reg.RecordConnect(hostID) 150 s.reg.RecordConnect(hostID)
151 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch()) 151 slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch())
152 152
153 // Best-effort: refresh the host's OS facts from this Hello. A failed write
154 // must not drop the connection — the report path is otherwise authoritative.
155 if err := s.st.UpdateHostFacts(hostID, toStoreFacts(h.GetFacts())); err != nil {
156 slog.Warn("update host facts", "host", hostID, "err", err)
157 }
158
153 // Down-stream: server opens it; first write makes it visible to the agent. 159 // Down-stream: server opens it; first write makes it visible to the agent.
154 down, err := conn.OpenStreamSync(ctx) 160 down, err := conn.OpenStreamSync(ctx)
155 if err != nil { 161 if err != nil {
@@ -297,6 +303,7 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) {
297 r.VMs = toRegistryVMs(rep.GetVms()) 303 r.VMs = toRegistryVMs(rep.GetVms())
298 r.Quarantined = toRegistryQuarantined(rep.GetQuarantined()) 304 r.Quarantined = toRegistryQuarantined(rep.GetQuarantined())
299 r.Capacity = toRegistryCapacity(rep.GetCapacity()) 305 r.Capacity = toRegistryCapacity(rep.GetCapacity())
306 r.Metrics = toRegistryMetrics(rep.GetMetrics())
300 307
301 s.reg.UpdateReport(hostID, r) 308 s.reg.UpdateReport(hostID, r)
302 309
@@ -407,6 +414,34 @@ func toRegistryCapacity(c *pb.Capacity) registry.Capacity {
407 return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()} 414 return registry.Capacity{VCPUs: c.GetVcpus(), MemMB: c.GetMemMb(), DiskGB: c.GetDiskGb()}
408 } 415 }
409 416
417 // toStoreFacts maps reported host facts (nil → zero value) to the store shape.
418 func toStoreFacts(f *pb.HostFacts) store.HostFacts {
419 if f == nil {
420 return store.HostFacts{}
421 }
422 return store.HostFacts{
423 OSID: f.GetOsId(), OSPretty: f.GetOsPretty(), OSVersion: f.GetOsVersion(),
424 Kernel: f.GetKernel(), CPUModel: f.GetCpuModel(), Virt: f.GetVirt(),
425 }
426 }
427
428 // toRegistryMetrics maps reported live metrics (nil → zero value).
429 func toRegistryMetrics(m *pb.HostMetrics) registry.Metrics {
430 if m == nil {
431 return registry.Metrics{}
432 }
433 return registry.Metrics{
434 UptimeS: m.GetUptimeS(),
435 MemUsedMB: m.GetMemUsedMb(),
436 MemAvailableMB: m.GetMemAvailableMb(),
437 Load1: m.GetLoad1(),
438 Load5: m.GetLoad5(),
439 Load15: m.GetLoad15(),
440 DiskUsedGB: m.GetDiskUsedGb(),
441 DiskFreeGB: m.GetDiskFreeGb(),
442 }
443 }
444
410 // ErrAgentOffline reports that the target host has no live sync connection. 445 // ErrAgentOffline reports that the target host has no live sync connection.
411 var ErrAgentOffline = errors.New("agent not connected") 446 var ErrAgentOffline = errors.New("agent not connected")
412 447
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -682,3 +682,54 @@ func TestOpenTCPRefused(t *testing.T) {
682 require.Error(t, err) 682 require.Error(t, err)
683 assert.Contains(t, err.Error(), "connection refused") 683 assert.Contains(t, err.Error(), "connection refused")
684 } 684 }
685
686 func TestHelloPersistsHostFacts(t *testing.T) {
687 f := setup(t)
688 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
689 defer cancel()
690 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(f.fp),
691 &quic.Config{MaxIdleTimeout: 5 * time.Second})
692 require.NoError(t, err)
693 t.Cleanup(func() { conn.CloseWithError(0, "") })
694 up, err := conn.OpenStreamSync(ctx)
695 require.NoError(t, err)
696 require.NoError(t, transport.WriteMsg(up, &pb.AgentMessage{Msg: &pb.AgentMessage_Hello{Hello: &pb.Hello{
697 HostId: f.host.ID, Credential: f.cred,
698 Facts: &pb.HostFacts{
699 OsId: "debian", OsPretty: "Debian GNU/Linux 12 (bookworm)", OsVersion: "12",
700 Kernel: "6.1.0-18-amd64", CpuModel: "AMD EPYC 7302P", Virt: "kvm",
701 },
702 }}}))
703 _, err = conn.AcceptStream(ctx) // down-stream opens only after the Hello is processed
704 require.NoError(t, err)
705
706 require.Eventually(t, func() bool {
707 h, err := f.st.GetHost(f.host.ID)
708 return err == nil && h.OSPretty == "Debian GNU/Linux 12 (bookworm)"
709 }, 2*time.Second, 20*time.Millisecond)
710 h, _ := f.st.GetHost(f.host.ID)
711 assert.Equal(t, "debian", h.OSID)
712 assert.Equal(t, "6.1.0-18-amd64", h.Kernel)
713 assert.Equal(t, "kvm", h.Virt)
714 }
715
716 func TestReportMetricsLandInRegistry(t *testing.T) {
717 f := setup(t)
718 c := mustDial(t, f)
719 c.recv(t) // initial snapshot
720
721 c.send(t, &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: &pb.ActualStateReport{
722 Capacity: &pb.Capacity{Vcpus: 8},
723 Metrics: &pb.HostMetrics{UptimeS: 3600, Load1: 1.5, MemUsedMb: 2048, MemAvailableMb: 6144, DiskUsedGb: 20, DiskFreeGb: 80},
724 LastSeenEpoch: 2,
725 }}})
726
727 require.Eventually(t, func() bool {
728 st, ok := f.reg.Get(f.host.ID)
729 return ok && st.Metrics.UptimeS == 3600
730 }, 2*time.Second, 20*time.Millisecond)
731 st, _ := f.reg.Get(f.host.ID)
732 assert.InDelta(t, 1.5, st.Metrics.Load1, 0.001)
733 assert.Equal(t, int64(2048), st.Metrics.MemUsedMB)
734 assert.Equal(t, int64(80), st.Metrics.DiskFreeGB)
735 }
proto/eitri/v1/sync.proto
Old New
@@ -29,6 +29,7 @@ message Hello {
29 uint64 last_seen_epoch = 7; // for the restore runbook 29 uint64 last_seen_epoch = 7; // for the restore runbook
30 Capacity capacity = 8; 30 Capacity capacity = 8;
31 string credential = 9; // Bearer host credential, verified in first frame 31 string credential = 9; // Bearer host credential, verified in first frame
32 HostFacts facts = 10; // best-effort static host identity; refreshed each Hello
32 } 33 }
33 34
34 message Capacity { 35 message Capacity {
@@ -37,6 +38,30 @@ message Capacity {
37 int64 disk_gb = 3; 38 int64 disk_gb = 3;
38 } 39 }
39 40
41 // HostFacts is slow-changing host identity, gathered best-effort by the agent.
42 // Every field is empty when its source can't be read.
43 message HostFacts {
44 string os_id = 1; // /etc/os-release ID, e.g. "debian"
45 string os_pretty = 2; // PRETTY_NAME, e.g. "Debian GNU/Linux 12 (bookworm)"
46 string os_version = 3; // VERSION_ID, e.g. "12"
47 string kernel = 4; // kernel release, e.g. "6.1.0-18-amd64"
48 string cpu_model = 5; // /proc/cpuinfo model name
49 string virt = 6; // systemd-detect-virt: "kvm" | "none" | "" (unknown)
50 }
51
52 // HostMetrics is live measured host utilization, refreshed each report. It is
53 // NOT persisted — it lives only in the registry while the host is online.
54 message HostMetrics {
55 int64 uptime_s = 1;
56 int64 mem_used_mb = 2;
57 int64 mem_available_mb = 3;
58 double load1 = 4; // 1-min load average (whole machine)
59 double load5 = 5;
60 double load15 = 6;
61 int64 disk_used_gb = 7;
62 int64 disk_free_gb = 8;
63 }
64
40 message ActualVM { 65 message ActualVM {
41 string vm_id = 1; 66 string vm_id = 1;
42 string power = 2; // "running"|"stopped" 67 string power = 2; // "running"|"stopped"
@@ -61,6 +86,7 @@ message ActualStateReport {
61 Capacity capacity = 4; 86 Capacity capacity = 4;
62 bool fence_violation = 5; 87 bool fence_violation = 5;
63 uint64 last_seen_epoch = 6; 88 uint64 last_seen_epoch = 6;
89 HostMetrics metrics = 7; // live measured host utilization (heartbeat)
64 } 90 }
65 91
66 message VMDesired { 92 message VMDesired {
web/src/lib/api-types.ts
Old New
@@ -979,19 +979,36 @@ export interface components {
979 arch: string; 979 arch: string;
980 bridge_cidr: string; 980 bridge_cidr: string;
981 capacity: components["schemas"]["Capacity"]; 981 capacity: components["schemas"]["Capacity"];
982 cpu_model: string;
982 /** Format: date-time */ 983 /** Format: date-time */
983 enrolled_at: string; 984 enrolled_at: string;
984 id: string; 985 id: string;
986 kernel: string;
985 /** Format: date-time */ 987 /** Format: date-time */
986 last_seen?: string | null; 988 last_seen?: string | null;
989 metrics?: components["schemas"]["Metrics"] | null;
987 name: string; 990 name: string;
988 online: boolean; 991 online: boolean;
989 os: string; 992 os: string;
993 os_id: string;
994 os_pretty: string;
995 os_version: string;
990 provisioner: string; 996 provisioner: string;
991 seconds_since_last_seen?: number | null; 997 seconds_since_last_seen?: number | null;
992 sessions: number; 998 sessions: number;
993 stale: boolean; 999 stale: boolean;
994 status: string; 1000 status: string;
1001 virt: string;
1002 };
1003 Metrics: {
1004 disk_free_gb: number;
1005 disk_used_gb: number;
1006 load1: number;
1007 load15: number;
1008 load5: number;
1009 mem_available_mb: number;
1010 mem_used_mb: number;
1011 uptime_s: number;
995 }; 1012 };
996 PatchVMRequest: { 1013 PatchVMRequest: {
997 power_state?: string; 1014 power_state?: string;
web/src/lib/fleet.svelte.ts
Old New
@@ -8,6 +8,7 @@
8 import type { components } from './api-types'; 8 import type { components } from './api-types';
9 9
10 export type Capacity = components['schemas']['Capacity']; 10 export type Capacity = components['schemas']['Capacity'];
11 export type Metrics = components['schemas']['Metrics'];
11 export type Host = components['schemas']['Host']; 12 export type Host = components['schemas']['Host'];
12 export type VM = components['schemas']['VM']; 13 export type VM = components['schemas']['VM'];
13 14
web/src/routes/+page.svelte
Old New
@@ -32,7 +32,7 @@
32 fleet.hosts.filter( 32 fleet.hosts.filter(
33 (h) => 33 (h) =>
34 !needle || 34 !needle ||
35 `${h.name} ${h.bridge_cidr} ${h.status} ${h.online ? 'online' : 'offline'}` 35 `${h.name} ${osLabel(h)} ${h.bridge_cidr} ${h.status} ${h.online ? 'online' : 'offline'}`
36 .toLowerCase() 36 .toLowerCase()
37 .includes(needle) 37 .includes(needle)
38 ) 38 )
@@ -51,6 +51,29 @@
51 function hostName(hostId: string): string { 51 function hostName(hostId: string): string {
52 return hostNameById.get(hostId) ?? hostId.slice(0, 8); 52 return hostNameById.get(hostId) ?? hostId.slice(0, 8);
53 } 53 }
54 // osLabel: compact distro for the list ("Ubuntu 24.04"), falling back to the
55 // full os-release pretty name, then the coarse GOOS, then a dash for a
56 // host that has not reported facts yet.
57 function osLabel(h: (typeof fleet.hosts)[number]): string {
58 if (h.os_id && h.os_version) {
59 return h.os_id.charAt(0).toUpperCase() + h.os_id.slice(1) + ' ' + h.os_version;
60 }
61 return h.os_pretty || h.os || '—';
62 }
63 // osTitle: the full pretty name + kernel, shown on hover so the detail is a
64 // mouseover away without widening the column.
65 function osTitle(h: (typeof fleet.hosts)[number]): string {
66 return [h.os_pretty, h.kernel].filter(Boolean).join(' · ');
67 }
68 // loadRatio: normalized pressure = 1-min load ÷ vCPUs (a 0–1+ gauge, unlike
69 // the raw whole-machine load). null when offline, metric-less, or vCPUs are
70 // unknown — the cell then shows a dash rather than a bogus 0.
71 function loadRatio(h: (typeof fleet.hosts)[number]): number | null {
72 if (!h.online || !h.metrics) return null;
73 const vcpus = h.capacity.vcpus;
74 if (!vcpus) return null;
75 return h.metrics.load1 / vcpus;
76 }
54 const shownVMs = $derived( 77 const shownVMs = $derived(
55 fleet.vms.filter((v) => { 78 fleet.vms.filter((v) => {
56 if (!needle) return true; 79 if (!needle) return true;
@@ -166,16 +189,33 @@
166 {:else} 189 {:else}
167 <table> 190 <table>
168 <thead> 191 <thead>
169 <tr><th>Name</th><th>Status</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr> 192 <tr><th>Name</th><th>Status</th><th>OS</th><th>Load</th><th>VMs</th><th>CIDR</th><th>Used / total (vCPU · mem · disk)</th><th></th></tr>
170 </thead> 193 </thead>
171 <tbody> 194 <tbody>
172 {#each shownHosts as h (h.id)} 195 {#each shownHosts as h (h.id)}
196 {@const load = loadRatio(h)}
173 <tr> 197 <tr>
174 <td><a href="/hosts/{h.id}">{h.name}</a></td> 198 <td><a href="/hosts/{h.id}">{h.name}</a></td>
175 <td> 199 <td>
176 <span class="dot {h.online ? 'on' : 'off'}"></span> 200 <span class="dot {h.online ? 'on' : 'off'}"></span>
177 {h.status}{h.online ? '' : ' · offline'} 201 {h.status}{h.online ? '' : ' · offline'}
178 </td> 202 </td>
203 <td title={osTitle(h)}>{osLabel(h)}</td>
204 <td>
205 {#if load !== null}
206 <span class="loadcell" title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU">
207 <span
208 class="loadbar"
209 class:calm={load <= 0.7}
210 class:warm={load > 0.7 && load <= 1}
211 class:hot={load > 1}
212 ><span style="width:{Math.min(load, 1) * 100}%"></span></span>
213 {load.toFixed(2)}
214 </span>
215 {:else}
216 <span class="hint">—</span>
217 {/if}
218 </td>
179 <td>{vmCountByHost.get(h.id) ?? 0}</td> 219 <td>{vmCountByHost.get(h.id) ?? 0}</td>
180 <td>{h.bridge_cidr}</td> 220 <td>{h.bridge_cidr}</td>
181 <td> 221 <td>
@@ -348,6 +388,36 @@
348 display: flex; 388 display: flex;
349 gap: 0.3rem; 389 gap: 0.3rem;
350 } 390 }
391 /* Normalized-load pressure gauge: the number is the source of truth, the
392 bar + colour are a supplementary at-a-glance signal (calm/warm/hot). */
393 .loadcell {
394 display: inline-flex;
395 align-items: center;
396 gap: 0.4rem;
397 white-space: nowrap;
398 }
399 .loadbar {
400 display: inline-block;
401 width: 34px;
402 height: 6px;
403 border-radius: 3px;
404 background: #2a2e37;
405 overflow: hidden;
406 }
407 .loadbar > span {
408 display: block;
409 height: 100%;
410 border-radius: 3px;
411 }
412 .loadbar.calm > span {
413 background: #8fe3a0;
414 }
415 .loadbar.warm > span {
416 background: #e3c98f;
417 }
418 .loadbar.hot > span {
419 background: #e38f8f;
420 }
351 .enroll { 421 .enroll {
352 background: #15171c; 422 background: #15171c;
353 border: 1px solid #2a2e37; 423 border: 1px solid #2a2e37;
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -15,6 +15,15 @@
15 const host = $derived(fleet.hosts.find((h) => h.id === id)); 15 const host = $derived(fleet.hosts.find((h) => h.id === id));
16 const vms = $derived(id ? vmsForHost(id) : []); 16 const vms = $derived(id ? vmsForHost(id) : []);
17 17
18 function fmtUptime(s: number): string {
19 const d = Math.floor(s / 86400);
20 const h = Math.floor((s % 86400) / 3600);
21 const m = Math.floor((s % 3600) / 60);
22 if (d > 0) return `${d}d ${h}h`;
23 if (h > 0) return `${h}h ${m}m`;
24 return `${m}m`;
25 }
26
18 async function decommission() { 27 async function decommission() {
19 if (!host) return; 28 if (!host) return;
20 if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return; 29 if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return;
@@ -33,7 +42,10 @@
33 <tbody> 42 <tbody>
34 <tr><th>ID</th><td>{host.id}</td></tr> 43 <tr><th>ID</th><td>{host.id}</td></tr>
35 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr> 44 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr>
36 <tr><th>OS / Arch</th><td>{host.os} / {host.arch}</td></tr> 45 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
46 <tr><th>Kernel</th><td>{host.kernel || '—'}</td></tr>
47 <tr><th>CPU</th><td>{host.cpu_model || '—'}</td></tr>
48 <tr><th>Virtualization</th><td>{host.virt || '—'}</td></tr>
37 <tr><th>Provisioner</th><td>{host.provisioner}</td></tr> 49 <tr><th>Provisioner</th><td>{host.provisioner}</td></tr>
38 <tr><th>Bridge CIDR</th><td>{host.bridge_cidr}</td></tr> 50 <tr><th>Bridge CIDR</th><td>{host.bridge_cidr}</td></tr>
39 <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr> 51 <tr><th>Capacity</th><td>{host.capacity.vcpus}c / {host.capacity.mem_mb}MB / {host.capacity.disk_gb}GB</td></tr>
@@ -52,6 +64,21 @@
52 vCPU is commonly oversubscribed; memory and disk are hard limits. 64 vCPU is commonly oversubscribed; memory and disk are hard limits.
53 </p> 65 </p>
54 66
67 {#if host.online && host.metrics}
68 <h2>Host metrics (measured)</h2>
69 <table class="kv">
70 <tbody>
71 <tr><th>Uptime</th><td>{fmtUptime(host.metrics.uptime_s)}</td></tr>
72 <tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr>
73 <tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr>
74 <tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr>
75 </tbody>
76 </table>
77 <p class="note">
78 Measured off the host — distinct from the allocated bars above, which sum committed VM specs (quotas).
79 </p>
80 {/if}
81
55 {#if host.status !== 'decommissioning'} 82 {#if host.status !== 'decommissioning'}
56 <button class="danger" onclick={decommission}>Decommission host</button> 83 <button class="danger" onclick={decommission}>Decommission host</button>
57 {:else} 84 {:else}