d3f4ed21
feat(server,agent): report host OS facts and live metrics per fleet host
a73x 2026-07-25 18:02
Commit message
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} |