72a48183
feat: UEFI guest boot, deploy boot-gate, and control-plane hardening
a73x 2026-07-25 11:22
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -138,16 +138,14 @@ shape-check: | |||
| 138 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what | 138 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what |
| 139 | # catches production code kept alive only by its own tests; the fix is to remove | 139 | # catches production code kept alive only by its own tests; the fix is to remove |
| 140 | # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags | 140 | # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags |
| 141 | # compile the tag-gated code so it is analysed too. Three sanctioned exceptions, | 141 | # compile the tag-gated code so it is analysed too. Two sanctioned exceptions, |
| 142 | # all production code that only a CROSS-package test can reach (so none can be | 142 | # both production code that only a CROSS-package test can reach (so neither can be |
| 143 | # a _test.go): internal/integration (the e2e/harness tree), reconcile.Engine.Stop | 143 | # a _test.go): internal/integration (the e2e/harness tree) and reconcile.Engine.Stop |
| 144 | # (terminal teardown that must not run in production — it would report every VM as | 144 | # (terminal teardown that must not run in production — it would report every VM as |
| 145 | # vanished — used only by an integration test's cleanup), and | 145 | # vanished — used only by an integration test's cleanup). |
| 146 | # store.Store.AllocatedByHost (the single-tx Snapshot path now serves GET /hosts; | ||
| 147 | # the standalone accessor is exercised only by the store's own allocation tests). | ||
| 148 | deadcode: | 146 | deadcode: |
| 149 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ | 147 | @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \ |
| 150 | | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.AllocatedByHost$$' || true; }); \ | 148 | | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$' || true; }); \ |
| 151 | if [ -n "$$out" ]; then \ | 149 | if [ -n "$$out" ]; then \ |
| 152 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ | 150 | echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \ |
| 153 | echo "$$out"; exit 1; \ | 151 | echo "$$out"; exit 1; \ |
cmd/eitri-agent/main.go
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | "os" | 11 | "os" |
| 12 | "os/exec" | 12 | "os/exec" |
| 13 | "os/signal" | 13 | "os/signal" |
| 14 | "path/filepath" | ||
| 15 | "runtime" | 14 | "runtime" |
| 16 | "syscall" | 15 | "syscall" |
| 17 | "time" | 16 | "time" |
| @@ -42,7 +41,7 @@ type agentConfig struct { | |||
| 42 | func main() { | 41 | func main() { |
| 43 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") | 42 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") |
| 44 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") | 43 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") |
| 45 | firmware := flag.String("firmware", "/usr/share/eitri/hypervisor-fw", "path to hypervisor-fw") | 44 | firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") |
| 46 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") | 45 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") |
| 47 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") | 46 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") |
| 48 | stepTimeout := flag.Duration("step-timeout", 15*time.Minute, "watchdog bound on one WHOLE reconcile step — all VMs, summed (0 disables); keep above the 10m image-download timeout") | 47 | stepTimeout := flag.Duration("step-timeout", 15*time.Minute, "watchdog bound on one WHOLE reconcile step — all VMs, summed (0 disables); keep above the 10m image-download timeout") |
| @@ -169,9 +168,7 @@ func runAgent(st *state.Store, cfg agentConfig) { | |||
| 169 | // and reattached here for VMs that survived an agent restart (CH runs in | 168 | // and reattached here for VMs that survived an agent restart (CH runs in |
| 170 | // its own process group; the pump reconnects to the still-listening | 169 | // its own process group; the pump reconnects to the still-listening |
| 171 | // serial socket). | 170 | // serial socket). |
| 172 | pumps := serialpump.NewManager(st.SerialSocketPath, func(vmID string) string { | 171 | pumps := serialpump.NewManager(st.SerialSocketPath, st.SerialLogPath) |
| 173 | return filepath.Join(st.VMDir(vmID), "serial.log") | ||
| 174 | }) | ||
| 175 | prov.Pumps = pumps | 172 | prov.Pumps = pumps |
| 176 | if recs, err := st.LoadVMs(); err == nil { | 173 | if recs, err := st.LoadVMs(); err == nil { |
| 177 | for _, rec := range recs { | 174 | for _, rec := range recs { |
| @@ -211,10 +208,6 @@ func runAgent(st *state.Store, cfg agentConfig) { | |||
| 211 | MaxDiskGB: cfg.MaxDiskGB, | 208 | MaxDiskGB: cfg.MaxDiskGB, |
| 212 | } | 209 | } |
| 213 | 210 | ||
| 214 | // Compile-time interface satisfaction checks. | ||
| 215 | var _ reconcile.Provisioner = prov | ||
| 216 | var _ reconcile.NetEnv = net | ||
| 217 | |||
| 218 | client := &syncclient.Client{ | 211 | client := &syncclient.Client{ |
| 219 | Engine: engine, | 212 | Engine: engine, |
| 220 | St: st, | 213 | St: st, |
| @@ -228,4 +221,12 @@ func runAgent(st *state.Store, cfg agentConfig) { | |||
| 228 | 221 | ||
| 229 | slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) | 222 | slog.Info("agent started", "host_id", id.HostID, "bridge_cidr", id.BridgeCIDR) |
| 230 | client.Run(ctx) | 223 | client.Run(ctx) |
| 224 | |||
| 225 | // client.Run blocks until ctx is cancelled (SIGINT/SIGTERM) and only then | ||
| 226 | // returns, so the reconcile/sync loop is done driving VMs and no further | ||
| 227 | // Ensure/Stop calls are expected to reach pumps (an unjoined session | ||
| 228 | // worker could in principle still be mid-Engine.Step, but it has nothing | ||
| 229 | // left to drive once client.Run has returned). Tear down every serial | ||
| 230 | // console pump here, at the very end of agent shutdown. | ||
| 231 | pumps.StopAll() | ||
| 231 | } | 232 | } |
cmd/eitri-server/main.go
| Old | New | ||
|---|---|---|---|
| @@ -5,75 +5,60 @@ package main | |||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | "encoding/json" | 7 | "encoding/json" |
| 8 | "errors" | ||
| 8 | "flag" | 9 | "flag" |
| 9 | "log/slog" | 10 | "log/slog" |
| 10 | "net" | ||
| 11 | "net/http" | 11 | "net/http" |
| 12 | "os" | 12 | "os" |
| 13 | "os/signal" | ||
| 13 | "regexp" | 14 | "regexp" |
| 15 | "syscall" | ||
| 14 | "time" | 16 | "time" |
| 15 | 17 | ||
| 16 | "github.com/a73x/eitri/internal/joinblob" | 18 | "github.com/a73x/eitri/internal/joinblob" |
| 17 | "github.com/a73x/eitri/internal/server/api" | 19 | "github.com/a73x/eitri/internal/server/api" |
| 20 | serverconfig "github.com/a73x/eitri/internal/server/config" | ||
| 18 | "github.com/a73x/eitri/internal/server/health" | 21 | "github.com/a73x/eitri/internal/server/health" |
| 19 | "github.com/a73x/eitri/internal/server/hub" | 22 | "github.com/a73x/eitri/internal/server/hub" |
| 20 | "github.com/a73x/eitri/internal/server/registry" | 23 | "github.com/a73x/eitri/internal/server/registry" |
| 21 | "github.com/a73x/eitri/internal/server/sshca" | ||
| 22 | "github.com/a73x/eitri/internal/server/sshgate" | ||
| 23 | "github.com/a73x/eitri/internal/server/store" | 24 | "github.com/a73x/eitri/internal/server/store" |
| 24 | "github.com/a73x/eitri/internal/server/syncsvc" | 25 | "github.com/a73x/eitri/internal/server/syncsvc" |
| 25 | "github.com/a73x/eitri/internal/server/web" | 26 | "github.com/a73x/eitri/internal/server/web" |
| 26 | "github.com/a73x/eitri/internal/transport" | 27 | "github.com/a73x/eitri/internal/transport" |
| 27 | "github.com/quic-go/quic-go" | 28 | "github.com/quic-go/quic-go" |
| 28 | "golang.org/x/crypto/ssh" | ||
| 29 | ) | 29 | ) |
| 30 | 30 | ||
| 31 | // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept | 31 | // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept local |
| 32 | // local (not internal/agent/imagecache.ValidSHA256): R1 forbids the | 32 | // to the control plane rather than shared with the agent's imagecache: R1 |
| 33 | // control plane importing the data plane, even transitively. | 33 | // forbids the control plane importing the data plane, even transitively. |
| 34 | var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`) | 34 | var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`) |
| 35 | 35 | ||
| 36 | type config struct { | 36 | // parseDurationCfg parses raw (a config duration string) for the knob named |
| 37 | HTTPListen string `json:"http_listen"` | 37 | // name (the JSON field label echoed in error logs). Empty raw keeps def. A |
| 38 | QUICListen string `json:"quic_listen"` | 38 | // parse failure is fatal. valid, when non-nil, is the knob's range rule; a |
| 39 | DBPath string `json:"db_path"` | 39 | // value failing it is fatal, with rule (e.g. ">= 0", "> 0") spelling the rule |
| 40 | AdminToken string `json:"admin_token"` | 40 | // in the log line. A nil valid skips the range check. |
| 41 | HostSecret string `json:"host_secret"` | 41 | func parseDurationCfg(name, raw string, def time.Duration, valid func(time.Duration) bool, rule string) time.Duration { |
| 42 | CIDRPool string `json:"cidr_pool"` | 42 | if raw == "" { |
| 43 | DefaultImageURL string `json:"default_image_url"` | 43 | return def |
| 44 | DefaultImageSHA string `json:"default_image_sha256"` | 44 | } |
| 45 | AdvertiseHTTP string `json:"advertise_http"` | 45 | d, err := time.ParseDuration(raw) |
| 46 | AdvertiseQUIC string `json:"advertise_quic"` | 46 | if err != nil { |
| 47 | // CredentialMaxAge optionally bounds host credential age (Go duration, | 47 | slog.Error(name+" invalid", "err", err) |
| 48 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via | 48 | os.Exit(1) |
| 49 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; | 49 | } |
| 50 | // max-age forces periodic re-enrollment and is opt-in defense-in-depth. | 50 | if valid != nil && !valid(d) { |
| 51 | CredentialMaxAge string `json:"credential_max_age"` | 51 | slog.Error(name+" must be "+rule, "value", raw) |
| 52 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = | 52 | os.Exit(1) |
| 53 | // 90 days; "0" disables pruning). Pruned at startup and daily. | 53 | } |
| 54 | AuditRetention string `json:"audit_retention"` | 54 | return d |
| 55 | // SSHCAKey is the path to the persistent SSH user CA private key | ||
| 56 | // (auto-created 0600 if absent, handled like AdminToken — never logged). | ||
| 57 | // The CA signs the short-lived certs the jump gate accepts. | ||
| 58 | SSHCAKey string `json:"ssh_ca_key"` | ||
| 59 | // SSHHostKey is the path to the gate's persistent SSH host key | ||
| 60 | // (auto-created 0600 if absent, never regenerated on restart so users | ||
| 61 | // don't see host-key-changed warnings). | ||
| 62 | SSHHostKey string `json:"ssh_host_key"` | ||
| 63 | // SSHListen is the jump-gate listen address. Empty ⇒ gate is OFF (no | ||
| 64 | // key material is loaded and no listener is started). | ||
| 65 | SSHListen string `json:"ssh_listen"` | ||
| 66 | // SSHGateDomain is the hostname clients dial the gate as (the principal put | ||
| 67 | // on the gate's signed HOST certificate). Empty ⇒ derived from SSHListen's | ||
| 68 | // host part; if that is also empty (e.g. ":2222") it falls back to | ||
| 69 | // "localhost". It must match the host in EITRI_GATE so `@cert-authority` | ||
| 70 | // verification accepts the presented host cert. | ||
| 71 | SSHGateDomain string `json:"ssh_gate_domain"` | ||
| 72 | // SSHCertTTL bounds minted user-cert validity (Go duration; default 10m). | ||
| 73 | // Set server-side; client-requested validity is never honored. | ||
| 74 | SSHCertTTL string `json:"ssh_cert_ttl"` | ||
| 75 | } | 55 | } |
| 76 | 56 | ||
| 57 | // config is the on-disk JSON schema, shared with the integration harness | ||
| 58 | // (which renders this file for the server subprocess it launches) via | ||
| 59 | // internal/server/config so the compiler keeps the two in agreement. | ||
| 60 | type config = serverconfig.Config | ||
| 61 | |||
| 77 | func main() { | 62 | func main() { |
| 78 | cfgPath := flag.String("config", "/etc/eitri/server.json", "config file") | 63 | cfgPath := flag.String("config", "/etc/eitri/server.json", "config file") |
| 79 | flag.Parse() | 64 | flag.Parse() |
| @@ -97,8 +82,8 @@ func main() { | |||
| 97 | } | 82 | } |
| 98 | // [carry-forward] Fail fast on a malformed default image digest rather | 83 | // [carry-forward] Fail fast on a malformed default image digest rather |
| 99 | // than letting every mint silently propagate a bad hash. A local regex | 84 | // than letting every mint silently propagate a bad hash. A local regex |
| 100 | // (not internal/agent/imagecache.ValidSHA256) because R1 forbids the | 85 | // (not the agent's imagecache) because R1 forbids the control plane |
| 101 | // control plane importing the data plane, even transitively. | 86 | // importing the data plane, even transitively. |
| 102 | if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) { | 87 | if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) { |
| 103 | slog.Error("default_image_sha256 malformed (want 64 lowercase hex chars)", "value", cfg.DefaultImageSHA) | 88 | slog.Error("default_image_sha256 malformed (want 64 lowercase hex chars)", "value", cfg.DefaultImageSHA) |
| 104 | os.Exit(1) | 89 | os.Exit(1) |
| @@ -145,20 +130,10 @@ func main() { | |||
| 145 | warnIfRenewalDue() | 130 | warnIfRenewalDue() |
| 146 | 131 | ||
| 147 | // Audit retention: bound the append-only log (default 90 days, 0 disables). | 132 | // Audit retention: bound the append-only log (default 90 days, 0 disables). |
| 148 | auditRetention := 90 * 24 * time.Hour | 133 | // A negative value is almost certainly a typo — refuse rather than silently |
| 149 | if cfg.AuditRetention != "" { | 134 | // keeping the audit log forever ("0" is the explicit disable spelling). |
| 150 | auditRetention, err = time.ParseDuration(cfg.AuditRetention) | 135 | auditRetention := parseDurationCfg("audit_retention", cfg.AuditRetention, 90*24*time.Hour, |
| 151 | if err != nil { | 136 | func(d time.Duration) bool { return d >= 0 }, ">= 0") |
| 152 | slog.Error("audit_retention invalid", "err", err) | ||
| 153 | os.Exit(1) | ||
| 154 | } | ||
| 155 | if auditRetention < 0 { | ||
| 156 | // Almost certainly a typo — refuse rather than silently keeping | ||
| 157 | // the audit log forever ("0" is the explicit disable spelling). | ||
| 158 | slog.Error("audit_retention must be >= 0", "value", cfg.AuditRetention) | ||
| 159 | os.Exit(1) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | pruneAudit := func() { | 137 | pruneAudit := func() { |
| 163 | if auditRetention <= 0 { | 138 | if auditRetention <= 0 { |
| 164 | return | 139 | return |
| @@ -186,40 +161,10 @@ func main() { | |||
| 186 | os.Exit(1) | 161 | os.Exit(1) |
| 187 | } | 162 | } |
| 188 | 163 | ||
| 189 | // SSH jump gate (§B): OFF unless ssh_listen is set. When enabled, load or | 164 | // SSH jump gate (§B): nil when ssh_listen is unset (gate OFF); see |
| 190 | // create the persistent user CA + gate host key (0600, never logged) and | 165 | // setupSSHGate. The listener itself is started below, once syncsvc.Service |
| 191 | // resolve the cert TTL now so a misconfig fails fast at startup. | 166 | // (the tunnel dialer) exists. |
| 192 | var sshGate *sshca.CA | 167 | sshGate := setupSSHGate(cfg) |
| 193 | sshCertTTL := 10 * time.Minute | ||
| 194 | if cfg.SSHListen != "" { | ||
| 195 | if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { | ||
| 196 | slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set") | ||
| 197 | os.Exit(1) | ||
| 198 | } | ||
| 199 | if cfg.SSHCertTTL != "" { | ||
| 200 | sshCertTTL, err = time.ParseDuration(cfg.SSHCertTTL) | ||
| 201 | if err != nil { | ||
| 202 | slog.Error("ssh_cert_ttl invalid", "err", err) | ||
| 203 | os.Exit(1) | ||
| 204 | } | ||
| 205 | if sshCertTTL <= 0 { | ||
| 206 | slog.Error("ssh_cert_ttl must be > 0", "value", cfg.SSHCertTTL) | ||
| 207 | os.Exit(1) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | sshGate, err = sshca.New(cfg.SSHCAKey, cfg.SSHHostKey) | ||
| 211 | if err != nil { | ||
| 212 | slog.Error("ssh ca", "err", err) | ||
| 213 | os.Exit(1) | ||
| 214 | } | ||
| 215 | // Log the CA identity operators pin in known_hosts / inject into VMs. | ||
| 216 | // Only the *public* key is ever logged (private material never is). | ||
| 217 | slog.Info("ssh jump gate configured", "listen", cfg.SSHListen, | ||
| 218 | "cert_ttl", sshCertTTL, | ||
| 219 | "user_ca", string(sshGate.UserCAAuthorizedKey())) | ||
| 220 | // The gate listener itself is started below, once syncsvc.Service (the | ||
| 221 | // tunnel dialer) exists. | ||
| 222 | } | ||
| 223 | 168 | ||
| 224 | reg := registry.New(time.Now) | 169 | reg := registry.New(time.Now) |
| 225 | h := hub.New() | 170 | h := hub.New() |
| @@ -237,112 +182,26 @@ func main() { | |||
| 237 | os.Exit(1) | 182 | os.Exit(1) |
| 238 | } | 183 | } |
| 239 | lis, err := quic.ListenAddr(cfg.QUICListen, tlsConf, | 184 | lis, err := quic.ListenAddr(cfg.QUICListen, tlsConf, |
| 240 | &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second}) | 185 | // Shared with the agent dialer via transport so the two ends can't drift. |
| 186 | transport.SyncQUICConfig()) | ||
| 241 | if err != nil { | 187 | if err != nil { |
| 242 | slog.Error("quic listen", "err", err) | 188 | slog.Error("quic listen", "err", err) |
| 243 | os.Exit(1) | 189 | os.Exit(1) |
| 244 | } | 190 | } |
| 245 | var maxCredAge time.Duration | 191 | maxCredAge := parseDurationCfg("credential_max_age", cfg.CredentialMaxAge, 0, nil, "") |
| 246 | if cfg.CredentialMaxAge != "" { | 192 | // SSH cert minters: no-op when the gate is off, so the endpoint 404s. |
| 247 | maxCredAge, err = time.ParseDuration(cfg.CredentialMaxAge) | 193 | sshGate.wireAPI(a) |
| 248 | if err != nil { | ||
| 249 | slog.Error("credential_max_age invalid", "err", err) | ||
| 250 | os.Exit(1) | ||
| 251 | } | ||
| 252 | } | ||
| 253 | // SSH cert minter: when the jump gate is enabled, the API mints short-lived | ||
| 254 | // user certs signed by the persistent user CA (POST /api/v1/ssh-certs). | ||
| 255 | // Left nil when the gate is off, so the endpoint 404s. | ||
| 256 | if sshGate != nil { | ||
| 257 | a.SetCertMinter(api.NewMinter(sshGate.UserCA(), sshCertTTL)) | ||
| 258 | // Per-VM host certs: sign a persistent host key + cert at each VM create, | ||
| 259 | // so VMs present verifiable host keys (clients accept via @cert-authority). | ||
| 260 | a.SetHostCertMinter(api.NewHostMinter(sshGate.UserCA())) | ||
| 261 | // Publish the CA public key so clients can pin `@cert-authority` for host | ||
| 262 | // verification of both the gate and every VM. | ||
| 263 | a.SetSSHCAAuthorizedKey(string(sshGate.UserCAAuthorizedKey())) | ||
| 264 | } | ||
| 265 | 194 | ||
| 266 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) | 195 | svc := syncsvc.New(st, reg, h, []byte(cfg.HostSecret), maxCredAge) |
| 267 | // When the jump gate is enabled, advertise the user-CA public key in every | 196 | // Advertise the user-CA public key in desired-VM snapshots (no-op when off). |
| 268 | // desired-VM snapshot so guests inject it as an sshd TrustedUserCAKeys | 197 | sshGate.wireSync(svc) |
| 269 | // drop-in and trust CA-signed certs. Off (nil gate) => no injection. | ||
| 270 | if sshGate != nil { | ||
| 271 | svc.SetSSHUserCAKey(string(sshGate.UserCAAuthorizedKey())) | ||
| 272 | } | ||
| 273 | // Console broker: the API bridges browser WebSockets to agent console | 198 | // Console broker: the API bridges browser WebSockets to agent console |
| 274 | // streams over the live sync connections the service tracks. | 199 | // streams over the live sync connections the service tracks. |
| 275 | a.SetConsoleDialer(svc) | 200 | a.SetConsoleDialer(svc) |
| 276 | 201 | ||
| 277 | // SSH jump gate listener: when enabled, front `ssh -J gate ubuntu@<vm>` with | 202 | // SSH jump gate listener (no-op when off); fatal on a failed bind, like |
| 278 | // the hardened bastion. It resolves VM names against the store, tunnels port | 203 | // QUIC/HTTP below — a dead gate must not run silently. |
| 279 | // 22 through the sync connection (svc.OpenTCP), and trusts only certs signed | 204 | sshGate.startListener(st, svc) |
| 280 | // by the user CA. A failed bind is fatal (like QUIC/HTTP below): a dead gate | ||
| 281 | // must not run silently. | ||
| 282 | if sshGate != nil { | ||
| 283 | // The gate host cert's principal is the name clients dial. Prefer the | ||
| 284 | // configured domain; else the host part of ssh_listen; else "localhost". | ||
| 285 | gateDomain := cfg.SSHGateDomain | ||
| 286 | if gateDomain == "" { | ||
| 287 | if h, _, err := net.SplitHostPort(cfg.SSHListen); err == nil { | ||
| 288 | gateDomain = h | ||
| 289 | } | ||
| 290 | } | ||
| 291 | if gateDomain == "" { | ||
| 292 | gateDomain = "localhost" | ||
| 293 | } | ||
| 294 | slog.Info("ssh gate host cert", "principal", gateDomain) | ||
| 295 | // resolve maps a VM name to its host/VM IDs; unknown or tombstoned ⇒ ok=false. | ||
| 296 | resolve := func(name string) (hostID, vmID string, ok bool) { | ||
| 297 | vm, err := st.VMByName(name) | ||
| 298 | if err != nil { | ||
| 299 | return "", "", false | ||
| 300 | } | ||
| 301 | return vm.HostID, vm.ID, true | ||
| 302 | } | ||
| 303 | // v1 single-admin: any CA-signed cert reaches any VM; per-user ownership is Task/Slice per §5/§9. | ||
| 304 | authorize := func(principal, vmID string) bool { return true } | ||
| 305 | // Sign a long-lived HOST cert for the gate's own host key and present THAT | ||
| 306 | // (via a cert signer) instead of the bare key, so a client verifying with | ||
| 307 | // `@cert-authority` accepts the gate on first connect — no TOFU window. | ||
| 308 | gateCert, err := sshca.SignHostCert(sshGate.UserCA(), sshGate.HostKey().PublicKey(), | ||
| 309 | []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL) | ||
| 310 | if err != nil { | ||
| 311 | slog.Error("sign gate host cert", "err", err) | ||
| 312 | os.Exit(1) | ||
| 313 | } | ||
| 314 | gateHostSigner, err := ssh.NewCertSigner(gateCert, sshGate.HostKey()) | ||
| 315 | if err != nil { | ||
| 316 | slog.Error("gate host cert signer", "err", err) | ||
| 317 | os.Exit(1) | ||
| 318 | } | ||
| 319 | // isRevoked gates every cert auth against the revocation list. Fail-CLOSED | ||
| 320 | // for the single connection on a DB error: a store hiccup rejects THAT | ||
| 321 | // login (returns revoked=true) rather than fail-open (which would let a | ||
| 322 | // possibly-revoked cert through) or fail-the-whole-gate (which a global | ||
| 323 | // close would amount to, DoSing every login on any transient error). | ||
| 324 | isRevoked := func(serial uint64) bool { | ||
| 325 | revoked, err := st.IsSSHCertRevoked(serial) | ||
| 326 | if err != nil { | ||
| 327 | slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) | ||
| 328 | return true | ||
| 329 | } | ||
| 330 | return revoked | ||
| 331 | } | ||
| 332 | gate := sshgate.New(gateHostSigner, sshGate.UserCA().PublicKey(), resolve, authorize, svc.OpenTCP, isRevoked) | ||
| 333 | ln, err := net.Listen("tcp", cfg.SSHListen) | ||
| 334 | if err != nil { | ||
| 335 | slog.Error("ssh gate listen", "err", err) | ||
| 336 | os.Exit(1) | ||
| 337 | } | ||
| 338 | go func() { | ||
| 339 | slog.Info("ssh jump gate listening", "addr", cfg.SSHListen) | ||
| 340 | if err := gate.Serve(ln); err != nil { | ||
| 341 | slog.Error("ssh gate serve", "err", err) | ||
| 342 | os.Exit(1) | ||
| 343 | } | ||
| 344 | }() | ||
| 345 | } | ||
| 346 | 205 | ||
| 347 | go func() { | 206 | go func() { |
| 348 | slog.Info("quic listening", "addr", cfg.QUICListen) | 207 | slog.Info("quic listening", "addr", cfg.QUICListen) |
| @@ -370,9 +229,28 @@ func main() { | |||
| 370 | )) | 229 | )) |
| 371 | root.Handle("/", web.Handler()) | 230 | root.Handle("/", web.Handler()) |
| 372 | 231 | ||
| 373 | slog.Info("http listening", "addr", cfg.HTTPListen) | 232 | // Graceful shutdown: SIGINT/SIGTERM stops accepting, drains in-flight |
| 374 | if err := http.ListenAndServe(cfg.HTTPListen, root); err != nil { | 233 | // requests, then closes the API — stopping the SSE snapshot-hub goroutine and |
| 375 | slog.Error("http serve", "err", err) | 234 | // releasing its notifier subscription. The QUIC listener and background worker |
| 376 | os.Exit(1) | 235 | // die with the process. |
| 236 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | ||
| 237 | defer stop() | ||
| 238 | |||
| 239 | srv := &http.Server{Addr: cfg.HTTPListen, Handler: root} | ||
| 240 | go func() { | ||
| 241 | slog.Info("http listening", "addr", cfg.HTTPListen) | ||
| 242 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 243 | slog.Error("http serve", "err", err) | ||
| 244 | os.Exit(1) | ||
| 245 | } | ||
| 246 | }() | ||
| 247 | |||
| 248 | <-ctx.Done() | ||
| 249 | slog.Info("shutting down") | ||
| 250 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| 251 | defer cancel() | ||
| 252 | if err := srv.Shutdown(shutdownCtx); err != nil { | ||
| 253 | slog.Warn("http graceful shutdown", "err", err) | ||
| 377 | } | 254 | } |
| 255 | a.Close() | ||
| 378 | } | 256 | } |
cmd/eitri-server/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,154 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "log/slog" | ||
| 5 | "net" | ||
| 6 | "os" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/server/api" | ||
| 10 | "github.com/a73x/eitri/internal/server/sshca" | ||
| 11 | "github.com/a73x/eitri/internal/server/sshgate" | ||
| 12 | "github.com/a73x/eitri/internal/server/store" | ||
| 13 | "github.com/a73x/eitri/internal/server/syncsvc" | ||
| 14 | "golang.org/x/crypto/ssh" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // sshGateSetup carries the jump-gate state from config-time setup to the later | ||
| 18 | // wiring points in startup (API cert minters, sync snapshot, gate listener). | ||
| 19 | // A nil *sshGateSetup means the gate is OFF: every method is a no-op on a nil | ||
| 20 | // receiver, so main holds one value instead of repeating `!= nil` guards. | ||
| 21 | type sshGateSetup struct { | ||
| 22 | ca *sshca.CA | ||
| 23 | certTTL time.Duration | ||
| 24 | listen string // cfg.SSHListen | ||
| 25 | domain string // cfg.SSHGateDomain | ||
| 26 | } | ||
| 27 | |||
| 28 | // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set | ||
| 29 | // (returns nil). When enabled, load or create the persistent user CA + gate | ||
| 30 | // host key (0600, never logged) and resolve the cert TTL now so a misconfig | ||
| 31 | // fails fast at startup. | ||
| 32 | func setupSSHGate(cfg config) *sshGateSetup { | ||
| 33 | if cfg.SSHListen == "" { | ||
| 34 | return nil | ||
| 35 | } | ||
| 36 | if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { | ||
| 37 | slog.Error("ssh_ca_key and ssh_host_key are required when ssh_listen is set") | ||
| 38 | os.Exit(1) | ||
| 39 | } | ||
| 40 | sshCertTTL := parseDurationCfg("ssh_cert_ttl", cfg.SSHCertTTL, 10*time.Minute, | ||
| 41 | func(d time.Duration) bool { return d > 0 }, "> 0") | ||
| 42 | sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey) | ||
| 43 | if err != nil { | ||
| 44 | slog.Error("ssh ca", "err", err) | ||
| 45 | os.Exit(1) | ||
| 46 | } | ||
| 47 | // Log the CA identity operators pin in known_hosts / inject into VMs. | ||
| 48 | // Only the *public* key is ever logged (private material never is). | ||
| 49 | slog.Info("ssh jump gate configured", "listen", cfg.SSHListen, | ||
| 50 | "cert_ttl", sshCertTTL, | ||
| 51 | "user_ca", string(sshGate.UserCAAuthorizedKey())) | ||
| 52 | // The gate listener itself is started later (startListener), once | ||
| 53 | // syncsvc.Service (the tunnel dialer) exists. | ||
| 54 | return &sshGateSetup{ca: sshGate, certTTL: sshCertTTL, listen: cfg.SSHListen, domain: cfg.SSHGateDomain} | ||
| 55 | } | ||
| 56 | |||
| 57 | // wireAPI installs the SSH cert minter: when the jump gate is enabled, the API | ||
| 58 | // mints short-lived user certs signed by the persistent user CA | ||
| 59 | // (POST /api/v1/ssh-certs). Left nil when the gate is off, so the endpoint 404s. | ||
| 60 | func (g *sshGateSetup) wireAPI(a *api.API) { | ||
| 61 | if g == nil { | ||
| 62 | return | ||
| 63 | } | ||
| 64 | a.SetCertMinter(api.NewMinter(g.ca.UserCA(), g.certTTL)) | ||
| 65 | // Per-VM host certs: sign a persistent host key + cert at each VM create, | ||
| 66 | // so VMs present verifiable host keys (clients accept via @cert-authority). | ||
| 67 | a.SetHostCertMinter(api.NewHostMinter(g.ca.UserCA())) | ||
| 68 | // Publish the CA public key so clients can pin `@cert-authority` for host | ||
| 69 | // verification of both the gate and every VM. | ||
| 70 | a.SetSSHCAAuthorizedKey(string(g.ca.UserCAAuthorizedKey())) | ||
| 71 | } | ||
| 72 | |||
| 73 | // wireSync: when the jump gate is enabled, advertise the user-CA public key in | ||
| 74 | // every desired-VM snapshot so guests inject it as an sshd TrustedUserCAKeys | ||
| 75 | // drop-in and trust CA-signed certs. Off (nil gate) => no injection. | ||
| 76 | func (g *sshGateSetup) wireSync(svc *syncsvc.Service) { | ||
| 77 | if g == nil { | ||
| 78 | return | ||
| 79 | } | ||
| 80 | svc.SetSSHUserCAKey(string(g.ca.UserCAAuthorizedKey())) | ||
| 81 | } | ||
| 82 | |||
| 83 | // startListener starts the SSH jump gate listener: when enabled, front | ||
| 84 | // `ssh -J gate ubuntu@<vm>` with the hardened bastion. It resolves VM names | ||
| 85 | // against the store, tunnels port 22 through the sync connection (svc.OpenTCP), | ||
| 86 | // and trusts only certs signed by the user CA. A failed bind is fatal (like | ||
| 87 | // QUIC/HTTP): a dead gate must not run silently. | ||
| 88 | func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) { | ||
| 89 | if g == nil { | ||
| 90 | return | ||
| 91 | } | ||
| 92 | // The gate host cert's principal is the name clients dial. Prefer the | ||
| 93 | // configured domain; else the host part of ssh_listen; else "localhost". | ||
| 94 | gateDomain := g.domain | ||
| 95 | if gateDomain == "" { | ||
| 96 | if h, _, err := net.SplitHostPort(g.listen); err == nil { | ||
| 97 | gateDomain = h | ||
| 98 | } | ||
| 99 | } | ||
| 100 | if gateDomain == "" { | ||
| 101 | gateDomain = "localhost" | ||
| 102 | } | ||
| 103 | slog.Info("ssh gate host cert", "principal", gateDomain) | ||
| 104 | // resolve maps a VM name to its host/VM IDs; unknown or tombstoned ⇒ ok=false. | ||
| 105 | resolve := func(name string) (hostID, vmID string, ok bool) { | ||
| 106 | vm, err := st.VMByName(name) | ||
| 107 | if err != nil { | ||
| 108 | return "", "", false | ||
| 109 | } | ||
| 110 | return vm.HostID, vm.ID, true | ||
| 111 | } | ||
| 112 | // v1 single-admin: any CA-signed cert reaches any VM; per-user ownership is Task/Slice per §5/§9. | ||
| 113 | authorize := func(principal, vmID string) bool { return true } | ||
| 114 | // Sign a long-lived HOST cert for the gate's own host key and present THAT | ||
| 115 | // (via a cert signer) instead of the bare key, so a client verifying with | ||
| 116 | // `@cert-authority` accepts the gate on first connect — no TOFU window. | ||
| 117 | gateCert, err := sshca.SignHostCert(g.ca.UserCA(), g.ca.HostKey().PublicKey(), | ||
| 118 | []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL) | ||
| 119 | if err != nil { | ||
| 120 | slog.Error("sign gate host cert", "err", err) | ||
| 121 | os.Exit(1) | ||
| 122 | } | ||
| 123 | gateHostSigner, err := ssh.NewCertSigner(gateCert, g.ca.HostKey()) | ||
| 124 | if err != nil { | ||
| 125 | slog.Error("gate host cert signer", "err", err) | ||
| 126 | os.Exit(1) | ||
| 127 | } | ||
| 128 | // isRevoked gates every cert auth against the revocation list. Fail-CLOSED | ||
| 129 | // for the single connection on a DB error: a store hiccup rejects THAT | ||
| 130 | // login (returns revoked=true) rather than fail-open (which would let a | ||
| 131 | // possibly-revoked cert through) or fail-the-whole-gate (which a global | ||
| 132 | // close would amount to, DoSing every login on any transient error). | ||
| 133 | isRevoked := func(serial uint64) bool { | ||
| 134 | revoked, err := st.IsSSHCertRevoked(serial) | ||
| 135 | if err != nil { | ||
| 136 | slog.Error("ssh cert revocation lookup failed; rejecting connection", "err", err) | ||
| 137 | return true | ||
| 138 | } | ||
| 139 | return revoked | ||
| 140 | } | ||
| 141 | gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), resolve, authorize, svc.OpenTCP, isRevoked) | ||
| 142 | ln, err := net.Listen("tcp", g.listen) | ||
| 143 | if err != nil { | ||
| 144 | slog.Error("ssh gate listen", "err", err) | ||
| 145 | os.Exit(1) | ||
| 146 | } | ||
| 147 | go func() { | ||
| 148 | slog.Info("ssh jump gate listening", "addr", g.listen) | ||
| 149 | if err := gate.Serve(ln); err != nil { | ||
| 150 | slog.Error("ssh gate serve", "err", err) | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | }() | ||
| 154 | } | ||
docs/architecture.md
| Old | New | ||
|---|---|---|---|
| @@ -39,6 +39,7 @@ bridge IP (`assigned_ip`) via the agent. | |||
| 39 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | | 39 | | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | |
| 40 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | | 40 | | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. | |
| 41 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | | 41 | | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | |
| 42 | | **R7** | `internal/integration/*` is test infrastructure only — no package under `internal/server/*`, `internal/agent/*`, or `cmd/*` may import it, even transitively. The sanctioned exceptions are the test-tooling launcher binaries that are the infrastructure's entry points: `cmd/eitri-smoketest`, `cmd/eitri-devstack`, `cmd/eitri-sandbox`. | `internal/arch` `TestProductionPlanesDoNotImportIntegrationTestInfra` (transitive). | | ||
| 42 | 43 | ||
| 43 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see | 44 | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see |
| 44 | > edges changing elsewhere in the module. Always run them with `-count=1` | 45 | > edges changing elsewhere in the module. Always run them with `-count=1` |
| @@ -80,7 +81,7 @@ why the invariants hold: | |||
| 80 | |------|--------|---------------| | 81 | |------|--------|---------------| |
| 81 | | Compile all packages | `make build-go` | yes | | 82 | | Compile all packages | `make build-go` | yes | |
| 82 | | `go vet` | `make vet` | yes | | 83 | | `go vet` | `make vet` | yes | |
| 83 | | Architecture fitness tests (R1–R6) | `make arch` | yes | | 84 | | Architecture fitness tests (R1–R7) | `make arch` | yes | |
| 84 | | Block-tier lint (boundaries + correctness) | `make lint` | yes | | 85 | | Block-tier lint (boundaries + correctness) | `make lint` | yes | |
| 85 | | Race-detector tests | `make test` | yes | | 86 | | Race-detector tests | `make test` | yes | |
| 86 | | Per-package coverage ratchet | `make cover` | yes | | 87 | | Per-package coverage ratchet | `make cover` | yes | |
docs/ethos.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,47 @@ | |||
| 1 | # eitri design ethos | ||
| 2 | |||
| 3 | *Something to fall back on when a design debate stalls.* | ||
| 4 | |||
| 5 | ## North star | ||
| 6 | |||
| 7 | eitri is a **general-purpose cloud for quasi-cattle** — user-administered VMs with an on-demand lifecycle (throwaway → long-lived webserver). | ||
| 8 | |||
| 9 | > **The guest owns the guest; eitri owns the fleet.** | ||
| 10 | |||
| 11 | That line is the tie-breaker for almost everything. | ||
| 12 | |||
| 13 | ## Principles | ||
| 14 | |||
| 15 | Each is tagged with a real fork it settled, so it stays concrete rather than aspirational. | ||
| 16 | |||
| 17 | 1. **The guest/fleet boundary is the VM's edge.** Inside — kernel, packages, filesystem, in-VM config — is the user's; don't manage it. Outside — placement, lifecycle, access seam, quotas, identity — is eitri's; own it fully. | ||
| 18 | *→ guests own their kernel (UEFI + guest GRUB, not pinned by us); cloud-init injection stays minimal.* | ||
| 19 | |||
| 20 | 2. **When unsure, do what a real cloud does — and distrust anything the clouds abandoned.** EC2/GCE/Linode/DO already paid for these lessons. | ||
| 21 | *→ UEFI + guest GRUB, not provider-pinned external kernels (which AWS→PV-GRUB, DigitalOcean, and Linode all migrated away from).* | ||
| 22 | |||
| 23 | 3. **Composable, agnostic seams — BYO everything (CNI-style).** We care that an interface is satisfied, not what satisfies it. | ||
| 24 | *→ BYO image = just a disk; network-provider seam; no hardcoded fabric, image, or kernel.* | ||
| 25 | |||
| 26 | 4. **Design for the most demanding workload in the union, not the average.** The long-lived pet's needs dominate; serve it and the throwaway case is free. | ||
| 27 | *→ "kernel security updates must take effect" (the webserver) drove UEFI over direct kernel boot.* | ||
| 28 | |||
| 29 | 5. **Prefer the design that removes machinery.** Ballooning scope — extra wire fields, validation ladders, lifecycle plumbing — is a smell that you're on the wrong side of a boundary or fighting the ecosystem. | ||
| 30 | *→ a firmware swap beat direct kernel boot + a wire contract + a validation ladder + kernel-as-control-plane-input.* | ||
| 31 | |||
| 32 | 6. **Defer freely, foreclose never.** Ship the minimum; keep seams open so deferred features slot in without a rewrite. | ||
| 33 | *→ multi-tenancy deferred but design-forward; direct kernel boot kept as a back-pocket fast-ephemeral profile.* | ||
| 34 | |||
| 35 | 7. **Prove it on the stack; evidence over assertion.** Boot the VM, capture the serial. | ||
| 36 | *→ the "modern images panic under CH firmware (EFI/Secure-Boot/TPM, CH #7356)" comment was flat wrong — only caught by reproducing it.* | ||
| 37 | |||
| 38 | ## The "um and ahh" checklist | ||
| 39 | |||
| 40 | When a design debate stalls, walk this out loud. The first item that bites usually decides it. | ||
| 41 | |||
| 42 | 1. Guest's business or fleet's? (guest → hands off) | ||
| 43 | 2. What does EC2/GCE do here? (default to it; stop if it's something they abandoned) | ||
| 44 | 3. Does this remove machinery or add it? (prefer removal) | ||
| 45 | 4. Does it serve the long-lived pet? (design for that; throwaway comes free) | ||
| 46 | 5. Are we deferring (fine) or foreclosing (stop)? | ||
| 47 | 6. Have we booted it, or are we guessing? | ||
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -170,6 +170,13 @@ | |||
| 170 | "id": { | 170 | "id": { |
| 171 | "type": "string" | 171 | "type": "string" |
| 172 | }, | 172 | }, |
| 173 | "last_seen": { | ||
| 174 | "format": "date-time", | ||
| 175 | "type": [ | ||
| 176 | "string", | ||
| 177 | "null" | ||
| 178 | ] | ||
| 179 | }, | ||
| 173 | "name": { | 180 | "name": { |
| 174 | "type": "string" | 181 | "type": "string" |
| 175 | }, | 182 | }, |
| @@ -182,6 +189,18 @@ | |||
| 182 | "provisioner": { | 189 | "provisioner": { |
| 183 | "type": "string" | 190 | "type": "string" |
| 184 | }, | 191 | }, |
| 192 | "seconds_since_last_seen": { | ||
| 193 | "type": [ | ||
| 194 | "integer", | ||
| 195 | "null" | ||
| 196 | ] | ||
| 197 | }, | ||
| 198 | "sessions": { | ||
| 199 | "type": "integer" | ||
| 200 | }, | ||
| 201 | "stale": { | ||
| 202 | "type": "boolean" | ||
| 203 | }, | ||
| 185 | "status": { | 204 | "status": { |
| 186 | "type": "string" | 205 | "type": "string" |
| 187 | } | 206 | } |
| @@ -197,6 +216,8 @@ | |||
| 197 | "online", | 216 | "online", |
| 198 | "os", | 217 | "os", |
| 199 | "provisioner", | 218 | "provisioner", |
| 219 | "sessions", | ||
| 220 | "stale", | ||
| 200 | "status" | 221 | "status" |
| 201 | ], | 222 | ], |
| 202 | "type": "object" | 223 | "type": "object" |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -84,6 +84,7 @@ | |||
| 84 | "imports": [ | 84 | "imports": [ |
| 85 | "internal/joinblob", | 85 | "internal/joinblob", |
| 86 | "internal/server/api", | 86 | "internal/server/api", |
| 87 | "internal/server/config", | ||
| 87 | "internal/server/health", | 88 | "internal/server/health", |
| 88 | "internal/server/hub", | 89 | "internal/server/hub", |
| 89 | "internal/server/registry", | 90 | "internal/server/registry", |
| @@ -217,6 +218,12 @@ | |||
| 217 | "imports": [] | 218 | "imports": [] |
| 218 | }, | 219 | }, |
| 219 | { | 220 | { |
| 221 | "importPath": "internal/random", | ||
| 222 | "plane": "wire", | ||
| 223 | "synopsis": "Package random provides small cryptographically-random helpers shared across the control plane, CLIs, and the integration harness — a leaf package so a CLI or test binary can reuse them without importing heavier deps (e.g.", | ||
| 224 | "imports": [] | ||
| 225 | }, | ||
| 226 | { | ||
| 220 | "importPath": "internal/server/api", | 227 | "importPath": "internal/server/api", |
| 221 | "plane": "control", | 228 | "plane": "control", |
| 222 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 229 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| @@ -224,6 +231,7 @@ | |||
| 224 | "internal/cloudinit", | 231 | "internal/cloudinit", |
| 225 | "internal/joinblob", | 232 | "internal/joinblob", |
| 226 | "internal/names", | 233 | "internal/names", |
| 234 | "internal/random", | ||
| 227 | "internal/server/api/types", | 235 | "internal/server/api/types", |
| 228 | "internal/server/hosttoken", | 236 | "internal/server/hosttoken", |
| 229 | "internal/server/hub", | 237 | "internal/server/hub", |
| @@ -248,6 +256,12 @@ | |||
| 248 | "imports": [] | 256 | "imports": [] |
| 249 | }, | 257 | }, |
| 250 | { | 258 | { |
| 259 | "importPath": "internal/server/config", | ||
| 260 | "plane": "control", | ||
| 261 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema.", | ||
| 262 | "imports": [] | ||
| 263 | }, | ||
| 264 | { | ||
| 251 | "importPath": "internal/server/health", | 265 | "importPath": "internal/server/health", |
| 252 | "plane": "control", | 266 | "plane": "control", |
| 253 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", | 267 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", |
| @@ -288,6 +302,7 @@ | |||
| 288 | "plane": "control", | 302 | "plane": "control", |
| 289 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", | 303 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", |
| 290 | "imports": [ | 304 | "imports": [ |
| 305 | "internal/random", | ||
| 291 | "internal/transport" | 306 | "internal/transport" |
| 292 | ] | 307 | ] |
| 293 | }, | 308 | }, |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,7 @@ | |||
| 33 | "imports": [ | 33 | "imports": [ |
| 34 | "internal/joinblob", | 34 | "internal/joinblob", |
| 35 | "internal/server/api", | 35 | "internal/server/api", |
| 36 | "internal/server/config", | ||
| 36 | "internal/server/health", | 37 | "internal/server/health", |
| 37 | "internal/server/hub", | 38 | "internal/server/hub", |
| 38 | "internal/server/registry", | 39 | "internal/server/registry", |
| @@ -166,6 +167,12 @@ | |||
| 166 | "imports": [] | 167 | "imports": [] |
| 167 | }, | 168 | }, |
| 168 | { | 169 | { |
| 170 | "importPath": "internal/random", | ||
| 171 | "plane": "wire", | ||
| 172 | "synopsis": "Package random provides small cryptographically-random helpers shared across the control plane, CLIs, and the integration harness — a leaf package so a CLI or test binary can reuse them without importing heavier deps (e.g.", | ||
| 173 | "imports": [] | ||
| 174 | }, | ||
| 175 | { | ||
| 169 | "importPath": "internal/server/api", | 176 | "importPath": "internal/server/api", |
| 170 | "plane": "control", | 177 | "plane": "control", |
| 171 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 178 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| @@ -173,6 +180,7 @@ | |||
| 173 | "internal/cloudinit", | 180 | "internal/cloudinit", |
| 174 | "internal/joinblob", | 181 | "internal/joinblob", |
| 175 | "internal/names", | 182 | "internal/names", |
| 183 | "internal/random", | ||
| 176 | "internal/server/api/types", | 184 | "internal/server/api/types", |
| 177 | "internal/server/hosttoken", | 185 | "internal/server/hosttoken", |
| 178 | "internal/server/hub", | 186 | "internal/server/hub", |
| @@ -197,6 +205,12 @@ | |||
| 197 | "imports": [] | 205 | "imports": [] |
| 198 | }, | 206 | }, |
| 199 | { | 207 | { |
| 208 | "importPath": "internal/server/config", | ||
| 209 | "plane": "control", | ||
| 210 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema.", | ||
| 211 | "imports": [] | ||
| 212 | }, | ||
| 213 | { | ||
| 200 | "importPath": "internal/server/health", | 214 | "importPath": "internal/server/health", |
| 201 | "plane": "control", | 215 | "plane": "control", |
| 202 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", | 216 | "synopsis": "Package health serves the eitri-server liveness and readiness probes.", |
| @@ -237,6 +251,7 @@ | |||
| 237 | "plane": "control", | 251 | "plane": "control", |
| 238 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", | 252 | "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.", |
| 239 | "imports": [ | 253 | "imports": [ |
| 254 | "internal/random", | ||
| 240 | "internal/transport" | 255 | "internal/transport" |
| 241 | ] | 256 | ] |
| 242 | }, | 257 | }, |
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -45,7 +45,7 @@ type PumpHooks interface { | |||
| 45 | type Provisioner struct { | 45 | type Provisioner struct { |
| 46 | st *state.Store | 46 | st *state.Store |
| 47 | chBin string // path to cloud-hypervisor binary | 47 | chBin string // path to cloud-hypervisor binary |
| 48 | firmware string // path to hypervisor-fw (EFI firmware) | 48 | firmware string // path to CLOUDHV.fd (UEFI firmware) |
| 49 | run agentexec.Runner | 49 | run agentexec.Runner |
| 50 | 50 | ||
| 51 | // Pumps receives serial-pump lifecycle calls at Boot/Kill. nil = no-op. | 51 | // Pumps receives serial-pump lifecycle calls at Boot/Kill. nil = no-op. |
| @@ -125,12 +125,26 @@ func (p *Provisioner) PrepareDisk(ctx context.Context, spec state.VMSpec, basePa | |||
| 125 | if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { | 125 | if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { |
| 126 | return fmt.Errorf("mkdir %s: %w", p.st.VMDir(spec.VMID), err) | 126 | return fmt.Errorf("mkdir %s: %w", p.st.VMDir(spec.VMID), err) |
| 127 | } | 127 | } |
| 128 | if _, err := p.run(ctx, "cp", "--reflink=auto", basePath, diskPath); err != nil { | 128 | // Build into a sibling temp file and rename into place, so a create that is |
| 129 | return fmt.Errorf("cp --reflink=auto %s %s: %w", basePath, diskPath, err) | 129 | // killed (ctx cancel on agent stop, ENOSPC) mid-copy/truncate can never leave |
| 130 | // a torn disk.raw at the final path — the reconcile "exists" gate keys off | ||
| 131 | // rec.BootID, not disk presence, but an atomic artifact keeps the on-disk | ||
| 132 | // state honest for debugging and any future disk-aware code. The temp is a | ||
| 133 | // sibling (same dir/filesystem) so cp keeps its reflink and rename is atomic. | ||
| 134 | tmpPath := diskPath + ".partial" | ||
| 135 | _ = os.Remove(tmpPath) // clear any leftover from an earlier interrupted create | ||
| 136 | if _, err := p.run(ctx, "cp", "--reflink=auto", basePath, tmpPath); err != nil { | ||
| 137 | _ = os.Remove(tmpPath) | ||
| 138 | return fmt.Errorf("cp --reflink=auto %s %s: %w", basePath, tmpPath, err) | ||
| 130 | } | 139 | } |
| 131 | sizeArg := fmt.Sprintf("%dG", spec.DiskGB) | 140 | sizeArg := fmt.Sprintf("%dG", spec.DiskGB) |
| 132 | if _, err := p.run(ctx, "truncate", "-s", sizeArg, diskPath); err != nil { | 141 | if _, err := p.run(ctx, "truncate", "-s", sizeArg, tmpPath); err != nil { |
| 133 | return fmt.Errorf("truncate -s %s %s: %w", sizeArg, diskPath, err) | 142 | _ = os.Remove(tmpPath) |
| 143 | return fmt.Errorf("truncate -s %s %s: %w", sizeArg, tmpPath, err) | ||
| 144 | } | ||
| 145 | if err := os.Rename(tmpPath, diskPath); err != nil { | ||
| 146 | _ = os.Remove(tmpPath) | ||
| 147 | return fmt.Errorf("rename %s -> %s: %w", tmpPath, diskPath, err) | ||
| 134 | } | 148 | } |
| 135 | return nil | 149 | return nil |
| 136 | } | 150 | } |
| @@ -182,8 +196,11 @@ func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) er | |||
| 182 | // Write PID file so Running/Shutdown/Kill can find the process later. | 196 | // Write PID file so Running/Shutdown/Kill can find the process later. |
| 183 | pidData := []byte(strconv.Itoa(cmd.Process.Pid)) | 197 | pidData := []byte(strconv.Itoa(cmd.Process.Pid)) |
| 184 | if err := os.WriteFile(p.pidPath(vmID), pidData, 0o600); err != nil { | 198 | if err := os.WriteFile(p.pidPath(vmID), pidData, 0o600); err != nil { |
| 185 | // Best effort — kill the orphan if we can't track it. | 199 | // Best effort — kill the orphan if we can't track it, and Wait to reap it |
| 200 | // (the async reaper below is not started on this path, so without Wait the | ||
| 201 | // killed child would linger as a zombie for the agent's whole lifetime). | ||
| 186 | _ = cmd.Process.Kill() | 202 | _ = cmd.Process.Kill() |
| 203 | _ = cmd.Wait() | ||
| 187 | return fmt.Errorf("write pidfile %s: %w", vmID, err) | 204 | return fmt.Errorf("write pidfile %s: %w", vmID, err) |
| 188 | } | 205 | } |
| 189 | 206 | ||
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -118,6 +118,9 @@ func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) { | |||
| 118 | var cmds []string | 118 | var cmds []string |
| 119 | run := func(ctx context.Context, name string, args ...string) (string, error) { | 119 | run := func(ctx context.Context, name string, args ...string) (string, error) { |
| 120 | cmds = append(cmds, name+" "+strings.Join(args, " ")) | 120 | cmds = append(cmds, name+" "+strings.Join(args, " ")) |
| 121 | if name == "cp" { // simulate the copy so the atomic rename has a file to move | ||
| 122 | _ = os.WriteFile(args[len(args)-1], []byte("disk"), 0o600) | ||
| 123 | } | ||
| 121 | return "", nil | 124 | return "", nil |
| 122 | } | 125 | } |
| 123 | st, _ := state.Open(t.TempDir()) | 126 | st, _ := state.Open(t.TempDir()) |
| @@ -126,9 +129,13 @@ func TestPrepareDiskUsesReflinkAndResizes(t *testing.T) { | |||
| 126 | require.NoError(t, p.PrepareDisk(context.Background(), | 129 | require.NoError(t, p.PrepareDisk(context.Background(), |
| 127 | state.VMSpec{VMID: "vm1", DiskGB: 10}, base)) | 130 | state.VMSpec{VMID: "vm1", DiskGB: 10}, base)) |
| 128 | joined := strings.Join(cmds, "\n") | 131 | joined := strings.Join(cmds, "\n") |
| 132 | // Artifacts are built at a .partial sibling then renamed into place atomically. | ||
| 133 | partial := st.DiskPath("vm1") + ".partial" | ||
| 129 | // reflink=auto: instant on XFS/btrfs, silent full-copy fallback on ext4 (spec) | 134 | // reflink=auto: instant on XFS/btrfs, silent full-copy fallback on ext4 (spec) |
| 130 | assert.Contains(t, joined, "cp --reflink=auto "+base+" "+st.DiskPath("vm1")) | 135 | assert.Contains(t, joined, "cp --reflink=auto "+base+" "+partial) |
| 131 | assert.Contains(t, joined, "truncate -s 10G "+st.DiskPath("vm1")) | 136 | assert.Contains(t, joined, "truncate -s 10G "+partial) |
| 137 | assert.FileExists(t, st.DiskPath("vm1"), "disk.raw must be renamed into place") | ||
| 138 | assert.NoFileExists(t, partial, "temp must not survive a successful prepare") | ||
| 132 | } | 139 | } |
| 133 | 140 | ||
| 134 | // TestPrepareDiskRefusesToShrinkBaseImage pins the never-shrink guard: | 141 | // TestPrepareDiskRefusesToShrinkBaseImage pins the never-shrink guard: |
| @@ -281,6 +288,9 @@ func TestPrepareDiskShrinkGuardEdgeCases(t *testing.T) { | |||
| 281 | newP := func(t *testing.T, cmds *[]string) *Provisioner { | 288 | newP := func(t *testing.T, cmds *[]string) *Provisioner { |
| 282 | run := func(ctx context.Context, name string, args ...string) (string, error) { | 289 | run := func(ctx context.Context, name string, args ...string) (string, error) { |
| 283 | *cmds = append(*cmds, name) | 290 | *cmds = append(*cmds, name) |
| 291 | if name == "cp" { // simulate the copy so the atomic rename has a file to move | ||
| 292 | _ = os.WriteFile(args[len(args)-1], []byte("disk"), 0o600) | ||
| 293 | } | ||
| 284 | return "", nil | 294 | return "", nil |
| 285 | } | 295 | } |
| 286 | st, _ := state.Open(t.TempDir()) | 296 | st, _ := state.Open(t.TempDir()) |
internal/agent/imagecache/imagecache.go
| Old | New | ||
|---|---|---|---|
| @@ -112,15 +112,24 @@ func (c *Cache) Ensure(ctx context.Context, url, sha string) (string, error) { | |||
| 112 | return "", err | 112 | return "", err |
| 113 | } | 113 | } |
| 114 | defer os.Remove(tmp) | 114 | defer os.Remove(tmp) |
| 115 | // Convert to a temp file first; rename onto final atomically so a crash | 115 | // Convert to a UNIQUE temp file first, then rename onto final atomically so a |
| 116 | // mid-convert cannot leave a corrupt file at the final path. | 116 | // crash mid-convert cannot leave a corrupt file at the final path. The temp |
| 117 | converting := final + ".converting" | 117 | // name is unique (not a fixed "<sha>.converting") so two concurrent Ensure |
| 118 | // calls for the same sha can never converge on one file and corrupt it. The | ||
| 119 | // suffix deliberately does NOT end in ".raw", so evict()'s "*.raw" glob never | ||
| 120 | // sees a half-built temp. | ||
| 121 | cf, err := os.CreateTemp(c.dir, sha+".raw.converting-*") | ||
| 122 | if err != nil { | ||
| 123 | return "", fmt.Errorf("imagecache: create temp: %w", err) | ||
| 124 | } | ||
| 125 | converting := cf.Name() | ||
| 126 | _ = cf.Close() | ||
| 127 | _ = os.Remove(converting) // reserve the unique name; let qemu-img create it fresh | ||
| 128 | defer os.Remove(converting) | ||
| 118 | if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", tmp, converting); err != nil { | 129 | if _, err := c.run(ctx, "qemu-img", "convert", "-O", "raw", tmp, converting); err != nil { |
| 119 | os.Remove(converting) | ||
| 120 | return "", fmt.Errorf("qemu-img convert: %w", err) | 130 | return "", fmt.Errorf("qemu-img convert: %w", err) |
| 121 | } | 131 | } |
| 122 | if err := os.Rename(converting, final); err != nil { | 132 | if err := os.Rename(converting, final); err != nil { |
| 123 | os.Remove(converting) | ||
| 124 | return "", fmt.Errorf("imagecache: rename to final: %w", err) | 133 | return "", fmt.Errorf("imagecache: rename to final: %w", err) |
| 125 | } | 134 | } |
| 126 | c.evict(final) | 135 | c.evict(final) |
internal/agent/ipalloc/ipalloc.go
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,7 @@ | |||
| 1 | // Package ipalloc allocates VM IPs within the host's bridge CIDR. | 1 | // Package ipalloc allocates VM IPs within the host's bridge CIDR. |
| 2 | // .0 = network, .1 = bridge gateway, .255 = broadcast (for /24). | 2 | // The network address (first) is reserved, the gateway takes the next, and the |
| 3 | // subnet broadcast (last) is reserved. For the /24 the server assigns today | ||
| 4 | // those are .0, .1 and .255 respectively. | ||
| 3 | package ipalloc | 5 | package ipalloc |
| 4 | 6 | ||
| 5 | import ( | 7 | import ( |
| @@ -20,13 +22,25 @@ func Alloc(cidr string, used []string) (string, error) { | |||
| 20 | inUse[u] = true | 22 | inUse[u] = true |
| 21 | } | 23 | } |
| 22 | network := prefix.Masked().Addr() | 24 | network := prefix.Masked().Addr() |
| 23 | addr := network.Next().Next() // skip network + gateway | 25 | broadcast := broadcastAddr(prefix) // subnet-relative, not hardcoded .255 |
| 26 | addr := network.Next().Next() // skip network + gateway | ||
| 24 | for prefix.Contains(addr) { | 27 | for prefix.Contains(addr) { |
| 25 | a4 := addr.As4() | 28 | if addr != broadcast && !inUse[addr.String()] { |
| 26 | if a4[3] != 255 && !inUse[addr.String()] { | ||
| 27 | return addr.String(), nil | 29 | return addr.String(), nil |
| 28 | } | 30 | } |
| 29 | addr = addr.Next() | 31 | addr = addr.Next() |
| 30 | } | 32 | } |
| 31 | return "", fmt.Errorf("no free IP in %s", cidr) | 33 | return "", fmt.Errorf("no free IP in %s", cidr) |
| 32 | } | 34 | } |
| 35 | |||
| 36 | // broadcastAddr returns the all-host-bits-set (broadcast) address of an IPv4 | ||
| 37 | // prefix — e.g. 10.0.5.255 for 10.0.5.0/24, 10.0.0.63 for 10.0.0.0/26. The old | ||
| 38 | // code hardcoded a last octet of 255, which is only the broadcast for /24 or | ||
| 39 | // shorter; a /25–/30 bridge CIDR would otherwise hand out its true broadcast. | ||
| 40 | func broadcastAddr(p netip.Prefix) netip.Addr { | ||
| 41 | b := p.Masked().Addr().As4() | ||
| 42 | n := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) | ||
| 43 | host := uint32(0xffffffff) >> uint(p.Bits()) // low (32-bits) host bits set | ||
| 44 | n |= host | ||
| 45 | return netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}) | ||
| 46 | } | ||
internal/agent/ipalloc/ipalloc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package ipalloc | |||
| 3 | import ( | 3 | import ( |
| 4 | "strconv" | 4 | "strconv" |
| 5 | "testing" | 5 | "testing" |
| 6 | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" |
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" |
| 8 | ) | 9 | ) |
| @@ -21,6 +22,22 @@ func TestAllocFirstVM(t *testing.T) { | |||
| 21 | assert.Equal(t, "10.77.1.2", ip) | 22 | assert.Equal(t, "10.77.1.2", ip) |
| 22 | } | 23 | } |
| 23 | 24 | ||
| 25 | func TestAllocReservesSubnetBroadcastForNonSlash24(t *testing.T) { | ||
| 26 | // /26 => hosts .0(network) .1(gw) ... .63(broadcast). The old hardcoded-.255 | ||
| 27 | // broadcast would have wrongly handed out .63 and stopped short of .255. | ||
| 28 | used := make([]string, 0, 60) | ||
| 29 | for i := 2; i <= 62; i++ { // fill .2..*.62, leaving only .63 (broadcast) | ||
| 30 | used = append(used, "10.0.0."+itoa(i)) | ||
| 31 | } | ||
| 32 | _, err := Alloc("10.0.0.0/26", used) | ||
| 33 | assert.Error(t, err, ".63 is the /26 broadcast and must not be allocated") | ||
| 34 | |||
| 35 | // With .62 free it is allocatable (it is a valid host, not the broadcast). | ||
| 36 | ip, err := Alloc("10.0.0.0/26", used[:len(used)-1]) | ||
| 37 | require.NoError(t, err) | ||
| 38 | assert.Equal(t, "10.0.0.62", ip) | ||
| 39 | } | ||
| 40 | |||
| 24 | func TestAllocExhausted(t *testing.T) { | 41 | func TestAllocExhausted(t *testing.T) { |
| 25 | used := make([]string, 0, 253) | 42 | used := make([]string, 0, 253) |
| 26 | for i := 2; i <= 254; i++ { | 43 | for i := 2; i <= 254; i++ { |
internal/agent/netenv/netenv.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | // Package netenv manages the host side of VM networking: bridge eitri0 with | 1 | // Package netenv manages the host side of VM networking: bridge eitri0 with |
| 2 | // the host as .1 gateway, per-VM taps, and NAT for outbound internet. The | 2 | // the host as .1 gateway, per-VM taps, and NAT for outbound internet. The |
| 3 | // bridge is a pure masqueraded underlay; mesh connectivity (per-VM identity | 3 | // bridge is a pure masqueraded underlay; inbound admin reachability is via the |
| 4 | // and routing) is the guest's own concern via the rayfish (ray) binary. | 4 | // server's SSH-CA jump gate (internal/server/sshgate), not any guest overlay. |
| 5 | package netenv | 5 | package netenv |
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -2,18 +2,22 @@ | |||
| 2 | // | 2 | // |
| 3 | // Definitions (normative, from the spec): | 3 | // Definitions (normative, from the spec): |
| 4 | // | 4 | // |
| 5 | // exists = state-dir record present AND (disk present OR create completed) | 5 | // exists = state-dir record present AND create completed (rec.BootID != "") |
| 6 | // lost = boot ID changed OR process died without a recorded stop request; | 6 | // lost = boot ID changed OR process died without a recorded stop request; |
| 7 | // a deliberately stopped VM is stopped, NOT lost | 7 | // a deliberately stopped VM is stopped, NOT lost |
| 8 | // destroyed[] ack = level-triggered: every tombstoned vm_id with no local | 8 | // destroyed[] ack = level-triggered: every tombstoned vm_id with no local |
| 9 | // record, repeated until the server hard-deletes it | 9 | // record, repeated until the server hard-deletes it |
| 10 | // | 10 | // |
| 11 | // The "exists" definition deserves a comment: | 11 | // The "exists" definition deserves a comment: |
| 12 | // On real hosts, disk presence is the physical witness that a create completed. | 12 | // rec.BootID is the sole completion witness. create() sets it to the current |
| 13 | // With fake provisioners (tests), no disk file is written, so we fall back to | 13 | // host boot ID only after every side effect (image, tap, disk, seed, boot) has |
| 14 | // the logical witness: a completed create always sets rec.BootID to the current | 14 | // succeeded, so rec.BootID != "" means — and only means — a create finished. |
| 15 | // host boot ID. rec.BootID != "" means create succeeded. Absence of both (no | 15 | // Disk presence is deliberately NOT consulted: create() writes disk.raw in the |
| 16 | // disk AND BootID == "") means an incomplete create that must be retried. | 16 | // middle of the sequence, so a create that fails after the disk is written but |
| 17 | // before boot leaves a disk on a still-empty BootID; treating that as "exists" | ||
| 18 | // would divert the retry to converge() (which rebuilds neither disk nor seed) | ||
| 19 | // and strand the VM. Belt-and-suspenders: PrepareDisk and seed.Build both write | ||
| 20 | // via a temp file + rename, so a killed create can never leave a torn artifact. | ||
| 17 | package reconcile | 21 | package reconcile |
| 18 | 22 | ||
| 19 | import ( | 23 | import ( |
| @@ -132,11 +136,7 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 132 | for _, rec := range recs { | 136 | for _, rec := range recs { |
| 133 | // Fix 4: quarantined VMs belong in Quarantined[], not Vms[]. | 137 | // Fix 4: quarantined VMs belong in Quarantined[], not Vms[]. |
| 134 | if rec.QuarantinedAt != nil { | 138 | if rec.QuarantinedAt != nil { |
| 135 | grace := e.VanishGrace | 139 | rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, e.graceFor(rec))) |
| 136 | if rec.QuarantineTombstoned { | ||
| 137 | grace = e.TombstoneGrace | ||
| 138 | } | ||
| 139 | rep.Quarantined = append(rep.Quarantined, quarantinedEntry(rec, grace)) | ||
| 140 | continue | 140 | continue |
| 141 | } | 141 | } |
| 142 | power := "stopped" | 142 | power := "stopped" |
| @@ -206,10 +206,7 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 206 | _ = e.St.SaveVM(rec) | 206 | _ = e.St.SaveVM(rec) |
| 207 | } | 207 | } |
| 208 | 208 | ||
| 209 | grace := e.VanishGrace | 209 | grace := e.graceFor(rec) |
| 210 | if rec.QuarantineTombstoned { | ||
| 211 | grace = e.TombstoneGrace | ||
| 212 | } | ||
| 213 | 210 | ||
| 214 | if now.Sub(*rec.QuarantinedAt) >= grace { | 211 | if now.Sub(*rec.QuarantinedAt) >= grace { |
| 215 | // Grace expired: destroy the VM. | 212 | // Grace expired: destroy the VM. |
| @@ -219,7 +216,16 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 219 | // that honors ctx here would skip the destroy until a later tick, | 216 | // that honors ctx here would skip the destroy until a later tick, |
| 220 | // which the level-triggered loop tolerates but delays. | 217 | // which the level-triggered loop tolerates but delays. |
| 221 | _ = e.Prov.Kill(ctx, id) | 218 | _ = e.Prov.Kill(ctx, id) |
| 222 | _ = e.Net.DeleteTap(ctx, state.TapName(id)) | 219 | if err := e.Net.DeleteTap(ctx, state.TapName(id)); err != nil { |
| 220 | // TAP deletion failed — e.g. DeleteTap runs `ip link del` under | ||
| 221 | // ctx and the ctx expired during a SIGTERM shutdown. KEEP the | ||
| 222 | // record so a later tick retries; deleting it here would orphan | ||
| 223 | // the eit-XXXXXXXX interface with nothing left to reap it (agent | ||
| 224 | // restart's EnsureBridge does not sweep orphan taps). Kill already | ||
| 225 | // stopped the guest, so re-entering here next tick (grace still | ||
| 226 | // expired) just retries DeleteTap idempotently until it succeeds. | ||
| 227 | continue | ||
| 228 | } | ||
| 223 | _ = e.St.DeleteVM(id) | 229 | _ = e.St.DeleteVM(id) |
| 224 | // Do NOT append to rep.Quarantined — VM is gone. | 230 | // Do NOT append to rep.Quarantined — VM is gone. |
| 225 | } else { | 231 | } else { |
| @@ -232,6 +238,14 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 232 | // Re-load records after reap pass. Every tombstoned ID with NO local record | 238 | // Re-load records after reap pass. Every tombstoned ID with NO local record |
| 233 | // is acked in destroyed[] every report until the server hard-deletes it. | 239 | // is acked in destroyed[] every report until the server hard-deletes it. |
| 234 | recs, _ = e.St.LoadVMs() | 240 | recs, _ = e.St.LoadVMs() |
| 241 | // LoadVMs returns a nil map on a (transient) ReadDir failure. The converge | ||
| 242 | // pass below WRITES into recs (recs[d.VmId]=rec after a durable create); a | ||
| 243 | // write to a nil map panics. Fall back to an empty map so a load blip | ||
| 244 | // degrades to an empty view (as the old read-only use did) instead of | ||
| 245 | // crashing the agent. | ||
| 246 | if recs == nil { | ||
| 247 | recs = map[string]state.Record{} | ||
| 248 | } | ||
| 235 | for id := range tombstoned { | 249 | for id := range tombstoned { |
| 236 | if _, hasRecord := recs[id]; !hasRecord { | 250 | if _, hasRecord := recs[id]; !hasRecord { |
| 237 | rep.Destroyed = append(rep.Destroyed, id) | 251 | rep.Destroyed = append(rep.Destroyed, id) |
| @@ -245,13 +259,16 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 245 | continue // tombstoned VMs are handled by reap + destroyed[] | 259 | continue // tombstoned VMs are handled by reap + destroyed[] |
| 246 | } | 260 | } |
| 247 | rec, ok := recs[id] | 261 | rec, ok := recs[id] |
| 248 | // exists = record present AND (disk present OR create completed). | 262 | // exists = record present AND create completed. rec.BootID is the sole |
| 249 | // BootID != "" is the logical witness that create finished: the real | 263 | // completion witness: create() writes it only after every side effect |
| 250 | // provisioner creates the disk; in tests the fake does not, but both | 264 | // (image, tap, disk, seed, boot) has succeeded. Disk presence is NOT a |
| 251 | // set BootID after a successful create. | 265 | // witness — create() writes disk.raw mid-sequence, so a create that fails |
| 252 | exists := ok && (e.St.DiskExists(id) || rec.BootID != "") | 266 | // after PrepareDisk but before boot leaves a disk on a still-empty BootID. |
| 267 | // Treating that disk as "exists" would divert the retry to converge(), | ||
| 268 | // which never rebuilds the disk or seed, and the VM would never recover. | ||
| 269 | exists := ok && rec.BootID != "" | ||
| 253 | if !exists { | 270 | if !exists { |
| 254 | e.create(ctx, d, rec, ok, rep) | 271 | e.create(ctx, d, rep, recs) |
| 255 | } else { | 272 | } else { |
| 256 | e.converge(ctx, d, rec, rep) | 273 | e.converge(ctx, d, rec, rep) |
| 257 | } | 274 | } |
| @@ -260,19 +277,31 @@ func (e *Engine) Step(ctx context.Context, snap *pb.DesiredStateSnapshot) *pb.Ac | |||
| 260 | return rep | 277 | return rep |
| 261 | } | 278 | } |
| 262 | 279 | ||
| 280 | // graceFor returns the quarantine grace period for rec: the shorter | ||
| 281 | // TombstoneGrace for a tombstoned VM, else VanishGrace. | ||
| 282 | func (e *Engine) graceFor(rec state.Record) time.Duration { | ||
| 283 | if rec.QuarantineTombstoned { | ||
| 284 | return e.TombstoneGrace | ||
| 285 | } | ||
| 286 | return e.VanishGrace | ||
| 287 | } | ||
| 288 | |||
| 263 | // quotaBlock returns a non-empty reason when booting d would exceed a configured | 289 | // quotaBlock returns a non-empty reason when booting d would exceed a configured |
| 264 | // host resource cap, else "". It sums the resources of the currently-committed | 290 | // host resource cap, else "". It sums the resources of the currently-committed |
| 265 | // local VMs — excluding quarantined records (being torn down; their guests are | 291 | // local VMs — excluding quarantined records (being torn down; their guests are |
| 266 | // already stopped) and d's own record (so a retry or spec-edit of an existing | 292 | // already stopped) and d's own record (so a retry or spec-edit of an existing |
| 267 | // VM does not double-count itself) — and adds d's request. The binding | 293 | // VM does not double-count itself) — and adds d's request. The binding |
| 268 | // dimension is named in the message so the operator sees which cap was hit. | 294 | // dimension is named in the message so the operator sees which cap was hit. |
| 269 | func (e *Engine) quotaBlock(d *pb.VMDesired) string { | 295 | func (e *Engine) quotaBlock(d *pb.VMDesired, recs map[string]state.Record) string { |
| 270 | if e.MaxVCPUs == 0 && e.MaxMemMB == 0 && e.MaxDiskGB == 0 { | 296 | if e.MaxVCPUs == 0 && e.MaxMemMB == 0 && e.MaxDiskGB == 0 { |
| 271 | return "" // no caps configured — unlimited | 297 | return "" // no caps configured — unlimited |
| 272 | } | 298 | } |
| 273 | spec := specFromDesired(d) | 299 | spec := specFromDesired(d) |
| 274 | vcpus, mem, disk := spec.VCPUs, spec.MemMB, spec.DiskGB | 300 | vcpus, mem, disk := spec.VCPUs, spec.MemMB, spec.DiskGB |
| 275 | recs, _ := e.St.LoadVMs() | 301 | // recs is the tick's live record map (loaded once post-reap in Step, kept |
| 302 | // intra-tick-consistent as each create commits its record). Reading it — | ||
| 303 | // rather than re-LoadVMs here — is what lets a second create in the same tick | ||
| 304 | // see the first's committed resources and trip the cap. | ||
| 276 | for _, r := range recs { | 305 | for _, r := range recs { |
| 277 | if r.QuarantinedAt != nil || r.Spec.VMID == d.VmId { | 306 | if r.QuarantinedAt != nil || r.Spec.VMID == d.VmId { |
| 278 | continue | 307 | continue |
| @@ -293,8 +322,20 @@ func (e *Engine) quotaBlock(d *pb.VMDesired) string { | |||
| 293 | } | 322 | } |
| 294 | 323 | ||
| 295 | // create attempts to create a new VM from desired state d. | 324 | // create attempts to create a new VM from desired state d. |
| 296 | // rec is the existing (potentially stale) record, ok indicates whether one exists. | 325 | // recs is the tick's live record map (loaded once in Step); create looks up |
| 297 | func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, ok bool, rep *pb.ActualStateReport) { | 326 | // d.VmId in it to get the existing (potentially stale) record, if any. create |
| 327 | // also reads recs for the quota sum and the IP used-set INSTEAD of | ||
| 328 | // re-LoadVMs-ing, and — crucially — | ||
| 329 | // writes the new record back into it (recs[d.VmId]=rec) the moment that record's | ||
| 330 | // Spec+IP are durably committed, so a later create in the same Step's converge | ||
| 331 | // loop observes this VM's committed IP and resources. This reproduces exactly | ||
| 332 | // what the old per-create LoadVMs did: pre-change it re-read disk and saw the | ||
| 333 | // earlier create's just-saved record; now it reads the same fact from the shared | ||
| 334 | // map. Step passes the same map reference to every create, so the write is | ||
| 335 | // visible to subsequent calls. | ||
| 336 | func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rep *pb.ActualStateReport, recs map[string]state.Record) { | ||
| 337 | rec, ok := recs[d.VmId] | ||
| 338 | |||
| 298 | // A fired step watchdog is the STEP's failure, not this VM's: don't start | 339 | // A fired step watchdog is the STEP's failure, not this VM's: don't start |
| 299 | // an attempt (which would burn retry budget) with a dead context. Report | 340 | // an attempt (which would burn retry budget) with a dead context. Report |
| 300 | // and let the next tick — with a fresh budget — do the work. Pinned by | 341 | // and let the next tick — with a fresh budget — do the work. Pinned by |
| @@ -304,11 +345,13 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 304 | return | 345 | return |
| 305 | } | 346 | } |
| 306 | 347 | ||
| 348 | spec := specFromDesired(d) | ||
| 349 | |||
| 307 | // Fix 2: if the desired spec differs from the stored spec, the user edited the | 350 | // Fix 2: if the desired spec differs from the stored spec, the user edited the |
| 308 | // VM definition. Reset CreateAttempts so the new spec gets a fresh retry budget | 351 | // VM definition. Reset CreateAttempts so the new spec gets a fresh retry budget |
| 309 | // instead of being permanently terminal-failed due to the old spec's failures. | 352 | // instead of being permanently terminal-failed due to the old spec's failures. |
| 310 | // VMSpec is fully comparable (all fields are strings/ints/bool), so == is safe. | 353 | // VMSpec is fully comparable (all fields are strings/ints/bool), so == is safe. |
| 311 | if ok && specFromDesired(d) != rec.Spec { | 354 | if ok && spec != rec.Spec { |
| 312 | rec.CreateAttempts = 0 | 355 | rec.CreateAttempts = 0 |
| 313 | rec.LastError = "" | 356 | rec.LastError = "" |
| 314 | } | 357 | } |
| @@ -324,28 +367,35 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 324 | // before touching CreateAttempts or any state, so once a running VM is | 367 | // before touching CreateAttempts or any state, so once a running VM is |
| 325 | // removed and room frees, the next level-triggered tick retries and boots. | 368 | // removed and room frees, the next level-triggered tick retries and boots. |
| 326 | // A newly-lowered cap never kills a running guest; only new boots are gated. | 369 | // A newly-lowered cap never kills a running guest; only new boots are gated. |
| 327 | if msg := e.quotaBlock(d); msg != "" { | 370 | if msg := e.quotaBlock(d, recs); msg != "" { |
| 328 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", msg) | 371 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", msg) |
| 329 | return | 372 | return |
| 330 | } | 373 | } |
| 331 | 374 | ||
| 332 | // Build the spec from desired. | 375 | // Build the spec from desired. |
| 333 | rec.Spec = specFromDesired(d) | 376 | rec.Spec = spec |
| 334 | rec.CreateAttempts++ | 377 | rec.CreateAttempts++ |
| 335 | rec.CreatedAt = e.Now() | 378 | rec.CreatedAt = e.Now() |
| 336 | rec.LastError = "" // clear for this attempt | 379 | rec.LastError = "" // clear for this attempt |
| 337 | 380 | ||
| 338 | // Allocate an IP if we don't have one yet. | 381 | // Allocate an IP if we don't have one yet. The used-set is built from the |
| 382 | // tick's live record map (kept intra-tick-consistent below), so two VMs | ||
| 383 | // created in one Step never collide on an address. | ||
| 339 | if rec.IP == "" { | 384 | if rec.IP == "" { |
| 340 | allRecs, _ := e.St.LoadVMs() | 385 | used := make([]string, 0, len(recs)) |
| 341 | used := make([]string, 0, len(allRecs)) | 386 | for _, r := range recs { |
| 342 | for _, r := range allRecs { | ||
| 343 | if r.IP != "" { | 387 | if r.IP != "" { |
| 344 | used = append(used, r.IP) | 388 | used = append(used, r.IP) |
| 345 | } | 389 | } |
| 346 | } | 390 | } |
| 347 | ip, err := e.Net.AllocateIP(ctx, used) | 391 | ip, err := e.Net.AllocateIP(ctx, used) |
| 348 | if err != nil { | 392 | if err != nil { |
| 393 | // failCreate durably saves this record (Spec set, IP still empty). | ||
| 394 | // Publish it so a same-tick sibling's quota check counts its Spec — | ||
| 395 | // matching the old per-create LoadVMs, which re-read the failCreate | ||
| 396 | // save. Tick-scoped: conservative inclusion only over-counts for one | ||
| 397 | // tick and self-heals on the next fresh load. | ||
| 398 | recs[d.VmId] = rec | ||
| 349 | e.failCreate(ctx, rec, err, rep) | 399 | e.failCreate(ctx, rec, err, rep) |
| 350 | return | 400 | return |
| 351 | } | 401 | } |
| @@ -354,9 +404,21 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 354 | 404 | ||
| 355 | // Record BEFORE side effects so a crash is recoverable. | 405 | // Record BEFORE side effects so a crash is recoverable. |
| 356 | if err := e.St.SaveVM(rec); err != nil { | 406 | if err := e.St.SaveVM(rec); err != nil { |
| 407 | // This save failed but failCreate's save may succeed, durably committing | ||
| 408 | // the allocated IP. Publish so a same-tick sibling excludes that IP from | ||
| 409 | // its used-set and counts its Spec — preventing a double-allocation the | ||
| 410 | // old per-create LoadVMs could not produce. | ||
| 411 | recs[d.VmId] = rec | ||
| 357 | e.failCreate(ctx, rec, err, rep) | 412 | e.failCreate(ctx, rec, err, rep) |
| 358 | return | 413 | return |
| 359 | } | 414 | } |
| 415 | // Intra-tick consistency: this record's Spec+IP are now durably committed. | ||
| 416 | // Publish it into the shared tick map so a later create in this same Step | ||
| 417 | // sees it — its quotaBlock sums this VM's resources and its IP used-set | ||
| 418 | // excludes this VM's address. This mirrors the old per-create LoadVMs, which | ||
| 419 | // re-read exactly this just-saved record. Only Spec/IP matter to quota/IP; | ||
| 420 | // the later BootID/StopRequested save does not change them, so we update here. | ||
| 421 | recs[d.VmId] = rec | ||
| 360 | 422 | ||
| 361 | // Resolve base image. | 423 | // Resolve base image. |
| 362 | basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256) | 424 | basePath, err := e.Images(ctx, d.ImageUrl, d.ImageSha256) |
| @@ -452,6 +514,16 @@ func (e *Engine) failCreate(ctx context.Context, rec state.Record, err error, re | |||
| 452 | addReport(rep, rec.Spec.VMID, rec.IP, "stopped", phase, rec.LastError) | 514 | addReport(rep, rec.Spec.VMID, rec.IP, "stopped", phase, rec.LastError) |
| 453 | } | 515 | } |
| 454 | 516 | ||
| 517 | // failConverge is the shared epilogue for the converge restart/boot paths: | ||
| 518 | // persist err on the record and report it stopped/failed. Unlike failCreate | ||
| 519 | // there is no create-attempt budget — a converge (already-created VM) failure | ||
| 520 | // is terminal-for-this-tick and reported failed immediately. | ||
| 521 | func (e *Engine) failConverge(rec state.Record, err error, rep *pb.ActualStateReport) { | ||
| 522 | rec.LastError = err.Error() | ||
| 523 | _ = e.St.SaveVM(rec) | ||
| 524 | addReport(rep, rec.Spec.VMID, rec.IP, "stopped", "failed", rec.LastError) | ||
| 525 | } | ||
| 526 | |||
| 455 | // converge drives an existing VM toward its desired power state, | 527 | // converge drives an existing VM toward its desired power state, |
| 456 | // handling lost detection and restart logic. | 528 | // handling lost detection and restart logic. |
| 457 | func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record, rep *pb.ActualStateReport) { | 529 | func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record, rep *pb.ActualStateReport) { |
| @@ -479,15 +551,11 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record | |||
| 479 | // ITS message — letting Boot fail instead yields an illegible | 551 | // ITS message — letting Boot fail instead yields an illegible |
| 480 | // cloud-hypervisor error for the same root cause. | 552 | // cloud-hypervisor error for the same root cause. |
| 481 | if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil { | 553 | if err := e.Net.CreateTap(ctx, state.TapName(d.VmId)); err != nil { |
| 482 | rec.LastError = err.Error() | 554 | e.failConverge(rec, err, rep) |
| 483 | _ = e.St.SaveVM(rec) | ||
| 484 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError) | ||
| 485 | return | 555 | return |
| 486 | } | 556 | } |
| 487 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { | 557 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { |
| 488 | rec.LastError = err.Error() | 558 | e.failConverge(rec, err, rep) |
| 489 | _ = e.St.SaveVM(rec) | ||
| 490 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError) | ||
| 491 | return | 559 | return |
| 492 | } | 560 | } |
| 493 | rec.BootID = bootID | 561 | rec.BootID = bootID |
| @@ -509,9 +577,7 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record | |||
| 509 | if d.PowerState == "running" && !running { | 577 | if d.PowerState == "running" && !running { |
| 510 | // Start the VM. | 578 | // Start the VM. |
| 511 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { | 579 | if err := e.Prov.Boot(ctx, d.VmId, rec.Spec); err != nil { |
| 512 | rec.LastError = err.Error() | 580 | e.failConverge(rec, err, rep) |
| 513 | _ = e.St.SaveVM(rec) | ||
| 514 | addReport(rep, d.VmId, rec.IP, "stopped", "failed", rec.LastError) | ||
| 515 | return | 581 | return |
| 516 | } | 582 | } |
| 517 | rec.StopRequested = false | 583 | rec.StopRequested = false |
| @@ -540,10 +606,9 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMDesired, rec state.Record | |||
| 540 | if running { | 606 | if running { |
| 541 | power = "running" | 607 | power = "running" |
| 542 | } | 608 | } |
| 543 | phase := "ready" | ||
| 544 | // Preserve last error in the report field but phase stays ready | 609 | // Preserve last error in the report field but phase stays ready |
| 545 | // (the VM is converged; the error is informational history). | 610 | // (the VM is converged; the error is informational history). |
| 546 | addReport(rep, d.VmId, rec.IP, power, phase, rec.LastError) | 611 | addReport(rep, d.VmId, rec.IP, power, "ready", rec.LastError) |
| 547 | } | 612 | } |
| 548 | } | 613 | } |
| 549 | 614 | ||
internal/agent/reconcile/reconcile_test.go
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "context" | 4 | "context" |
| 5 | "errors" | 5 | "errors" |
| 6 | "net/netip" | 6 | "net/netip" |
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 7 | "testing" | 9 | "testing" |
| 8 | "time" | 10 | "time" |
| 9 | 11 | ||
| @@ -61,9 +63,10 @@ func (f *fakeProv) Kill(_ context.Context, id string) error { | |||
| 61 | func (f *fakeProv) Running(id string) bool { return f.running[id] } | 63 | func (f *fakeProv) Running(id string) bool { return f.running[id] } |
| 62 | 64 | ||
| 63 | type fakeNet struct { | 65 | type fakeNet struct { |
| 64 | taps []string | 66 | taps []string |
| 65 | deleted []string | 67 | deleted []string |
| 66 | cidr string | 68 | cidr string |
| 69 | allocErr error // one-shot: consumed and cleared on the first AllocateIP call | ||
| 67 | } | 70 | } |
| 68 | 71 | ||
| 69 | func (f *fakeNet) CreateTap(_ context.Context, t string) error { | 72 | func (f *fakeNet) CreateTap(_ context.Context, t string) error { |
| @@ -78,6 +81,11 @@ func (f *fakeNet) DeleteTap(_ context.Context, t string) error { | |||
| 78 | // AllocateIP / GuestNetwork mirror netenv's host-local behavior so reconcile | 81 | // AllocateIP / GuestNetwork mirror netenv's host-local behavior so reconcile |
| 79 | // tests exercise identical addressing through the seam. | 82 | // tests exercise identical addressing through the seam. |
| 80 | func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) { | 83 | func (f *fakeNet) AllocateIP(_ context.Context, used []string) (string, error) { |
| 84 | if f.allocErr != nil { | ||
| 85 | err := f.allocErr | ||
| 86 | f.allocErr = nil // one-shot | ||
| 87 | return "", err | ||
| 88 | } | ||
| 81 | return ipalloc.Alloc(f.cidr, used) | 89 | return ipalloc.Alloc(f.cidr, used) |
| 82 | } | 90 | } |
| 83 | func (f *fakeNet) GuestNetwork() (string, int) { | 91 | func (f *fakeNet) GuestNetwork() (string, int) { |
| @@ -157,6 +165,81 @@ func TestCreateAllocatesIPPreparesAndBoots(t *testing.T) { | |||
| 157 | assert.Equal(t, uint64(1), rep.LastSeenEpoch) | 165 | assert.Equal(t, uint64(1), rep.LastSeenEpoch) |
| 158 | } | 166 | } |
| 159 | 167 | ||
| 168 | // TestIncompleteCreateWithDiskPresentIsRetried pins the completion-witness fix: | ||
| 169 | // a create that failed after PrepareDisk wrote disk.raw but before boot leaves a | ||
| 170 | // record with an empty BootID AND a disk file on disk. The witness is BootID, not | ||
| 171 | // disk presence — so the next tick must re-run create() (rebuilding disk + seed), | ||
| 172 | // NOT divert to converge() (which rebuilds neither and would strand the VM). | ||
| 173 | func TestIncompleteCreateWithDiskPresentIsRetried(t *testing.T) { | ||
| 174 | f := setup(t) | ||
| 175 | const id = "vm1" | ||
| 176 | |||
| 177 | // Simulate the interrupted create: record saved pre-boot (BootID == ""), | ||
| 178 | // with the disk already materialised so the disk file exists on disk. | ||
| 179 | require.NoError(t, f.st.SaveVM(state.Record{ | ||
| 180 | Spec: state.VMSpec{VMID: id, Name: "vm-" + id, VCPUs: 1, MemMB: 512, DiskGB: 5}, | ||
| 181 | IP: "10.77.1.2", | ||
| 182 | BootID: "", // create never completed | ||
| 183 | CreatedAt: f.now, | ||
| 184 | })) | ||
| 185 | require.NoError(t, os.MkdirAll(f.st.VMDir(id), 0o700)) | ||
| 186 | require.NoError(t, os.WriteFile(f.st.DiskPath(id), []byte("partial"), 0o600)) | ||
| 187 | _, statErr := os.Stat(f.st.DiskPath(id)) | ||
| 188 | require.NoError(t, statErr, "precondition: disk present") | ||
| 189 | |||
| 190 | rep := f.eng.Step(context.Background(), snap(1, vm(id))) | ||
| 191 | |||
| 192 | assert.Equal(t, []string{id}, f.prov.prepared, "must re-run create (PrepareDisk), not converge") | ||
| 193 | assert.Equal(t, []string{id}, f.prov.booted) | ||
| 194 | av := findVM(rep, id) | ||
| 195 | require.NotNil(t, av) | ||
| 196 | assert.Equal(t, "ready", av.Phase) | ||
| 197 | } | ||
| 198 | |||
| 199 | // TestCreateSurvivesRecordLoadFailure guards against the nil-map panic: when | ||
| 200 | // LoadVMs errors (transient ReadDir failure) it returns a nil map, and the | ||
| 201 | // converge pass WRITES into that map (recs[d.VmId]=rec) after a durable create. | ||
| 202 | // A write to a nil map panics — crashing the agent where the old read-only use | ||
| 203 | // merely degraded. We force the failure by removing the vms/ dir; SaveVM | ||
| 204 | // re-creates vms/<id> via MkdirAll, so the create still commits and the write | ||
| 205 | // path is exercised. | ||
| 206 | func TestCreateSurvivesRecordLoadFailure(t *testing.T) { | ||
| 207 | f := setup(t) | ||
| 208 | vmsDir := filepath.Dir(f.st.VMDir("placeholder")) | ||
| 209 | require.NoError(t, os.RemoveAll(vmsDir)) | ||
| 210 | |||
| 211 | require.NotPanics(t, func() { | ||
| 212 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1"))) | ||
| 213 | av := findVM(rep, "vm1") | ||
| 214 | require.NotNil(t, av) | ||
| 215 | assert.Equal(t, "ready", av.Phase) | ||
| 216 | }) | ||
| 217 | } | ||
| 218 | |||
| 219 | // TestSameTickSiblingCountsFailedCreateAgainstQuota pins that a VM whose create | ||
| 220 | // fails early (here: IP allocation) still has its Spec published into the tick's | ||
| 221 | // record map, so a SAME-TICK sibling's quota check counts it. Both VMs are | ||
| 222 | // identical (2 vCPU) under a 3-vCPU cap, so whichever is processed first fails IP | ||
| 223 | // allocation and the other must be quota-blocked — NEITHER boots. Before the fix | ||
| 224 | // the failed VM was invisible to the sibling, which wrongly booted. | ||
| 225 | func TestSameTickSiblingCountsFailedCreateAgainstQuota(t *testing.T) { | ||
| 226 | f := setup(t) | ||
| 227 | f.eng.MaxVCPUs = 3 | ||
| 228 | f.net.allocErr = assert.AnError | ||
| 229 | twoVCPU := func(v *pb.VMDesired) { v.Vcpus = 2 } | ||
| 230 | |||
| 231 | rep := f.eng.Step(context.Background(), snap(1, vm("vm1", twoVCPU), vm("vm2", twoVCPU))) | ||
| 232 | |||
| 233 | ready := 0 | ||
| 234 | for _, v := range rep.Vms { | ||
| 235 | if v.Phase == "ready" { | ||
| 236 | ready++ | ||
| 237 | } | ||
| 238 | } | ||
| 239 | assert.Equal(t, 0, ready, "a failed-create sibling must count against the other's cap") | ||
| 240 | assert.Empty(t, f.prov.booted, "neither VM should boot") | ||
| 241 | } | ||
| 242 | |||
| 160 | func TestFenceRefusesLowerEpochWithoutActing(t *testing.T) { | 243 | func TestFenceRefusesLowerEpochWithoutActing(t *testing.T) { |
| 161 | f := setup(t) | 244 | f := setup(t) |
| 162 | f.eng.Step(context.Background(), snap(5, vm("vm1"))) | 245 | f.eng.Step(context.Background(), snap(5, vm("vm1"))) |
| @@ -448,6 +531,51 @@ func TestPermanentCreateErrorFailsTerminallyInOneAttempt(t *testing.T) { | |||
| 448 | assert.Equal(t, 1, f.prov.prepCalls, "no further provisioner attempts after a permanent failure") | 531 | assert.Equal(t, 1, f.prov.prepCalls, "no further provisioner attempts after a permanent failure") |
| 449 | } | 532 | } |
| 450 | 533 | ||
| 534 | // TestTwoVMsCreatedInOneStepGetDistinctIPs pins intra-tick IP consistency: | ||
| 535 | // when a single Step creates two VMs, the second create must SEE the first's | ||
| 536 | // just-allocated IP in its used-set and pick a different one. A naive | ||
| 537 | // implementation that threads the pre-create records map (loaded before any | ||
| 538 | // create) into the IP used-set would hand both creates the same empty used-set | ||
| 539 | // and double-allocate the same address. The reconcile loop's converge order is | ||
| 540 | // randomized, so this must hold regardless of which VM is created first. | ||
| 541 | func TestTwoVMsCreatedInOneStepGetDistinctIPs(t *testing.T) { | ||
| 542 | f := setup(t) | ||
| 543 | f.eng.Step(context.Background(), snap(1, vm("vm1"), vm("vm2"))) | ||
| 544 | recs, err := f.st.LoadVMs() | ||
| 545 | require.NoError(t, err) | ||
| 546 | require.Contains(t, recs, "vm1") | ||
| 547 | require.Contains(t, recs, "vm2") | ||
| 548 | assert.NotEmpty(t, recs["vm1"].IP) | ||
| 549 | assert.NotEmpty(t, recs["vm2"].IP) | ||
| 550 | assert.NotEqual(t, recs["vm1"].IP, recs["vm2"].IP, | ||
| 551 | "two VMs created in one Step must not share an IP (intra-tick used-set)") | ||
| 552 | } | ||
| 553 | |||
| 554 | // TestSecondVMInOneStepBustingCapIsBlocked pins intra-tick quota consistency: | ||
| 555 | // two 2-vcpu VMs sum to 4 > the 3-vcpu cap, so exactly one may boot per tick. | ||
| 556 | // The second create's quotaBlock must SEE the first's just-committed record so | ||
| 557 | // the running total (2 + 2 = 4) trips the cap. A naive implementation feeding | ||
| 558 | // the pre-create records map to quotaBlock would count 0 committed for both and | ||
| 559 | // let both boot (over-commit). Converge order is randomized, so we assert on the | ||
| 560 | // count and identify the blocked VM dynamically. | ||
| 561 | func TestSecondVMInOneStepBustingCapIsBlocked(t *testing.T) { | ||
| 562 | f := setup(t) | ||
| 563 | f.eng.MaxVCPUs = 3 | ||
| 564 | rep := f.eng.Step(context.Background(), snap(1, | ||
| 565 | vm("vm1", withRes(2, 512, 5)), | ||
| 566 | vm("vm2", withRes(2, 512, 5)))) | ||
| 567 | require.Len(t, f.prov.booted, 1, "exactly one of two cap-busting VMs may boot in one tick") | ||
| 568 | other := "vm1" | ||
| 569 | if f.prov.booted[0] == "vm1" { | ||
| 570 | other = "vm2" | ||
| 571 | } | ||
| 572 | av := findVM(rep, other) | ||
| 573 | require.NotNil(t, av) | ||
| 574 | assert.Equal(t, "failed", av.Phase, "the second cap-busting VM must be quota-blocked") | ||
| 575 | assert.Contains(t, av.GetLastError(), "capacity limit") | ||
| 576 | assert.Contains(t, av.GetLastError(), "vcpus") | ||
| 577 | } | ||
| 578 | |||
| 451 | // TestTransientCreateErrorStillRetries pins the counterpart: unmarked errors | 579 | // TestTransientCreateErrorStillRetries pins the counterpart: unmarked errors |
| 452 | // keep the existing bounded-retry behavior (phase creating until the budget | 580 | // keep the existing bounded-retry behavior (phase creating until the budget |
| 453 | // is spent). | 581 | // is spent). |
internal/agent/seed/seed.go
| Old | New | ||
|---|---|---|---|
| @@ -55,6 +55,7 @@ func validateParams(p Params) error { | |||
| 55 | value string | 55 | value string |
| 56 | }{ | 56 | }{ |
| 57 | {"Hostname", p.Hostname}, | 57 | {"Hostname", p.Hostname}, |
| 58 | {"InstanceID", p.InstanceID}, | ||
| 58 | {"SSHAuthorizedKey", p.SSHAuthorizedKey}, | 59 | {"SSHAuthorizedKey", p.SSHAuthorizedKey}, |
| 59 | {"IP", p.IP}, | 60 | {"IP", p.IP}, |
| 60 | {"Gateway", p.Gateway}, | 61 | {"Gateway", p.Gateway}, |
| @@ -237,11 +238,37 @@ func Build(outPath string, p Params) error { | |||
| 237 | } | 238 | } |
| 238 | defer os.RemoveAll(workDir) | 239 | defer os.RemoveAll(workDir) |
| 239 | 240 | ||
| 240 | d, err := diskfs.Create(outPath, isoSize, isoSectorSize) | 241 | // Build into a sibling temp file and rename into place, so a Build that fails |
| 242 | // or is killed part-way (MkdirTemp, filesystem create, the write loop, or | ||
| 243 | // Finalize) can never leave a partial/empty seed.iso at the final path. The | ||
| 244 | // reconcile "exists" gate keys off rec.BootID rather than seed presence, but | ||
| 245 | // an atomic artifact keeps on-disk state honest. The deferred guard below | ||
| 246 | // closes the image (if not already closed) and drops the temp on any error | ||
| 247 | // path; on success we close explicitly before the rename so the ISO is fully | ||
| 248 | // flushed, and the guard is disarmed. | ||
| 249 | tmpPath := outPath + ".partial" | ||
| 250 | _ = os.Remove(tmpPath) | ||
| 251 | |||
| 252 | d, err := diskfs.Create(tmpPath, isoSize, isoSectorSize) | ||
| 241 | if err != nil { | 253 | if err != nil { |
| 242 | return fmt.Errorf("seed: create disk image: %w", err) | 254 | return fmt.Errorf("seed: create disk image: %w", err) |
| 243 | } | 255 | } |
| 244 | defer d.Close() | 256 | // success and closed track how far Build got. While d is still open, the |
| 257 | // guard closes it before removing the temp file; once Close has been | ||
| 258 | // attempted (success or fail), it must not be called again — the guard | ||
| 259 | // then just removes the temp file, matching the two distinct cleanups the | ||
| 260 | // manual version used at the close-failure and rename-failure sites. | ||
| 261 | success := false | ||
| 262 | closed := false | ||
| 263 | defer func() { | ||
| 264 | if success { | ||
| 265 | return | ||
| 266 | } | ||
| 267 | if !closed { | ||
| 268 | d.Close() | ||
| 269 | } | ||
| 270 | _ = os.Remove(tmpPath) | ||
| 271 | }() | ||
| 245 | 272 | ||
| 246 | fsi, err := d.CreateFilesystem(disk.FilesystemSpec{ | 273 | fsi, err := d.CreateFilesystem(disk.FilesystemSpec{ |
| 247 | Partition: 0, | 274 | Partition: 0, |
| @@ -285,5 +312,16 @@ func Build(outPath string, p Params) error { | |||
| 285 | return fmt.Errorf("seed: finalize iso: %w", err) | 312 | return fmt.Errorf("seed: finalize iso: %w", err) |
| 286 | } | 313 | } |
| 287 | 314 | ||
| 315 | closeErr := d.Close() | ||
| 316 | closed = true | ||
| 317 | if closeErr != nil { | ||
| 318 | return fmt.Errorf("seed: close iso: %w", closeErr) | ||
| 319 | } | ||
| 320 | |||
| 321 | if err := os.Rename(tmpPath, outPath); err != nil { | ||
| 322 | return fmt.Errorf("seed: rename %s -> %s: %w", tmpPath, outPath, err) | ||
| 323 | } | ||
| 324 | |||
| 325 | success = true | ||
| 288 | return nil | 326 | return nil |
| 289 | } | 327 | } |
internal/agent/state/state.go
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | type VMSpec struct { | 15 | type VMSpec struct { |
| 16 | VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string | 16 | VMID, Name, ImageURL, ImageSHA256, CloudInit, SSHAuthorizedKey string |
| 17 | VCPUs, MemMB, DiskGB int64 | 17 | VCPUs, MemMB, DiskGB int64 |
| 18 | Persistent bool | 18 | Persistent bool |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | type Record struct { | 21 | type Record struct { |
| @@ -32,8 +32,8 @@ type Record struct { | |||
| 32 | 32 | ||
| 33 | type Identity struct { | 33 | type Identity struct { |
| 34 | HostID, Credential, BridgeCIDR string | 34 | HostID, Credential, BridgeCIDR string |
| 35 | ServerQUICAddr string // host:port for QUIC dial | 35 | ServerQUICAddr string // host:port for QUIC dial |
| 36 | ServerCertSHA256 string // pinned server cert fingerprint | 36 | ServerCertSHA256 string // pinned server cert fingerprint |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | type Store struct{ dir string } | 39 | type Store struct{ dir string } |
| @@ -70,6 +70,12 @@ func (s *Store) SerialSocketPath(vmID string) string { | |||
| 70 | return filepath.Join(s.VMDir(vmID), "serial.sock") | 70 | return filepath.Join(s.VMDir(vmID), "serial.sock") |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | // SerialLogPath returns the path of the VM's on-disk serial console log, | ||
| 74 | // written by serialpump so console history survives an agent restart. | ||
| 75 | func (s *Store) SerialLogPath(vmID string) string { | ||
| 76 | return filepath.Join(s.VMDir(vmID), "serial.log") | ||
| 77 | } | ||
| 78 | |||
| 73 | // TapName returns the TAP device name for a given vmID. The name is truncated | 79 | // TapName returns the TAP device name for a given vmID. The name is truncated |
| 74 | // to the first 8 characters of the vmID, giving "eit-XXXXXXXX" (12 chars), | 80 | // to the first 8 characters of the vmID, giving "eit-XXXXXXXX" (12 chars), |
| 75 | // which is safely below the 15-char IFNAMSIZ limit. | 81 | // which is safely below the 15-char IFNAMSIZ limit. |
| @@ -161,17 +167,9 @@ func (s *Store) DeleteVM(vmID string) error { | |||
| 161 | return os.RemoveAll(s.VMDir(vmID)) | 167 | return os.RemoveAll(s.VMDir(vmID)) |
| 162 | } | 168 | } |
| 163 | 169 | ||
| 164 | // DiskExists reports whether the VM's disk image file exists on disk. | ||
| 165 | func (s *Store) DiskExists(vmID string) bool { | ||
| 166 | _, err := os.Stat(s.DiskPath(vmID)) | ||
| 167 | return err == nil | ||
| 168 | } | ||
| 169 | |||
| 170 | // epochPath returns the path to the epoch file. | 170 | // epochPath returns the path to the epoch file. |
| 171 | func (s *Store) epochPath() string { return filepath.Join(s.dir, "epoch") } | 171 | func (s *Store) epochPath() string { return filepath.Join(s.dir, "epoch") } |
| 172 | 172 | ||
| 173 | // Epoch reads the current epoch from disk, returning 0 if the file does not | ||
| 174 | // exist (fresh store). | ||
| 175 | // Epoch returns the highest epoch this agent has seen, or 0 for a fresh | 173 | // Epoch returns the highest epoch this agent has seen, or 0 for a fresh |
| 176 | // store. DELIBERATE FAIL-OPEN: a corrupt epoch file also reads as 0, which | 174 | // store. DELIBERATE FAIL-OPEN: a corrupt epoch file also reads as 0, which |
| 177 | // resets the reaping fence; the quarantine grace period (not the fence) is | 175 | // resets the reaping fence; the quarantine grace period (not the fence) is |
internal/agent/state/state_paths_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,28 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "path/filepath" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // TestStorePathHelpers pins the per-VM path derivations — including | ||
| 9 | // SerialLogPath, added for the on-disk serial console log — to their layout | ||
| 10 | // under the state root. | ||
| 11 | func TestStorePathHelpers(t *testing.T) { | ||
| 12 | s, err := Open(t.TempDir()) | ||
| 13 | if err != nil { | ||
| 14 | t.Fatalf("Open: %v", err) | ||
| 15 | } | ||
| 16 | const vm = "vm-42" | ||
| 17 | checks := []struct{ name, got, want string }{ | ||
| 18 | {"ImagesDir", s.ImagesDir(), filepath.Join(s.dir, "images")}, | ||
| 19 | {"SeedPath", s.SeedPath(vm), filepath.Join(s.VMDir(vm), "seed.iso")}, | ||
| 20 | {"SocketPath", s.SocketPath(vm), filepath.Join(s.VMDir(vm), "ch.sock")}, | ||
| 21 | {"SerialLogPath", s.SerialLogPath(vm), filepath.Join(s.VMDir(vm), "serial.log")}, | ||
| 22 | } | ||
| 23 | for _, c := range checks { | ||
| 24 | if c.got != c.want { | ||
| 25 | t.Errorf("%s = %q, want %q", c.name, c.got, c.want) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
internal/agent/state/state_test.go
| Old | New | ||
|---|---|---|---|
| @@ -62,10 +62,12 @@ func TestSerialSocketPath(t *testing.T) { | |||
| 62 | assert.Equal(t, filepath.Join(s.VMDir("vm1"), "serial.sock"), p) | 62 | assert.Equal(t, filepath.Join(s.VMDir("vm1"), "serial.sock"), p) |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | func TestDiskExistsReflectsDiskFile(t *testing.T) { | 65 | func TestDiskPathLocatesDiskFile(t *testing.T) { |
| 66 | s := open(t) | 66 | s := open(t) |
| 67 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}})) | 67 | require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}})) |
| 68 | assert.False(t, s.DiskExists("vm1"), "record without disk: VM does not 'exist' (spec)") | 68 | _, err := os.Stat(s.DiskPath("vm1")) |
| 69 | assert.True(t, os.IsNotExist(err), "record without disk: DiskPath points at a file that does not yet exist") | ||
| 69 | require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644)) | 70 | require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644)) |
| 70 | assert.True(t, s.DiskExists("vm1")) | 71 | _, err = os.Stat(s.DiskPath("vm1")) |
| 72 | assert.NoError(t, err) | ||
| 71 | } | 73 | } |
internal/agent/syncclient/client.go
| Old | New | ||
|---|---|---|---|
| @@ -34,6 +34,10 @@ func HostBootID() string { | |||
| 34 | // capacity returns the host's TOTAL capacity: total disk at stateDir, total mem, | 34 | // capacity returns the host's TOTAL capacity: total disk at stateDir, total mem, |
| 35 | // and CPU count. The server computes allocated/available by subtracting the sum | 35 | // and CPU count. The server computes allocated/available by subtracting the sum |
| 36 | // of live VM specs, so capacity must be totals (not free) for the math to cohere. | 36 | // of live VM specs, so capacity must be totals (not free) for the math to cohere. |
| 37 | // computeCapacity is the raw-capacity source, indirected through a var so tests | ||
| 38 | // can count how often the (memoized) computation actually runs. | ||
| 39 | var computeCapacity = capacity | ||
| 40 | |||
| 37 | func capacity(stateDir string) *pb.Capacity { | 41 | func capacity(stateDir string) *pb.Capacity { |
| 38 | var fs syscall.Statfs_t | 42 | var fs syscall.Statfs_t |
| 39 | var diskGB int64 | 43 | var diskGB int64 |
| @@ -67,6 +71,16 @@ type Console interface { | |||
| 67 | Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error | 71 | Attach(ctx context.Context, vmID string, rw io.ReadWriter, onReady func() error) error |
| 68 | } | 72 | } |
| 69 | 73 | ||
| 74 | // DefaultTickInterval is the fallback report/reconcile cadence when | ||
| 75 | // Client.TickInterval is zero. It is COUPLED to the server's | ||
| 76 | // registry.OnlineWindow (30s): the server marks a host offline after | ||
| 77 | // OnlineWindow of silence, so the agent must report well inside it. The invariant | ||
| 78 | // OnlineWindow >= 3*DefaultTickInterval gives a ~3-missed-report margin before a | ||
| 79 | // healthy host flaps Online/Offline; changing either value alone erodes it (see | ||
| 80 | // registry.OnlineWindow and the invariant test in this package). Independent of | ||
| 81 | // transport.SyncKeepAlivePeriod, which keeps the QUIC connection itself alive. | ||
| 82 | const DefaultTickInterval = 10 * time.Second | ||
| 83 | |||
| 70 | // Client manages the agent's QUIC sync session. | 84 | // Client manages the agent's QUIC sync session. |
| 71 | type Client struct { | 85 | type Client struct { |
| 72 | Engine *reconcile.Engine | 86 | Engine *reconcile.Engine |
| @@ -85,7 +99,7 @@ type Client struct { | |||
| 85 | 99 | ||
| 86 | // TickInterval is the period of the fallback ticker that drives a reconcile | 100 | // TickInterval is the period of the fallback ticker that drives a reconcile |
| 87 | // step even when no new snapshot has arrived (e.g. for periodic health | 101 | // step even when no new snapshot has arrived (e.g. for periodic health |
| 88 | // reports). Zero uses the default of 10 seconds. | 102 | // reports). Zero uses DefaultTickInterval (coupled to registry.OnlineWindow). |
| 89 | TickInterval time.Duration | 103 | TickInterval time.Duration |
| 90 | 104 | ||
| 91 | // ReconnectBackoff is the sleep between a transient session failure and the | 105 | // ReconnectBackoff is the sleep between a transient session failure and the |
| @@ -99,6 +113,16 @@ type Client struct { | |||
| 99 | MaxVCPUs int64 | 113 | MaxVCPUs int64 |
| 100 | MaxMemMB int64 | 114 | MaxMemMB int64 |
| 101 | MaxDiskGB int64 | 115 | MaxDiskGB int64 |
| 116 | |||
| 117 | // rawCap memoizes the machine's real capacity (vCPUs/RAM/disk). Host totals | ||
| 118 | // don't change over a session, so the Statfs+Sysinfo syscalls run once | ||
| 119 | // instead of on every report; the cheap per-report clamp still applies. Only | ||
| 120 | // a FULL reading is cached — capacity() silently zeroes a dimension whose | ||
| 121 | // syscall failed, and caching that would freeze a bad advertisement for the | ||
| 122 | // whole session, so a zeroed probe is re-tried on the next call. Guarded by | ||
| 123 | // rawCapMu (hello and report goroutines both call advertisedCapacity). | ||
| 124 | rawCapMu sync.Mutex | ||
| 125 | rawCap *pb.Capacity | ||
| 102 | } | 126 | } |
| 103 | 127 | ||
| 104 | // clampCapacity reduces each advertised dimension to its configured cap | 128 | // clampCapacity reduces each advertised dimension to its configured cap |
| @@ -120,9 +144,24 @@ func clampDim(actual, cap int64) int64 { | |||
| 120 | } | 144 | } |
| 121 | 145 | ||
| 122 | // advertisedCapacity is the machine's real capacity clamped to this agent's | 146 | // advertisedCapacity is the machine's real capacity clamped to this agent's |
| 123 | // configured caps — what the agent reports to the server. | 147 | // configured caps — what the agent reports to the server. The raw capacity is |
| 148 | // computed once (host totals are fixed for the session) and re-clamped cheaply | ||
| 149 | // on every call. | ||
| 124 | func (c *Client) advertisedCapacity(stateDir string) *pb.Capacity { | 150 | func (c *Client) advertisedCapacity(stateDir string) *pb.Capacity { |
| 125 | return clampCapacity(capacity(stateDir), c.MaxVCPUs, c.MaxMemMB, c.MaxDiskGB) | 151 | c.rawCapMu.Lock() |
| 152 | raw := c.rawCap | ||
| 153 | if raw == nil { | ||
| 154 | raw = computeCapacity(stateDir) | ||
| 155 | // Cache only a full reading. A zeroed dimension means the Statfs/Sysinfo | ||
| 156 | // probe failed; caching it would freeze a bad total for the session, so | ||
| 157 | // leave rawCap nil and re-probe next call (vCPUs is runtime.NumCPU, never | ||
| 158 | // zero, so only mem/disk gate the memoization). | ||
| 159 | if raw.GetMemMb() > 0 && raw.GetDiskGb() > 0 { | ||
| 160 | c.rawCap = raw | ||
| 161 | } | ||
| 162 | } | ||
| 163 | c.rawCapMu.Unlock() | ||
| 164 | return clampCapacity(raw, c.MaxVCPUs, c.MaxMemMB, c.MaxDiskGB) | ||
| 126 | } | 165 | } |
| 127 | 166 | ||
| 128 | // errPermanentAuth marks a credential rejection so Run() backs off long instead | 167 | // errPermanentAuth marks a credential rejection so Run() backs off long instead |
| @@ -167,8 +206,8 @@ func (c *Client) Run(ctx context.Context) { | |||
| 167 | } else { | 206 | } else { |
| 168 | failures++ | 207 | failures++ |
| 169 | if failures >= 3 { | 208 | if failures >= 3 { |
| 170 | slog.Warn("control-plane unreachable — QUIC/UDP may be blocked or filtered on this network", | 209 | slog.Warn("control-plane sync failing repeatedly — server unreachable OR wedged (accepting connections but not serving snapshots); check the server before suspecting the network", |
| 171 | "server", c.Identity.ServerQUICAddr, "consecutive_failures", failures) | 210 | "server", c.Identity.ServerQUICAddr, "consecutive_failures", failures, "last_err", err) |
| 172 | } | 211 | } |
| 173 | } | 212 | } |
| 174 | backoff := c.ReconnectBackoff | 213 | backoff := c.ReconnectBackoff |
| @@ -200,7 +239,8 @@ var errSessionConnected = errors.New("session was connected") | |||
| 200 | func (c *Client) session(ctx context.Context) error { | 239 | func (c *Client) session(ctx context.Context) error { |
| 201 | tlsConf := transport.ClientTLS(c.Identity.ServerCertSHA256) | 240 | tlsConf := transport.ClientTLS(c.Identity.ServerCertSHA256) |
| 202 | conn, err := quic.DialAddr(ctx, c.Identity.ServerQUICAddr, tlsConf, | 241 | conn, err := quic.DialAddr(ctx, c.Identity.ServerQUICAddr, tlsConf, |
| 203 | &quic.Config{KeepAlivePeriod: 15 * time.Second, MaxIdleTimeout: 30 * time.Second}) | 242 | // Shared with the server listener via transport so the two ends can't drift. |
| 243 | transport.SyncQUICConfig()) | ||
| 204 | if err != nil { | 244 | if err != nil { |
| 205 | return classifyErr(err) // transient: Run() backs off | 245 | return classifyErr(err) // transient: Run() backs off |
| 206 | } | 246 | } |
| @@ -256,6 +296,15 @@ func (c *Client) session(ctx context.Context) error { | |||
| 256 | stepSignal := make(chan struct{}, 1) | 296 | stepSignal := make(chan struct{}, 1) |
| 257 | errc := make(chan error, 2) | 297 | errc := make(chan error, 2) |
| 258 | 298 | ||
| 299 | // sessCtx is cancelled when session returns (for ANY reason), giving every | ||
| 300 | // worker goroutine a signal to exit. Most sessions end because the recv | ||
| 301 | // goroutine errored — NOT because the parent ctx was cancelled — so a worker | ||
| 302 | // that selects only on the long-lived parent ctx (plus a dead stepSignal and | ||
| 303 | // a stopped ticker) would never observe session end and would leak one | ||
| 304 | // goroutine per reconnect. | ||
| 305 | sessCtx, cancel := context.WithCancel(ctx) | ||
| 306 | defer cancel() | ||
| 307 | |||
| 259 | step := func() error { | 308 | step := func() error { |
| 260 | mu.Lock() | 309 | mu.Lock() |
| 261 | snap := latest | 310 | snap := latest |
| @@ -292,15 +341,15 @@ func (c *Client) session(ctx context.Context) error { | |||
| 292 | // Worker goroutine: owns all up-stream writes (snapshot-driven + ticker). | 341 | // Worker goroutine: owns all up-stream writes (snapshot-driven + ticker). |
| 293 | tick := c.TickInterval | 342 | tick := c.TickInterval |
| 294 | if tick == 0 { | 343 | if tick == 0 { |
| 295 | tick = 10 * time.Second | 344 | tick = DefaultTickInterval |
| 296 | } | 345 | } |
| 297 | ticker := time.NewTicker(tick) | 346 | ticker := time.NewTicker(tick) |
| 298 | defer ticker.Stop() | 347 | defer ticker.Stop() |
| 299 | go func() { | 348 | go func() { |
| 300 | for { | 349 | for { |
| 301 | select { | 350 | select { |
| 302 | case <-ctx.Done(): | 351 | case <-sessCtx.Done(): |
| 303 | errc <- ctx.Err() | 352 | errc <- sessCtx.Err() |
| 304 | return | 353 | return |
| 305 | case <-stepSignal: | 354 | case <-stepSignal: |
| 306 | if err := step(); err != nil { | 355 | if err := step(); err != nil { |
internal/agent/syncclient/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | "github.com/a73x/eitri/internal/agent/reconcile" | 12 | "github.com/a73x/eitri/internal/agent/reconcile" |
| 13 | "github.com/a73x/eitri/internal/agent/seed" | 13 | "github.com/a73x/eitri/internal/agent/seed" |
| 14 | "github.com/a73x/eitri/internal/agent/state" | 14 | "github.com/a73x/eitri/internal/agent/state" |
| 15 | "github.com/a73x/eitri/internal/pb" | ||
| 15 | "github.com/a73x/eitri/internal/server/hosttoken" | 16 | "github.com/a73x/eitri/internal/server/hosttoken" |
| 16 | "github.com/a73x/eitri/internal/server/hub" | 17 | "github.com/a73x/eitri/internal/server/hub" |
| 17 | "github.com/a73x/eitri/internal/server/registry" | 18 | "github.com/a73x/eitri/internal/server/registry" |
| @@ -42,6 +43,93 @@ func (noopNet) AllocateIP(context.Context, []string) (string, error) { | |||
| 42 | } | 43 | } |
| 43 | func (noopNet) GuestNetwork() (string, int) { return "10.77.1.1", 24 } | 44 | func (noopNet) GuestNetwork() (string, int) { return "10.77.1.1", 24 } |
| 44 | 45 | ||
| 46 | // testQUICIdle is the deliberately-short idle timeout the test listeners use so | ||
| 47 | // reconnect/drop cases don't wait out the production SyncMaxIdleTimeout. | ||
| 48 | const testQUICIdle = 5 * time.Second | ||
| 49 | |||
| 50 | // TestAdvertisedCapacityComputesOnce proves the Statfs+Sysinfo capacity probe | ||
| 51 | // runs a single time per client (host totals are fixed for the session) while | ||
| 52 | // the cheap cap clamp still applies on every call. | ||
| 53 | func TestAdvertisedCapacityComputesOnce(t *testing.T) { | ||
| 54 | orig := computeCapacity | ||
| 55 | defer func() { computeCapacity = orig }() | ||
| 56 | var calls int | ||
| 57 | computeCapacity = func(string) *pb.Capacity { | ||
| 58 | calls++ | ||
| 59 | return &pb.Capacity{Vcpus: 8, MemMb: 16384, DiskGb: 500} | ||
| 60 | } | ||
| 61 | c := &Client{MaxVCPUs: 2} // cap vCPUs to prove the clamp runs each call | ||
| 62 | for i := 0; i < 5; i++ { | ||
| 63 | got := c.advertisedCapacity("/state") | ||
| 64 | require.Equal(t, int64(2), got.GetVcpus()) | ||
| 65 | require.Equal(t, int64(16384), got.GetMemMb()) | ||
| 66 | } | ||
| 67 | require.Equal(t, 1, calls, "raw capacity syscalls must run exactly once, not per report") | ||
| 68 | } | ||
| 69 | |||
| 70 | // TestAdvertisedCapacityReprobesUntilSuccess proves a transiently-failed probe | ||
| 71 | // (capacity() silently zeroes a dimension whose syscall failed) is NOT memoized: | ||
| 72 | // the agent re-probes until it gets a full reading, then caches that — otherwise | ||
| 73 | // a boot-time blip would freeze a 0-capacity advertisement for the whole session | ||
| 74 | // where the old per-report probe self-healed within one tick. | ||
| 75 | func TestAdvertisedCapacityReprobesUntilSuccess(t *testing.T) { | ||
| 76 | orig := computeCapacity | ||
| 77 | defer func() { computeCapacity = orig }() | ||
| 78 | var calls int | ||
| 79 | computeCapacity = func(string) *pb.Capacity { | ||
| 80 | calls++ | ||
| 81 | if calls == 1 { | ||
| 82 | return &pb.Capacity{Vcpus: 8, MemMb: 0, DiskGb: 500} // transient mem probe failure | ||
| 83 | } | ||
| 84 | return &pb.Capacity{Vcpus: 8, MemMb: 16384, DiskGb: 500} | ||
| 85 | } | ||
| 86 | c := &Client{} | ||
| 87 | |||
| 88 | got1 := c.advertisedCapacity("/state") | ||
| 89 | require.Equal(t, int64(0), got1.GetMemMb(), "first (failed) probe surfaces the zero, not cached") | ||
| 90 | |||
| 91 | got2 := c.advertisedCapacity("/state") | ||
| 92 | require.Equal(t, int64(16384), got2.GetMemMb(), "must re-probe after a zeroed dimension") | ||
| 93 | require.Equal(t, 2, calls) | ||
| 94 | |||
| 95 | got3 := c.advertisedCapacity("/state") | ||
| 96 | require.Equal(t, int64(16384), got3.GetMemMb()) | ||
| 97 | require.Equal(t, 2, calls, "a full reading is memoized: no further syscalls") | ||
| 98 | } | ||
| 99 | |||
| 100 | // TestHeartbeatMarginInvariant guards the cross-package coupling documented on | ||
| 101 | // DefaultTickInterval and registry.OnlineWindow: the server must not declare a | ||
| 102 | // host offline before it has had room to miss ~3 reports, or healthy hosts flap. | ||
| 103 | func TestHeartbeatMarginInvariant(t *testing.T) { | ||
| 104 | require.GreaterOrEqual(t, registry.OnlineWindow, 3*DefaultTickInterval, | ||
| 105 | "OnlineWindow (%s) must stay >= 3x the agent report tick (%s) to avoid Online/Offline flapping", | ||
| 106 | registry.OnlineWindow, DefaultTickInterval) | ||
| 107 | } | ||
| 108 | |||
| 109 | // genTestCert returns a fresh self-signed server cert+key and its pinned | ||
| 110 | // fingerprint — the bootstrap every test QUIC server shares. | ||
| 111 | func genTestCert(t *testing.T) (certPEM, keyPEM []byte, fp string) { | ||
| 112 | t.Helper() | ||
| 113 | var err error | ||
| 114 | certPEM, keyPEM, err = transport.GenerateServerCert() | ||
| 115 | require.NoError(t, err) | ||
| 116 | fp, err = transport.CertFingerprint(certPEM) | ||
| 117 | require.NoError(t, err) | ||
| 118 | return | ||
| 119 | } | ||
| 120 | |||
| 121 | // listenTestQUIC opens a loopback QUIC listener with the shared short test idle | ||
| 122 | // timeout, from an already-generated cert+key (so a harness can re-listen on the | ||
| 123 | // same port with the same identity). | ||
| 124 | func listenTestQUIC(t *testing.T, addr string, certPEM, keyPEM []byte) *quic.Listener { | ||
| 125 | t.Helper() | ||
| 126 | tlsConf, err := transport.ServerTLS(certPEM, keyPEM) | ||
| 127 | require.NoError(t, err) | ||
| 128 | lis, err := quic.ListenAddr(addr, tlsConf, &quic.Config{MaxIdleTimeout: testQUICIdle}) | ||
| 129 | require.NoError(t, err) | ||
| 130 | return lis | ||
| 131 | } | ||
| 132 | |||
| 45 | // serverHarness owns a real syncsvc.Service on a fixed UDP loopback port so it | 133 | // serverHarness owns a real syncsvc.Service on a fixed UDP loopback port so it |
| 46 | // can be stopped and restarted (TestReconnectAfterDrop) on the same address. | 134 | // can be stopped and restarted (TestReconnectAfterDrop) on the same address. |
| 47 | type serverHarness struct { | 135 | type serverHarness struct { |
| @@ -64,10 +152,7 @@ func newServerHarness(t *testing.T) *serverHarness { | |||
| 64 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 152 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 65 | require.NoError(t, err) | 153 | require.NoError(t, err) |
| 66 | t.Cleanup(func() { st.Close() }) | 154 | t.Cleanup(func() { st.Close() }) |
| 67 | certPEM, keyPEM, err := transport.GenerateServerCert() | 155 | certPEM, keyPEM, fp := genTestCert(t) |
| 68 | require.NoError(t, err) | ||
| 69 | fp, err := transport.CertFingerprint(certPEM) | ||
| 70 | require.NoError(t, err) | ||
| 71 | h := &serverHarness{ | 156 | h := &serverHarness{ |
| 72 | t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), | 157 | t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), |
| 73 | secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp, | 158 | secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp, |
| @@ -78,16 +163,12 @@ func newServerHarness(t *testing.T) *serverHarness { | |||
| 78 | } | 163 | } |
| 79 | 164 | ||
| 80 | func (h *serverHarness) start(addr string) { | 165 | func (h *serverHarness) start(addr string) { |
| 81 | tlsConf, err := transport.ServerTLS(h.certPEM, h.keyPEM) | 166 | h.lis = listenTestQUIC(h.t, addr, h.certPEM, h.keyPEM) |
| 82 | require.NoError(h.t, err) | 167 | h.addr = h.lis.Addr().String() |
| 83 | lis, err := quic.ListenAddr(addr, tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second}) | ||
| 84 | require.NoError(h.t, err) | ||
| 85 | h.lis = lis | ||
| 86 | h.addr = lis.Addr().String() | ||
| 87 | ctx, cancel := context.WithCancel(context.Background()) | 168 | ctx, cancel := context.WithCancel(context.Background()) |
| 88 | h.cancel = cancel | 169 | h.cancel = cancel |
| 89 | h.svc = syncsvc.New(h.st, h.reg, h.hub, h.secret, 0) | 170 | h.svc = syncsvc.New(h.st, h.reg, h.hub, h.secret, 0) |
| 90 | go h.svc.Serve(ctx, lis) //nolint:errcheck | 171 | go h.svc.Serve(ctx, h.lis) //nolint:errcheck |
| 91 | } | 172 | } |
| 92 | 173 | ||
| 93 | func (h *serverHarness) stop() { | 174 | func (h *serverHarness) stop() { |
| @@ -285,25 +366,18 @@ func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness | |||
| 285 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 366 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 286 | require.NoError(t, err) | 367 | require.NoError(t, err) |
| 287 | t.Cleanup(func() { st.Close() }) | 368 | t.Cleanup(func() { st.Close() }) |
| 288 | certPEM, keyPEM, err := transport.GenerateServerCert() | 369 | certPEM, keyPEM, fp := genTestCert(t) |
| 289 | require.NoError(t, err) | ||
| 290 | fp, err := transport.CertFingerprint(certPEM) | ||
| 291 | require.NoError(t, err) | ||
| 292 | h := &serverHarness{ | 370 | h := &serverHarness{ |
| 293 | t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), | 371 | t: t, st: st, reg: registry.New(time.Now), hub: hub.New(), |
| 294 | secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp, | 372 | secret: []byte("s3cret"), certPEM: certPEM, keyPEM: keyPEM, fp: fp, |
| 295 | } | 373 | } |
| 296 | tlsConf, err := transport.ServerTLS(certPEM, keyPEM) | 374 | h.lis = listenTestQUIC(t, "127.0.0.1:0", certPEM, keyPEM) |
| 297 | require.NoError(t, err) | 375 | h.addr = h.lis.Addr().String() |
| 298 | lis, err := quic.ListenAddr("127.0.0.1:0", tlsConf, &quic.Config{MaxIdleTimeout: 5 * time.Second}) | ||
| 299 | require.NoError(t, err) | ||
| 300 | h.lis = lis | ||
| 301 | h.addr = lis.Addr().String() | ||
| 302 | ctx, cancel := context.WithCancel(context.Background()) | 376 | ctx, cancel := context.WithCancel(context.Background()) |
| 303 | h.cancel = cancel | 377 | h.cancel = cancel |
| 304 | go func() { | 378 | go func() { |
| 305 | for { | 379 | for { |
| 306 | conn, err := lis.Accept(ctx) | 380 | conn, err := h.lis.Accept(ctx) |
| 307 | if err != nil { | 381 | if err != nil { |
| 308 | return | 382 | return |
| 309 | } | 383 | } |
| @@ -313,6 +387,6 @@ func newCountingServer(t *testing.T, onAccept func(int64) int64) *serverHarness | |||
| 313 | _ = conn.CloseWithError(transport.CodeAuthRejected, "test reject") | 387 | _ = conn.CloseWithError(transport.CodeAuthRejected, "test reject") |
| 314 | } | 388 | } |
| 315 | }() | 389 | }() |
| 316 | t.Cleanup(func() { cancel(); lis.Close() }) | 390 | t.Cleanup(func() { cancel(); h.lis.Close() }) |
| 317 | return h | 391 | return h |
| 318 | } | 392 | } |
internal/agent/syncclient/leak_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,123 @@ | |||
| 1 | package syncclient | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "runtime" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/pb" | ||
| 10 | "github.com/a73x/eitri/internal/transport" | ||
| 11 | "github.com/stretchr/testify/require" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // settleGoroutines forces GC and polls until the live goroutine count drops to | ||
| 15 | // target or the deadline passes, returning the last observed count. It lets any | ||
| 16 | // per-session goroutines (recv/worker/console-accept + QUIC internals) unwind | ||
| 17 | // before we sample, so the assertion isn't racing normal teardown. | ||
| 18 | func settleGoroutines(target int, d time.Duration) int { | ||
| 19 | deadline := time.Now().Add(d) | ||
| 20 | n := runtime.NumGoroutine() | ||
| 21 | for time.Now().Before(deadline) { | ||
| 22 | runtime.GC() | ||
| 23 | n = runtime.NumGoroutine() | ||
| 24 | if n <= target { | ||
| 25 | return n | ||
| 26 | } | ||
| 27 | time.Sleep(20 * time.Millisecond) | ||
| 28 | } | ||
| 29 | return n | ||
| 30 | } | ||
| 31 | |||
| 32 | // newDropServer runs a minimal QUIC server that, for every connection, completes | ||
| 33 | // the sync handshake (reads Hello, opens the down-stream, pushes one snapshot so | ||
| 34 | // the client marks the session connected and exercises a step) and then drops | ||
| 35 | // the connection — exactly the production reconnect trigger: the session ends | ||
| 36 | // because the down-stream read errors, NOT because the parent ctx was cancelled. | ||
| 37 | // It returns the listen addr, the cert fingerprint to pin, and a stop func. | ||
| 38 | func newDropServer(t *testing.T) (addr, fp string, stop func()) { | ||
| 39 | t.Helper() | ||
| 40 | var certPEM, keyPEM []byte | ||
| 41 | certPEM, keyPEM, fp = genTestCert(t) | ||
| 42 | lis := listenTestQUIC(t, "127.0.0.1:0", certPEM, keyPEM) | ||
| 43 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 44 | go func() { | ||
| 45 | for { | ||
| 46 | conn, err := lis.Accept(ctx) | ||
| 47 | if err != nil { | ||
| 48 | return | ||
| 49 | } | ||
| 50 | go func() { | ||
| 51 | up, err := conn.AcceptStream(ctx) | ||
| 52 | if err != nil { | ||
| 53 | return | ||
| 54 | } | ||
| 55 | var first pb.AgentMessage | ||
| 56 | if err := transport.ReadMsg(up, &first, transport.DefaultMaxFrame); err != nil { | ||
| 57 | return | ||
| 58 | } | ||
| 59 | down, err := conn.OpenStreamSync(ctx) | ||
| 60 | if err != nil { | ||
| 61 | return | ||
| 62 | } | ||
| 63 | // One snapshot makes the client mark the session connected and run a | ||
| 64 | // step; then drop so the client's recv goroutine errors and the | ||
| 65 | // session ends the way a real server drop / idle timeout ends it. | ||
| 66 | _ = transport.WriteMsg(down, &pb.ServerMessage{ | ||
| 67 | Msg: &pb.ServerMessage_Snapshot{Snapshot: &pb.DesiredStateSnapshot{}}}) | ||
| 68 | time.Sleep(30 * time.Millisecond) | ||
| 69 | _ = conn.CloseWithError(0, "drop") | ||
| 70 | }() | ||
| 71 | } | ||
| 72 | }() | ||
| 73 | return lis.Addr().String(), fp, func() { cancel(); lis.Close() } | ||
| 74 | } | ||
| 75 | |||
| 76 | // TestSessionNoWorkerLeakAcrossReconnects drives many session start/stop cycles | ||
| 77 | // (each ends by dropping the server, exactly like a production reconnect/deploy) | ||
| 78 | // and asserts the live goroutine count returns to its post-warmup baseline. | ||
| 79 | // | ||
| 80 | // The regression it guards: the up-stream worker goroutine selects only on the | ||
| 81 | // long-lived parent ctx, a dead stepSignal, and a ticker that session stops on | ||
| 82 | // return — so when a session ends because the RECV goroutine errored (server | ||
| 83 | // drop / idle timeout), the worker has no signal to exit and leaks one | ||
| 84 | // goroutine per reconnect. Over long uptime these accumulate and the server-side | ||
| 85 | // sync eventually wedges. | ||
| 86 | func TestSessionNoWorkerLeakAcrossReconnects(t *testing.T) { | ||
| 87 | addr, fp, stop := newDropServer(t) | ||
| 88 | defer stop() | ||
| 89 | c := newClient(t, addr, fp, "host-x", "host-x.deadbeef") | ||
| 90 | |||
| 91 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 92 | defer cancel() | ||
| 93 | |||
| 94 | // Each session connects, gets one snapshot, then the server drops it, so | ||
| 95 | // session() returns promptly. The parent ctx is never cancelled — precisely | ||
| 96 | // the condition under which the worker goroutine cannot observe session end. | ||
| 97 | runOne := func() { | ||
| 98 | done := make(chan error, 1) | ||
| 99 | go func() { done <- c.session(ctx) }() | ||
| 100 | select { | ||
| 101 | case <-done: | ||
| 102 | case <-time.After(5 * time.Second): | ||
| 103 | t.Fatal("session did not return after server drop") | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | // Warm up to a steady goroutine state before sampling the baseline. | ||
| 108 | runOne() | ||
| 109 | runOne() | ||
| 110 | base := settleGoroutines(0, 3*time.Second) // target 0 => returns settled count | ||
| 111 | |||
| 112 | const cycles = 10 | ||
| 113 | for i := 0; i < cycles; i++ { | ||
| 114 | runOne() | ||
| 115 | } | ||
| 116 | |||
| 117 | // With the leak, each cycle strands one worker goroutine, so the count sits | ||
| 118 | // near base+cycles and never settles back. A small slack absorbs QUIC jitter. | ||
| 119 | got := settleGoroutines(base+2, 5*time.Second) | ||
| 120 | require.LessOrEqualf(t, got, base+2, | ||
| 121 | "goroutine count grew across %d reconnects (base=%d, got=%d): a worker goroutine leaks per session", | ||
| 122 | cycles, base, got) | ||
| 123 | } | ||
internal/arch/arch_test.go
| Old | New | ||
|---|---|---|---|
| @@ -205,6 +205,38 @@ func TestAPITypesIsALeaf(t *testing.T) { | |||
| 205 | } | 205 | } |
| 206 | } | 206 | } |
| 207 | 207 | ||
| 208 | // R7: internal/integration/testutil is test infrastructure only (its package | ||
| 209 | // doc says so) — it exists to support the in-process harness and the QEMU | ||
| 210 | // sandbox, not to be a dependency of anything shipped. Nothing under | ||
| 211 | // internal/server/*, internal/agent/*, or cmd/* may import | ||
| 212 | // internal/integration/* — checked transitively, so a wrapper package can't | ||
| 213 | // smuggle the dependency in either. The sanctioned exceptions are the | ||
| 214 | // test-tooling launcher binaries that ARE the integration infrastructure's | ||
| 215 | // entry points (smoketest, devstack, sandbox); the shipped binaries | ||
| 216 | // (eitri-server, eitri-agent) and everything else stay clean. | ||
| 217 | func TestProductionPlanesDoNotImportIntegrationTestInfra(t *testing.T) { | ||
| 218 | g := internalImports(t) | ||
| 219 | allowed := map[string]bool{ | ||
| 220 | module + "/cmd/eitri-smoketest": true, // drives internal/integration/harness | ||
| 221 | module + "/cmd/eitri-devstack": true, // drives internal/integration/harness | ||
| 222 | module + "/cmd/eitri-sandbox": true, // drives internal/integration/sandbox + testutil | ||
| 223 | } | ||
| 224 | for pkg := range g { | ||
| 225 | if allowed[pkg] { | ||
| 226 | continue | ||
| 227 | } | ||
| 228 | if !has(pkg, "internal/server/") && !has(pkg, "internal/agent/") && !has(pkg, "cmd/") { | ||
| 229 | continue | ||
| 230 | } | ||
| 231 | deps := transitiveDeps(g, pkg) | ||
| 232 | for d := range deps { | ||
| 233 | if has(d, "internal/integration/") { | ||
| 234 | t.Errorf("production package %s must not import test-infrastructure package %s", short(pkg), short(d)) | ||
| 235 | } | ||
| 236 | } | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 208 | // assertNotImported fails if pkg directly imports any path in forbidden. | 240 | // assertNotImported fails if pkg directly imports any path in forbidden. |
| 209 | func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) { | 241 | func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) { |
| 210 | t.Helper() | 242 | t.Helper() |
internal/cloudinit/cloudinit.go
| Old | New | ||
|---|---|---|---|
| @@ -18,13 +18,13 @@ import ( | |||
| 18 | "gopkg.in/yaml.v3" | 18 | "gopkg.in/yaml.v3" |
| 19 | ) | 19 | ) |
| 20 | 20 | ||
| 21 | // ErrNotCloudConfig reports user-data that is not a `#cloud-config` document — | 21 | // errNotCloudConfig reports user-data that is not a `#cloud-config` document — |
| 22 | // a shell script, a jinja-templated config, MIME multipart, etc. There is no | 22 | // a shell script, a jinja-templated config, MIME multipart, etc. There is no |
| 23 | // ssh_authorized_keys to merge into, so the caller must reject rather than | 23 | // ssh_authorized_keys to merge into, so the caller must reject rather than |
| 24 | // silently drop the key (the exact bug this package removes). | 24 | // silently drop the key (the exact bug this package removes). |
| 25 | var ErrNotCloudConfig = errors.New("user-data is not #cloud-config; cannot merge an ssh key into it") | 25 | var errNotCloudConfig = errors.New("user-data is not #cloud-config; cannot merge an ssh key into it") |
| 26 | 26 | ||
| 27 | // MergeSSHKey returns userData with key added to the top-level | 27 | // mergeSSHKey returns userData with key added to the top-level |
| 28 | // ssh_authorized_keys list — cloud-init's canonical way to add a key to the | 28 | // ssh_authorized_keys list — cloud-init's canonical way to add a key to the |
| 29 | // image's default user, robust whether or not the doc defines a `users:` block. | 29 | // image's default user, robust whether or not the doc defines a `users:` block. |
| 30 | // (An operator who REPLACES the default user with their own named user should | 30 | // (An operator who REPLACES the default user with their own named user should |
| @@ -44,9 +44,9 @@ var ErrNotCloudConfig = errors.New("user-data is not #cloud-config; cannot merge | |||
| 44 | // added as a YAML scalar node (not string-interpolated), so a value with | 44 | // added as a YAML scalar node (not string-interpolated), so a value with |
| 45 | // YAML-significant characters — or a bypassed multi-line value — is emitted as | 45 | // YAML-significant characters — or a bypassed multi-line value — is emitted as |
| 46 | // a quoted/block scalar and cannot inject structure. | 46 | // a quoted/block scalar and cannot inject structure. |
| 47 | func MergeSSHKey(userData, key string) (string, error) { | 47 | func mergeSSHKey(userData, key string) (string, error) { |
| 48 | if !isCloudConfig(userData) { | 48 | if !isCloudConfig(userData) { |
| 49 | return "", ErrNotCloudConfig | 49 | return "", errNotCloudConfig |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | dec := yaml.NewDecoder(strings.NewReader(userData)) | 52 | dec := yaml.NewDecoder(strings.NewReader(userData)) |
internal/cloudinit/cloudinit_test.go
| Old | New | ||
|---|---|---|---|
| @@ -36,7 +36,7 @@ func authKeys(t *testing.T, m map[string]any) []string { | |||
| 36 | 36 | ||
| 37 | func TestMergeAddsKeyWhenAbsent(t *testing.T) { | 37 | func TestMergeAddsKeyWhenAbsent(t *testing.T) { |
| 38 | in := "#cloud-config\npackages:\n - htop\n" | 38 | in := "#cloud-config\npackages:\n - htop\n" |
| 39 | out, err := MergeSSHKey(in, key) | 39 | out, err := mergeSSHKey(in, key) |
| 40 | require.NoError(t, err) | 40 | require.NoError(t, err) |
| 41 | m := parse(t, out) | 41 | m := parse(t, out) |
| 42 | assert.Equal(t, []string{key}, authKeys(t, m)) | 42 | assert.Equal(t, []string{key}, authKeys(t, m)) |
| @@ -47,14 +47,14 @@ func TestMergeAddsKeyWhenAbsent(t *testing.T) { | |||
| 47 | func TestMergeAppendsToExistingList(t *testing.T) { | 47 | func TestMergeAppendsToExistingList(t *testing.T) { |
| 48 | existing := "ssh-rsa AAAAexisting other@host" | 48 | existing := "ssh-rsa AAAAexisting other@host" |
| 49 | in := "#cloud-config\nssh_authorized_keys:\n - " + existing + "\n" | 49 | in := "#cloud-config\nssh_authorized_keys:\n - " + existing + "\n" |
| 50 | out, err := MergeSSHKey(in, key) | 50 | out, err := mergeSSHKey(in, key) |
| 51 | require.NoError(t, err) | 51 | require.NoError(t, err) |
| 52 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) | 52 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | func TestMergeIsIdempotentDedup(t *testing.T) { | 55 | func TestMergeIsIdempotentDedup(t *testing.T) { |
| 56 | in := "#cloud-config\nssh_authorized_keys:\n - " + key + "\n" | 56 | in := "#cloud-config\nssh_authorized_keys:\n - " + key + "\n" |
| 57 | out, err := MergeSSHKey(in, key) | 57 | out, err := mergeSSHKey(in, key) |
| 58 | require.NoError(t, err) | 58 | require.NoError(t, err) |
| 59 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out)), "an already-present key must not be duplicated") | 59 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out)), "an already-present key must not be duplicated") |
| 60 | } | 60 | } |
| @@ -64,38 +64,38 @@ func TestMergeNormalizesScalarKey(t *testing.T) { | |||
| 64 | // list and append rather than clobbering the user's existing key. | 64 | // list and append rather than clobbering the user's existing key. |
| 65 | existing := "ssh-rsa AAAAexisting other@host" | 65 | existing := "ssh-rsa AAAAexisting other@host" |
| 66 | in := "#cloud-config\nssh_authorized_keys: " + existing + "\n" | 66 | in := "#cloud-config\nssh_authorized_keys: " + existing + "\n" |
| 67 | out, err := MergeSSHKey(in, key) | 67 | out, err := mergeSSHKey(in, key) |
| 68 | require.NoError(t, err) | 68 | require.NoError(t, err) |
| 69 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) | 69 | assert.Equal(t, []string{existing, key}, authKeys(t, parse(t, out))) |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | func TestMergeHeaderWithLeadingBlankLines(t *testing.T) { | 72 | func TestMergeHeaderWithLeadingBlankLines(t *testing.T) { |
| 73 | in := "\n\n#cloud-config\nruncmd:\n - echo hi\n" | 73 | in := "\n\n#cloud-config\nruncmd:\n - echo hi\n" |
| 74 | out, err := MergeSSHKey(in, key) | 74 | out, err := mergeSSHKey(in, key) |
| 75 | require.NoError(t, err) | 75 | require.NoError(t, err) |
| 76 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) | 76 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) |
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | func TestMergeRejectsNonCloudConfig(t *testing.T) { | 79 | func TestMergeRejectsNonCloudConfig(t *testing.T) { |
| 80 | for _, in := range []string{ | 80 | for _, in := range []string{ |
| 81 | "#!/bin/bash\necho hi\n", // shell script user-data | 81 | "#!/bin/bash\necho hi\n", // shell script user-data |
| 82 | "## template: jinja\n#cloud-config\n", // jinja-templated (can't safely edit) | 82 | "## template: jinja\n#cloud-config\n", // jinja-templated (can't safely edit) |
| 83 | "just some text", // not user-data at all | 83 | "just some text", // not user-data at all |
| 84 | } { | 84 | } { |
| 85 | _, err := MergeSSHKey(in, key) | 85 | _, err := mergeSSHKey(in, key) |
| 86 | assert.Error(t, err, "must refuse to merge into non-cloud-config: %q", in) | 86 | assert.Error(t, err, "must refuse to merge into non-cloud-config: %q", in) |
| 87 | } | 87 | } |
| 88 | } | 88 | } |
| 89 | 89 | ||
| 90 | func TestMergeRejectsMalformedYAML(t *testing.T) { | 90 | func TestMergeRejectsMalformedYAML(t *testing.T) { |
| 91 | _, err := MergeSSHKey("#cloud-config\n bad: : : indent\n\t- x\n", key) | 91 | _, err := mergeSSHKey("#cloud-config\n bad: : : indent\n\t- x\n", key) |
| 92 | assert.Error(t, err) | 92 | assert.Error(t, err) |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | func TestMergeRejectsSSHAuthorizedKeysWrongType(t *testing.T) { | 95 | func TestMergeRejectsSSHAuthorizedKeysWrongType(t *testing.T) { |
| 96 | // A mapping where we expect a scalar/sequence — don't silently drop it. | 96 | // A mapping where we expect a scalar/sequence — don't silently drop it. |
| 97 | in := "#cloud-config\nssh_authorized_keys:\n nested: value\n" | 97 | in := "#cloud-config\nssh_authorized_keys:\n nested: value\n" |
| 98 | _, err := MergeSSHKey(in, key) | 98 | _, err := mergeSSHKey(in, key) |
| 99 | assert.Error(t, err) | 99 | assert.Error(t, err) |
| 100 | } | 100 | } |
| 101 | 101 | ||
| @@ -109,7 +109,7 @@ func TestMergePreservesScalarFidelity(t *testing.T) { | |||
| 109 | " permissions: 0644\n" + | 109 | " permissions: 0644\n" + |
| 110 | "version: 1.10\n" + | 110 | "version: 1.10\n" + |
| 111 | "stamp: 2020-01-02\n" | 111 | "stamp: 2020-01-02\n" |
| 112 | out, err := MergeSSHKey(in, key) | 112 | out, err := mergeSSHKey(in, key) |
| 113 | require.NoError(t, err) | 113 | require.NoError(t, err) |
| 114 | assert.Contains(t, out, "0644", "octal permissions must survive verbatim, not become 420") | 114 | assert.Contains(t, out, "0644", "octal permissions must survive verbatim, not become 420") |
| 115 | assert.Contains(t, out, "1.10", "trailing zero must survive") | 115 | assert.Contains(t, out, "1.10", "trailing zero must survive") |
| @@ -120,7 +120,7 @@ func TestMergePreservesScalarFidelity(t *testing.T) { | |||
| 120 | 120 | ||
| 121 | func TestMergePreservesComments(t *testing.T) { | 121 | func TestMergePreservesComments(t *testing.T) { |
| 122 | in := "#cloud-config\n# keep me\npackages:\n - htop # inline\n" | 122 | in := "#cloud-config\n# keep me\npackages:\n - htop # inline\n" |
| 123 | out, err := MergeSSHKey(in, key) | 123 | out, err := mergeSSHKey(in, key) |
| 124 | require.NoError(t, err) | 124 | require.NoError(t, err) |
| 125 | assert.Contains(t, out, "# keep me", "top-level comment must survive an in-place edit") | 125 | assert.Contains(t, out, "# keep me", "top-level comment must survive an in-place edit") |
| 126 | } | 126 | } |
| @@ -129,13 +129,13 @@ func TestMergeRejectsMultiDocYAML(t *testing.T) { | |||
| 129 | // cloud-init rejects multi-document cloud-config; accepting-and-truncating | 129 | // cloud-init rejects multi-document cloud-config; accepting-and-truncating |
| 130 | // would mask an error the guest would raise. Reject it too. | 130 | // would mask an error the guest would raise. Reject it too. |
| 131 | in := "#cloud-config\npackages: [htop]\n---\nruncmd:\n - echo hi\n" | 131 | in := "#cloud-config\npackages: [htop]\n---\nruncmd:\n - echo hi\n" |
| 132 | _, err := MergeSSHKey(in, key) | 132 | _, err := mergeSSHKey(in, key) |
| 133 | assert.Error(t, err) | 133 | assert.Error(t, err) |
| 134 | } | 134 | } |
| 135 | 135 | ||
| 136 | func TestMergeEmptyBodyGetsKey(t *testing.T) { | 136 | func TestMergeEmptyBodyGetsKey(t *testing.T) { |
| 137 | // A bare "#cloud-config" with no body is valid; the merge seeds the key. | 137 | // A bare "#cloud-config" with no body is valid; the merge seeds the key. |
| 138 | out, err := MergeSSHKey("#cloud-config\n", key) | 138 | out, err := mergeSSHKey("#cloud-config\n", key) |
| 139 | require.NoError(t, err) | 139 | require.NoError(t, err) |
| 140 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) | 140 | assert.Equal(t, []string{key}, authKeys(t, parse(t, out))) |
| 141 | } | 141 | } |
internal/cloudinit/multipart.go
| Old | New | ||
|---|---|---|---|
| @@ -13,40 +13,40 @@ import ( | |||
| 13 | "strings" | 13 | "strings" |
| 14 | ) | 14 | ) |
| 15 | 15 | ||
| 16 | // Format classifies cloud-init user-data. cloud-init picks a handler by the | 16 | // format classifies cloud-init user-data. cloud-init picks a handler by the |
| 17 | // payload's leading "magic" line (or MIME/gzip framing), so user-data is a | 17 | // payload's leading "magic" line (or MIME/gzip framing), so user-data is a |
| 18 | // tagged union, not just YAML — detection mirrors cloud-init's own sniff. | 18 | // tagged union, not just YAML — detection mirrors cloud-init's own sniff. |
| 19 | type Format int | 19 | type format int |
| 20 | 20 | ||
| 21 | const ( | 21 | const ( |
| 22 | FormatUnknown Format = iota | 22 | formatUnknown format = iota |
| 23 | FormatCloudConfig | 23 | formatCloudConfig |
| 24 | FormatShellScript | 24 | formatShellScript |
| 25 | FormatBoothook | 25 | formatBoothook |
| 26 | FormatInclude | 26 | formatInclude |
| 27 | FormatPartHandler | 27 | formatPartHandler |
| 28 | FormatMultipart // MIME multipart/mixed archive | 28 | formatMultipart // MIME multipart/mixed archive |
| 29 | FormatJinja // ## template: jinja — templated; not safe to edit or wrap blind | 29 | formatJinja // ## template: jinja — templated; not safe to edit or wrap blind |
| 30 | FormatGzip // gzip-compressed payload | 30 | formatGzip // gzip-compressed payload |
| 31 | ) | 31 | ) |
| 32 | 32 | ||
| 33 | func (f Format) String() string { | 33 | func (f format) String() string { |
| 34 | switch f { | 34 | switch f { |
| 35 | case FormatCloudConfig: | 35 | case formatCloudConfig: |
| 36 | return "cloud-config" | 36 | return "cloud-config" |
| 37 | case FormatShellScript: | 37 | case formatShellScript: |
| 38 | return "shell-script" | 38 | return "shell-script" |
| 39 | case FormatBoothook: | 39 | case formatBoothook: |
| 40 | return "cloud-boothook" | 40 | return "cloud-boothook" |
| 41 | case FormatInclude: | 41 | case formatInclude: |
| 42 | return "include" | 42 | return "include" |
| 43 | case FormatPartHandler: | 43 | case formatPartHandler: |
| 44 | return "part-handler" | 44 | return "part-handler" |
| 45 | case FormatMultipart: | 45 | case formatMultipart: |
| 46 | return "multipart" | 46 | return "multipart" |
| 47 | case FormatJinja: | 47 | case formatJinja: |
| 48 | return "jinja-template" | 48 | return "jinja-template" |
| 49 | case FormatGzip: | 49 | case formatGzip: |
| 50 | return "gzip" | 50 | return "gzip" |
| 51 | default: | 51 | default: |
| 52 | return "unknown" | 52 | return "unknown" |
| @@ -57,35 +57,35 @@ func (f Format) String() string { | |||
| 57 | // used to tell a MIME archive (starts with headers) from a #-tagged payload. | 57 | // used to tell a MIME archive (starts with headers) from a #-tagged payload. |
| 58 | var headerRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*:`) | 58 | var headerRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*:`) |
| 59 | 59 | ||
| 60 | // DetectFormat classifies user-data the way cloud-init does: gzip magic first, | 60 | // detectFormat classifies user-data the way cloud-init does: gzip magic first, |
| 61 | // then the leading non-blank line's marker (its first whitespace-delimited | 61 | // then the leading non-blank line's marker (its first whitespace-delimited |
| 62 | // token, so a trailing comment is tolerated), then — for a payload that leads | 62 | // token, so a trailing comment is tolerated), then — for a payload that leads |
| 63 | // with RFC 5322 headers rather than a #-marker — a real MIME header-block parse | 63 | // with RFC 5322 headers rather than a #-marker — a real MIME header-block parse |
| 64 | // (an archive may put MIME-Version before Content-Type). | 64 | // (an archive may put MIME-Version before Content-Type). |
| 65 | func DetectFormat(userData string) Format { | 65 | func detectFormat(userData string) format { |
| 66 | if len(userData) >= 2 && userData[0] == 0x1f && userData[1] == 0x8b { | 66 | if len(userData) >= 2 && userData[0] == 0x1f && userData[1] == 0x8b { |
| 67 | return FormatGzip | 67 | return formatGzip |
| 68 | } | 68 | } |
| 69 | first := firstNonBlankLine(userData) | 69 | first := firstNonBlankLine(userData) |
| 70 | if strings.HasPrefix(first, "## template: jinja") { | 70 | if strings.HasPrefix(first, "## template: jinja") { |
| 71 | return FormatJinja | 71 | return formatJinja |
| 72 | } | 72 | } |
| 73 | switch marker := firstMarker(userData); { | 73 | switch marker := firstMarker(userData); { |
| 74 | case marker == "#cloud-config": | 74 | case marker == "#cloud-config": |
| 75 | return FormatCloudConfig | 75 | return formatCloudConfig |
| 76 | case strings.HasPrefix(marker, "#!"): | 76 | case strings.HasPrefix(marker, "#!"): |
| 77 | return FormatShellScript | 77 | return formatShellScript |
| 78 | case marker == "#cloud-boothook": | 78 | case marker == "#cloud-boothook": |
| 79 | return FormatBoothook | 79 | return formatBoothook |
| 80 | case strings.HasPrefix(marker, "#include"): // #include and #include-once | 80 | case strings.HasPrefix(marker, "#include"): // #include and #include-once |
| 81 | return FormatInclude | 81 | return formatInclude |
| 82 | case marker == "#part-handler": | 82 | case marker == "#part-handler": |
| 83 | return FormatPartHandler | 83 | return formatPartHandler |
| 84 | } | 84 | } |
| 85 | if isMIMEMultipart(userData) { | 85 | if isMIMEMultipart(userData) { |
| 86 | return FormatMultipart | 86 | return formatMultipart |
| 87 | } | 87 | } |
| 88 | return FormatUnknown | 88 | return formatUnknown |
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | // isMIMEMultipart reports whether s is a MIME multipart archive by parsing its | 91 | // isMIMEMultipart reports whether s is a MIME multipart archive by parsing its |
| @@ -122,18 +122,18 @@ func isMIMEMultipart(s string) bool { | |||
| 122 | // generated #cloud-config, so a newline would produce a garbage key. It is | 122 | // generated #cloud-config, so a newline would produce a garbage key. It is |
| 123 | // added as a YAML scalar node, so it cannot inject structure regardless. | 123 | // added as a YAML scalar node, so it cannot inject structure regardless. |
| 124 | func AddSSHKey(userData, key string) (string, error) { | 124 | func AddSSHKey(userData, key string) (string, error) { |
| 125 | switch f := DetectFormat(userData); f { | 125 | switch f := detectFormat(userData); f { |
| 126 | case FormatCloudConfig: | 126 | case formatCloudConfig: |
| 127 | return MergeSSHKey(userData, key) | 127 | return mergeSSHKey(userData, key) |
| 128 | case FormatShellScript: | 128 | case formatShellScript: |
| 129 | return wrapMultipart([]part{typedPart("text/x-shellscript", userData), keyPart(key)}) | 129 | return wrapMultipart([]part{typedPart("text/x-shellscript", userData), keyPart(key)}) |
| 130 | case FormatBoothook: | 130 | case formatBoothook: |
| 131 | return wrapMultipart([]part{typedPart("text/cloud-boothook", userData), keyPart(key)}) | 131 | return wrapMultipart([]part{typedPart("text/cloud-boothook", userData), keyPart(key)}) |
| 132 | case FormatInclude: | 132 | case formatInclude: |
| 133 | return wrapMultipart([]part{typedPart("text/x-include-url", userData), keyPart(key)}) | 133 | return wrapMultipart([]part{typedPart("text/x-include-url", userData), keyPart(key)}) |
| 134 | case FormatPartHandler: | 134 | case formatPartHandler: |
| 135 | return wrapMultipart([]part{typedPart("text/part-handler", userData), keyPart(key)}) | 135 | return wrapMultipart([]part{typedPart("text/part-handler", userData), keyPart(key)}) |
| 136 | case FormatMultipart: | 136 | case formatMultipart: |
| 137 | return appendToMultipart(userData, key) | 137 | return appendToMultipart(userData, key) |
| 138 | default: | 138 | default: |
| 139 | return "", fmt.Errorf("cannot add an ssh key to %s user-data; include the key in the user-data itself", f) | 139 | return "", fmt.Errorf("cannot add an ssh key to %s user-data; include the key in the user-data itself", f) |
| @@ -156,9 +156,9 @@ func typedPart(contentType, body string) part { | |||
| 156 | } | 156 | } |
| 157 | 157 | ||
| 158 | // keyPart is the generated #cloud-config carrying just the ssh key, built via | 158 | // keyPart is the generated #cloud-config carrying just the ssh key, built via |
| 159 | // MergeSSHKey so the key lands in a valid ssh_authorized_keys list. | 159 | // mergeSSHKey so the key lands in a valid ssh_authorized_keys list. |
| 160 | func keyPart(key string) part { | 160 | func keyPart(key string) part { |
| 161 | doc, _ := MergeSSHKey("#cloud-config\n", key) // cannot fail on a literal #cloud-config | 161 | doc, _ := mergeSSHKey("#cloud-config\n", key) // cannot fail on a literal #cloud-config |
| 162 | return typedPart("text/cloud-config", doc) | 162 | return typedPart("text/cloud-config", doc) |
| 163 | } | 163 | } |
| 164 | 164 | ||
internal/cloudinit/multipart_test.go
| Old | New | ||
|---|---|---|---|
| @@ -15,22 +15,22 @@ import ( | |||
| 15 | func TestDetectFormat(t *testing.T) { | 15 | func TestDetectFormat(t *testing.T) { |
| 16 | cases := []struct { | 16 | cases := []struct { |
| 17 | in string | 17 | in string |
| 18 | want Format | 18 | want format |
| 19 | }{ | 19 | }{ |
| 20 | {"#cloud-config\npackages: [htop]\n", FormatCloudConfig}, | 20 | {"#cloud-config\npackages: [htop]\n", formatCloudConfig}, |
| 21 | {"\n\n#cloud-config\n", FormatCloudConfig}, // leading blank lines tolerated | 21 | {"\n\n#cloud-config\n", formatCloudConfig}, // leading blank lines tolerated |
| 22 | {"#!/bin/bash\necho hi\n", FormatShellScript}, | 22 | {"#!/bin/bash\necho hi\n", formatShellScript}, |
| 23 | {"#cloud-boothook\n#!/bin/sh\n", FormatBoothook}, | 23 | {"#cloud-boothook\n#!/bin/sh\n", formatBoothook}, |
| 24 | {"#include\nhttps://example/x\n", FormatInclude}, | 24 | {"#include\nhttps://example/x\n", formatInclude}, |
| 25 | {"#include-once\nhttps://example/x\n", FormatInclude}, | 25 | {"#include-once\nhttps://example/x\n", formatInclude}, |
| 26 | {"#part-handler\n", FormatPartHandler}, | 26 | {"#part-handler\n", formatPartHandler}, |
| 27 | {"## template: jinja\n#cloud-config\n", FormatJinja}, | 27 | {"## template: jinja\n#cloud-config\n", formatJinja}, |
| 28 | {"Content-Type: multipart/mixed; boundary=\"X\"\n\n", FormatMultipart}, | 28 | {"Content-Type: multipart/mixed; boundary=\"X\"\n\n", formatMultipart}, |
| 29 | {"\x1f\x8b\x08 gzip bytes", FormatGzip}, | 29 | {"\x1f\x8b\x08 gzip bytes", formatGzip}, |
| 30 | {"just some text", FormatUnknown}, | 30 | {"just some text", formatUnknown}, |
| 31 | } | 31 | } |
| 32 | for _, c := range cases { | 32 | for _, c := range cases { |
| 33 | assert.Equal(t, c.want, DetectFormat(c.in), "input %q", c.in) | 33 | assert.Equal(t, c.want, detectFormat(c.in), "input %q", c.in) |
| 34 | } | 34 | } |
| 35 | } | 35 | } |
| 36 | 36 | ||
| @@ -59,7 +59,7 @@ func mimeParts(t *testing.T, s string) map[string]string { | |||
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | func TestAddSSHKeyCloudConfigMerges(t *testing.T) { | 61 | func TestAddSSHKeyCloudConfigMerges(t *testing.T) { |
| 62 | // cloud-config path delegates to MergeSSHKey → a single merged document, | 62 | // cloud-config path delegates to mergeSSHKey → a single merged document, |
| 63 | // NOT a multipart wrapper. | 63 | // NOT a multipart wrapper. |
| 64 | out, err := AddSSHKey("#cloud-config\npackages:\n - htop\n", key) | 64 | out, err := AddSSHKey("#cloud-config\npackages:\n - htop\n", key) |
| 65 | require.NoError(t, err) | 65 | require.NoError(t, err) |
| @@ -125,13 +125,13 @@ func TestDetectFormatMultipartHeaderOrder(t *testing.T) { | |||
| 125 | // A valid archive may lead with MIME-Version before Content-Type; detection | 125 | // A valid archive may lead with MIME-Version before Content-Type; detection |
| 126 | // must parse the header block, not just sniff line one. | 126 | // must parse the header block, not just sniff line one. |
| 127 | in := "MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"X\"\n\n--X--\n" | 127 | in := "MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"X\"\n\n--X--\n" |
| 128 | assert.Equal(t, FormatMultipart, DetectFormat(in)) | 128 | assert.Equal(t, formatMultipart, detectFormat(in)) |
| 129 | } | 129 | } |
| 130 | 130 | ||
| 131 | func TestDetectFormatCloudConfigTrailingComment(t *testing.T) { | 131 | func TestDetectFormatCloudConfigTrailingComment(t *testing.T) { |
| 132 | // cloud-init detects #cloud-config by prefix, so a trailing comment on the | 132 | // cloud-init detects #cloud-config by prefix, so a trailing comment on the |
| 133 | // marker line is valid — don't reject it. | 133 | // marker line is valid — don't reject it. |
| 134 | assert.Equal(t, FormatCloudConfig, DetectFormat("#cloud-config # my vm\npackages: [htop]\n")) | 134 | assert.Equal(t, formatCloudConfig, detectFormat("#cloud-config # my vm\npackages: [htop]\n")) |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | func TestAppendPreservesPartHeaders(t *testing.T) { | 137 | func TestAppendPreservesPartHeaders(t *testing.T) { |
internal/random/random.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,19 @@ | |||
| 1 | // Package random provides small cryptographically-random helpers shared across | ||
| 2 | // the control plane, CLIs, and the integration harness — a leaf package so a | ||
| 3 | // CLI or test binary can reuse them without importing heavier deps (e.g. the | ||
| 4 | // sqlite-backed store). | ||
| 5 | package random | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "crypto/rand" | ||
| 9 | "encoding/hex" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Hex returns n cryptographically-random bytes encoded as hex (2n chars). | ||
| 13 | // crypto/rand.Read is documented never to fail (Go 1.24+), so its error is | ||
| 14 | // intentionally ignored. | ||
| 15 | func Hex(n int) string { | ||
| 16 | b := make([]byte, n) | ||
| 17 | rand.Read(b) //nolint:errcheck // crypto/rand.Read never returns an error | ||
| 18 | return hex.EncodeToString(b) | ||
| 19 | } | ||
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | "github.com/a73x/eitri/internal/cloudinit" | 18 | "github.com/a73x/eitri/internal/cloudinit" |
| 19 | "github.com/a73x/eitri/internal/joinblob" | 19 | "github.com/a73x/eitri/internal/joinblob" |
| 20 | "github.com/a73x/eitri/internal/names" | 20 | "github.com/a73x/eitri/internal/names" |
| 21 | "github.com/a73x/eitri/internal/random" | ||
| 21 | "github.com/a73x/eitri/internal/server/api/types" | 22 | "github.com/a73x/eitri/internal/server/api/types" |
| 22 | "github.com/a73x/eitri/internal/server/hosttoken" | 23 | "github.com/a73x/eitri/internal/server/hosttoken" |
| 23 | "github.com/a73x/eitri/internal/server/hub" | 24 | "github.com/a73x/eitri/internal/server/hub" |
| @@ -48,25 +49,37 @@ type Config struct { | |||
| 48 | 49 | ||
| 49 | // API is the HTTP handler container. | 50 | // API is the HTTP handler container. |
| 50 | type API struct { | 51 | type API struct { |
| 51 | cfg Config | 52 | cfg Config |
| 52 | st *store.Store | 53 | st *store.Store |
| 53 | reg *registry.Registry | 54 | reg *registry.Registry |
| 54 | hub *hub.Hub | 55 | hub *hub.Hub |
| 55 | notif *notifier | 56 | notif *notifier |
| 56 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) | 57 | enrolls *ipLimiter // per-client-bucket brake on the unauthenticated enroll endpoint (v4: address, v6: /64) |
| 57 | tickets *ticketStore // one-time SSE stream tickets | 58 | tickets *ticketStore // one-time SSE stream tickets |
| 59 | snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients | ||
| 58 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) | 60 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) |
| 59 | certs CertMinter // nil until main wires the SSH user CA (SetCertMinter); nil ⇒ gate off | 61 | certs CertMinter // nil until main wires the SSH user CA (SetCertMinter); nil ⇒ gate off |
| 60 | hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off | 62 | hostCerts HostCertMinter // nil until main wires the SSH host CA (SetHostCertMinter); nil ⇒ gate off |
| 61 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off | 63 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off |
| 62 | } | 64 | } |
| 63 | 65 | ||
| 64 | // New constructs an API. | 66 | // New constructs an API. It starts the central SSE snapshot hub (one goroutine |
| 67 | // that marshals the fleet snapshot on a 1s tick / desired-state wake and fans | ||
| 68 | // the identical bytes to all connected clients). Call Close to stop it. | ||
| 65 | func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API { | 69 | func New(cfg Config, st *store.Store, reg *registry.Registry, h *hub.Hub) *API { |
| 66 | return &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier(), | 70 | a := &API{cfg: cfg, st: st, reg: reg, hub: h, notif: newNotifier(), |
| 67 | enrolls: newIPLimiter(time.Now), tickets: newTicketStore(time.Now)} | 71 | enrolls: newIPLimiter(time.Now), tickets: newTicketStore(time.Now)} |
| 72 | a.snap = newSnapshotHub(a.marshalSnapshot, a.notif) | ||
| 73 | go a.snap.run() | ||
| 74 | return a | ||
| 68 | } | 75 | } |
| 69 | 76 | ||
| 77 | // Close stops the central snapshot hub goroutine. Idempotent. Tests and any | ||
| 78 | // future graceful-shutdown path should call it; the server binary itself blocks | ||
| 79 | // in ListenAndServe for the whole process lifetime and exits via os.Exit (which | ||
| 80 | // skips deferred cleanup), so the single hub goroutine simply lives until exit. | ||
| 81 | func (a *API) Close() { a.snap.Close() } | ||
| 82 | |||
| 70 | // Handler returns the ServeMux with all routes registered from the declared | 83 | // Handler returns the ServeMux with all routes registered from the declared |
| 71 | // route table (routes.go) — the table is the single enumerable surface, shared | 84 | // route table (routes.go) — the table is the single enumerable surface, shared |
| 72 | // with the OpenAPI generator. | 85 | // with the OpenAPI generator. |
| @@ -116,10 +129,10 @@ func (a *API) sweepDecommissioned() bool { | |||
| 116 | if h.Status != "decommissioning" { | 129 | if h.Status != "decommissioning" { |
| 117 | continue | 130 | continue |
| 118 | } | 131 | } |
| 119 | n, err := a.st.HostVMCount(h.ID) | 132 | // RemoveHost re-counts VM rows in-transaction and refuses while any |
| 120 | if err != nil || n > 0 { | 133 | // remain, so a pre-check here would only save a wasted call on hosts |
| 121 | continue | 134 | // that aren't yet drained — every RemoveHost error (drained or not) is |
| 122 | } | 135 | // already handled by simply skipping to the next host. |
| 123 | if a.st.RemoveHost(h.ID) == nil { | 136 | if a.st.RemoveHost(h.ID) == nil { |
| 124 | removed = true | 137 | removed = true |
| 125 | } | 138 | } |
| @@ -163,18 +176,12 @@ func writeJSON(w http.ResponseWriter, status int, v any) { | |||
| 163 | json.NewEncoder(w).Encode(v) //nolint:errcheck | 176 | json.NewEncoder(w).Encode(v) //nolint:errcheck |
| 164 | } | 177 | } |
| 165 | 178 | ||
| 166 | // httpError writes a plain-text error with the given status. Thin wrapper over | ||
| 167 | // http.Error kept for symmetry with writeJSON; the message is always explicit. | ||
| 168 | func httpError(w http.ResponseWriter, msg string, status int) { | ||
| 169 | http.Error(w, msg, status) | ||
| 170 | } | ||
| 171 | |||
| 172 | // decodeJSON decodes the request body into v, reporting a 400 with the standard | 179 | // decodeJSON decodes the request body into v, reporting a 400 with the standard |
| 173 | // "bad request" body on failure. Returns false when it has already written the | 180 | // "bad request" body on failure. Returns false when it has already written the |
| 174 | // response (caller must return). | 181 | // response (caller must return). |
| 175 | func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { | 182 | func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { |
| 176 | if err := json.NewDecoder(r.Body).Decode(v); err != nil { | 183 | if err := json.NewDecoder(r.Body).Decode(v); err != nil { |
| 177 | httpError(w, "bad request", http.StatusBadRequest) | 184 | http.Error(w, "bad request", http.StatusBadRequest) |
| 178 | return false | 185 | return false |
| 179 | } | 186 | } |
| 180 | return true | 187 | return true |
| @@ -212,7 +219,7 @@ func tokenHashPrefix(tok string) string { | |||
| 212 | 219 | ||
| 213 | func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | 220 | func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { |
| 214 | if !a.enrolls.allow(bucketKey(clientIP(r))) { | 221 | if !a.enrolls.allow(bucketKey(clientIP(r))) { |
| 215 | httpError(w, "rate limited", http.StatusTooManyRequests) | 222 | http.Error(w, "rate limited", http.StatusTooManyRequests) |
| 216 | return | 223 | return |
| 217 | } | 224 | } |
| 218 | // Unauthenticated endpoint: bound the body so junk can't bloat memory or | 225 | // Unauthenticated endpoint: bound the body so junk can't bloat memory or |
| @@ -227,7 +234,7 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | |||
| 227 | a.audit("host.enroll.denied", map[string]string{ | 234 | a.audit("host.enroll.denied", map[string]string{ |
| 228 | "remote": clientIP(r), "name": truncate(req.Name, 64), | 235 | "remote": clientIP(r), "name": truncate(req.Name, 64), |
| 229 | "token_hash_prefix": tokenHashPrefix(req.Token)}) | 236 | "token_hash_prefix": tokenHashPrefix(req.Token)}) |
| 230 | httpError(w, "forbidden", http.StatusForbidden) | 237 | http.Error(w, "forbidden", http.StatusForbidden) |
| 231 | return | 238 | return |
| 232 | } | 239 | } |
| 233 | // host.enroll is audited durably inside the redeem transaction. | 240 | // host.enroll is audited durably inside the redeem transaction. |
| @@ -243,14 +250,14 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | |||
| 243 | func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { | 250 | func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { |
| 244 | tok, err := a.st.CreateEnrollmentToken() | 251 | tok, err := a.st.CreateEnrollmentToken() |
| 245 | if err != nil { | 252 | if err != nil { |
| 246 | httpError(w, "internal error", http.StatusInternalServerError) | 253 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 247 | return | 254 | return |
| 248 | } | 255 | } |
| 249 | a.audit("enroll-token.mint", map[string]string{ | 256 | a.audit("enroll-token.mint", map[string]string{ |
| 250 | "remote": clientIP(r), "token_hash_prefix": tokenHashPrefix(tok)}) | 257 | "remote": clientIP(r), "token_hash_prefix": tokenHashPrefix(tok)}) |
| 251 | join, err := joinblob.Encode(a.cfg.AdvertiseHTTP, a.cfg.AdvertiseQUIC, tok, a.cfg.ServerCertSHA256) | 258 | join, err := joinblob.Encode(a.cfg.AdvertiseHTTP, a.cfg.AdvertiseQUIC, tok, a.cfg.ServerCertSHA256) |
| 252 | if err != nil { | 259 | if err != nil { |
| 253 | httpError(w, "internal error", http.StatusInternalServerError) | 260 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 254 | return | 261 | return |
| 255 | } | 262 | } |
| 256 | writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok, Join: join}) | 263 | writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok, Join: join}) |
| @@ -274,6 +281,16 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al | |||
| 274 | } | 281 | } |
| 275 | if ok { | 282 | if ok { |
| 276 | hr.Online = st.Online | 283 | hr.Online = st.Online |
| 284 | hr.Stale = st.Stale | ||
| 285 | hr.Sessions = st.Sessions | ||
| 286 | // LastSeen unset ⇒ connected but never reported: leave the age fields | ||
| 287 | // null rather than emit a bogus "last seen at the zero time". | ||
| 288 | if !st.LastSeen.IsZero() { | ||
| 289 | seen := st.LastSeen | ||
| 290 | secs := int64(st.SinceLastSeen.Seconds()) | ||
| 291 | hr.LastSeen = &seen | ||
| 292 | hr.SecondsSinceLastSeen = &secs | ||
| 293 | } | ||
| 277 | hr.Capacity = types.Capacity{ | 294 | hr.Capacity = types.Capacity{ |
| 278 | VCPUs: st.Capacity.VCPUs, | 295 | VCPUs: st.Capacity.VCPUs, |
| 279 | MemMB: st.Capacity.MemMB, | 296 | MemMB: st.Capacity.MemMB, |
| @@ -283,6 +300,32 @@ func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Al | |||
| 283 | return hr | 300 | return hr |
| 284 | } | 301 | } |
| 285 | 302 | ||
| 303 | // regState is one host's live registry state, fetched ONCE per snapshot. It | ||
| 304 | // exists so buildVMResponses can index a host's state instead of calling | ||
| 305 | // reg.Get(vm.HostID) per VM — registry.Get deep-clones the whole host report on | ||
| 306 | // every call, so N VMs on a host used to re-clone that report N times. | ||
| 307 | type regState struct { | ||
| 308 | st registry.HostState | ||
| 309 | ok bool | ||
| 310 | } | ||
| 311 | |||
| 312 | // fetchStates fetches each distinct host's registry state exactly once. The | ||
| 313 | // returned map covers every id passed (a missing registry entry is stored with | ||
| 314 | // ok=false), so callers can index it without falling back to reg.Get. | ||
| 315 | func (a *API) fetchStates(ids ...[]string) map[string]regState { | ||
| 316 | states := map[string]regState{} | ||
| 317 | for _, group := range ids { | ||
| 318 | for _, id := range group { | ||
| 319 | if _, seen := states[id]; seen { | ||
| 320 | continue | ||
| 321 | } | ||
| 322 | st, ok := a.reg.Get(id) | ||
| 323 | states[id] = regState{st: st, ok: ok} | ||
| 324 | } | ||
| 325 | } | ||
| 326 | return states | ||
| 327 | } | ||
| 328 | |||
| 286 | // snapshotHosts builds the wire host list (durable host rows merged with live | 329 | // snapshotHosts builds the wire host list (durable host rows merged with live |
| 287 | // registry state and server-computed allocation) for GET /hosts. | 330 | // registry state and server-computed allocation) for GET /hosts. |
| 288 | func (a *API) snapshotHosts() ([]types.Host, error) { | 331 | func (a *API) snapshotHosts() ([]types.Host, error) { |
| @@ -293,15 +336,34 @@ func (a *API) snapshotHosts() ([]types.Host, error) { | |||
| 293 | if err != nil { | 336 | if err != nil { |
| 294 | return nil, err | 337 | return nil, err |
| 295 | } | 338 | } |
| 296 | return a.buildHostResponses(hosts, alloc), nil | 339 | return a.buildHostResponses(hosts, alloc, a.fetchStates(hostIDs(hosts))), nil |
| 340 | } | ||
| 341 | |||
| 342 | // hostIDs projects the host row IDs (registry keys) for a single-fetch states map. | ||
| 343 | func hostIDs(hosts []store.Host) []string { | ||
| 344 | ids := make([]string, len(hosts)) | ||
| 345 | for i, h := range hosts { | ||
| 346 | ids[i] = h.ID | ||
| 347 | } | ||
| 348 | return ids | ||
| 297 | } | 349 | } |
| 298 | 350 | ||
| 299 | // buildHostResponses merges durable host rows with live registry state. | 351 | // vmHostIDs projects the host IDs referenced by a VM list (registry keys). |
| 300 | func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Alloc) []types.Host { | 352 | func vmHostIDs(vms []store.VM) []string { |
| 353 | ids := make([]string, len(vms)) | ||
| 354 | for i, vm := range vms { | ||
| 355 | ids[i] = vm.HostID | ||
| 356 | } | ||
| 357 | return ids | ||
| 358 | } | ||
| 359 | |||
| 360 | // buildHostResponses merges durable host rows with live registry state, indexing | ||
| 361 | // the pre-fetched states map (one reg.Get per host). | ||
| 362 | func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Alloc, states map[string]regState) []types.Host { | ||
| 301 | out := make([]types.Host, len(hosts)) | 363 | out := make([]types.Host, len(hosts)) |
| 302 | for i, h := range hosts { | 364 | for i, h := range hosts { |
| 303 | st, ok := a.reg.Get(h.ID) | 365 | rs := states[h.ID] |
| 304 | out[i] = toHostResponse(h, st, ok, alloc[h.ID]) | 366 | out[i] = toHostResponse(h, rs.st, rs.ok, alloc[h.ID]) |
| 305 | } | 367 | } |
| 306 | return out | 368 | return out |
| 307 | } | 369 | } |
| @@ -309,7 +371,7 @@ func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Allo | |||
| 309 | func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { | 371 | func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { |
| 310 | out, err := a.snapshotHosts() | 372 | out, err := a.snapshotHosts() |
| 311 | if err != nil { | 373 | if err != nil { |
| 312 | httpError(w, "internal error", http.StatusInternalServerError) | 374 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 313 | return | 375 | return |
| 314 | } | 376 | } |
| 315 | writeJSON(w, http.StatusOK, out) | 377 | writeJSON(w, http.StatusOK, out) |
| @@ -343,9 +405,11 @@ func toVMResponse(vm store.VM, actualPower, phase string, destroyAt int64) types | |||
| 343 | } | 405 | } |
| 344 | 406 | ||
| 345 | // deriveLifecycle folds the orthogonal state axes into one coarse lifecycle | 407 | // deriveLifecycle folds the orthogonal state axes into one coarse lifecycle |
| 346 | // word. This MUST stay in lockstep with vmStatus() in | 408 | // word. The server owns this derivation exclusively — every types.VM ships |
| 347 | // web/src/lib/fleet.svelte.ts — the client fold is retained so the UI degrades | 409 | // the result as vm.lifecycle, and the client (vmStatus() in |
| 348 | // gracefully against an older server, and the two must not disagree. | 410 | // web/src/lib/fleet.svelte.ts) displays it directly, keeping only a small |
| 411 | // defensive fallback for malformed or missing snapshots rather than | ||
| 412 | // re-deriving the value itself. | ||
| 349 | func deriveLifecycle(vm store.VM, actualPower, phase string) string { | 413 | func deriveLifecycle(vm store.VM, actualPower, phase string) string { |
| 350 | if vm.DeletedAt != nil { | 414 | if vm.DeletedAt != nil { |
| 351 | return "deleting" | 415 | return "deleting" |
| @@ -379,17 +443,18 @@ func (a *API) snapshotVMs() ([]types.VM, error) { | |||
| 379 | if err != nil { | 443 | if err != nil { |
| 380 | return nil, err | 444 | return nil, err |
| 381 | } | 445 | } |
| 382 | return a.buildVMResponses(vms), nil | 446 | return a.buildVMResponses(vms, a.fetchStates(vmHostIDs(vms))), nil |
| 383 | } | 447 | } |
| 384 | 448 | ||
| 385 | // buildVMResponses merges durable VM rows with live registry actual-state. | 449 | // buildVMResponses merges durable VM rows with live registry actual-state, |
| 386 | func (a *API) buildVMResponses(vms []store.VM) []types.VM { | 450 | // indexing the pre-fetched states map (one reg.Get per host, not per VM). |
| 451 | func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []types.VM { | ||
| 387 | out := make([]types.VM, len(vms)) | 452 | out := make([]types.VM, len(vms)) |
| 388 | for i, vm := range vms { | 453 | for i, vm := range vms { |
| 389 | var actualPower, phase string | 454 | var actualPower, phase string |
| 390 | var destroyAt int64 | 455 | var destroyAt int64 |
| 391 | if st, ok := a.reg.Get(vm.HostID); ok { | 456 | if rs := states[vm.HostID]; rs.ok { |
| 392 | for _, av := range st.Report.VMs { | 457 | for _, av := range rs.st.Report.VMs { |
| 393 | if av.VMID == vm.ID { | 458 | if av.VMID == vm.ID { |
| 394 | actualPower = av.Power | 459 | actualPower = av.Power |
| 395 | phase = av.Phase | 460 | phase = av.Phase |
| @@ -398,7 +463,7 @@ func (a *API) buildVMResponses(vms []store.VM) []types.VM { | |||
| 398 | } | 463 | } |
| 399 | // A tombstoned VM the agent has stopped and quarantined carries a | 464 | // A tombstoned VM the agent has stopped and quarantined carries a |
| 400 | // hard destroy deadline; surface it so clients can render a countdown. | 465 | // hard destroy deadline; surface it so clients can render a countdown. |
| 401 | for _, qv := range st.Report.Quarantined { | 466 | for _, qv := range rs.st.Report.Quarantined { |
| 402 | if qv.VMID == vm.ID { | 467 | if qv.VMID == vm.ID { |
| 403 | destroyAt = qv.DestroyAtUnix | 468 | destroyAt = qv.DestroyAtUnix |
| 404 | break | 469 | break |
| @@ -413,7 +478,7 @@ func (a *API) buildVMResponses(vms []store.VM) []types.VM { | |||
| 413 | func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { | 478 | func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { |
| 414 | out, err := a.snapshotVMs() | 479 | out, err := a.snapshotVMs() |
| 415 | if err != nil { | 480 | if err != nil { |
| 416 | httpError(w, "internal error", http.StatusInternalServerError) | 481 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 417 | return | 482 | return |
| 418 | } | 483 | } |
| 419 | writeJSON(w, http.StatusOK, out) | 484 | writeJSON(w, http.StatusOK, out) |
| @@ -424,7 +489,7 @@ func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { | |||
| 424 | // validation, not a default. | 489 | // validation, not a default. |
| 425 | func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) { | 490 | func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) { |
| 426 | if req.Name == "" { | 491 | if req.Name == "" { |
| 427 | req.Name = "sandbox-" + store.RandHex(3) | 492 | req.Name = "sandbox-" + random.Hex(3) |
| 428 | } | 493 | } |
| 429 | // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty. | 494 | // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty. |
| 430 | if req.ImageURL == "" && req.ImageSHA256 == "" { | 495 | if req.ImageURL == "" && req.ImageSHA256 == "" { |
| @@ -482,16 +547,16 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 482 | return | 547 | return |
| 483 | } | 548 | } |
| 484 | if req.HostID == "" { | 549 | if req.HostID == "" { |
| 485 | httpError(w, "host_id required", http.StatusBadRequest) | 550 | http.Error(w, "host_id required", http.StatusBadRequest) |
| 486 | return | 551 | return |
| 487 | } | 552 | } |
| 488 | 553 | ||
| 489 | if msg, code := a.applyVMDefaults(&req); msg != "" { | 554 | if msg, code := a.applyVMDefaults(&req); msg != "" { |
| 490 | httpError(w, msg, code) | 555 | http.Error(w, msg, code) |
| 491 | return | 556 | return |
| 492 | } | 557 | } |
| 493 | if msg, code := validateCreateVM(&req); msg != "" { | 558 | if msg, code := validateCreateVM(&req); msg != "" { |
| 494 | httpError(w, msg, code) | 559 | http.Error(w, msg, code) |
| 495 | return | 560 | return |
| 496 | } | 561 | } |
| 497 | 562 | ||
| @@ -514,7 +579,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 514 | if req.CloudInit != "" && req.SSHAuthorizedKey != "" { | 579 | if req.CloudInit != "" && req.SSHAuthorizedKey != "" { |
| 515 | merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) | 580 | merged, err := cloudinit.AddSSHKey(req.CloudInit, req.SSHAuthorizedKey) |
| 516 | if err != nil { | 581 | if err != nil { |
| 517 | httpError(w, "cannot add ssh_authorized_key to cloud_init: "+err.Error(), http.StatusBadRequest) | 582 | http.Error(w, "cannot add ssh_authorized_key to cloud_init: "+err.Error(), http.StatusBadRequest) |
| 518 | return | 583 | return |
| 519 | } | 584 | } |
| 520 | req.CloudInit = merged | 585 | req.CloudInit = merged |
| @@ -522,7 +587,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 522 | } | 587 | } |
| 523 | 588 | ||
| 524 | // Generate ID here so we can return it. | 589 | // Generate ID here so we can return it. |
| 525 | id := store.RandHex(16) | 590 | id := random.Hex(16) |
| 526 | 591 | ||
| 527 | vm := store.VM{ | 592 | vm := store.VM{ |
| 528 | ID: id, | 593 | ID: id, |
| @@ -547,7 +612,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 547 | if a.hostCerts != nil { | 612 | if a.hostCerts != nil { |
| 548 | keyPEM, cert, err := a.hostCerts.MintHostCert(req.Name) | 613 | keyPEM, cert, err := a.hostCerts.MintHostCert(req.Name) |
| 549 | if err != nil { | 614 | if err != nil { |
| 550 | httpError(w, "internal error", http.StatusInternalServerError) | 615 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 551 | return | 616 | return |
| 552 | } | 617 | } |
| 553 | vm.SSHHostKey = keyPEM | 618 | vm.SSHHostKey = keyPEM |
| @@ -557,11 +622,13 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 557 | if err := a.st.CreateVM(vm); err != nil { | 622 | if err := a.st.CreateVM(vm); err != nil { |
| 558 | switch { | 623 | switch { |
| 559 | case errors.Is(err, store.ErrNameTaken): | 624 | case errors.Is(err, store.ErrNameTaken): |
| 560 | httpError(w, "name already in use", http.StatusConflict) | 625 | http.Error(w, "name already in use", http.StatusConflict) |
| 561 | case errors.Is(err, store.ErrHostNotFound): | 626 | case errors.Is(err, store.ErrHostNotFound): |
| 562 | httpError(w, "unknown host_id", http.StatusBadRequest) | 627 | http.Error(w, "unknown host_id", http.StatusBadRequest) |
| 628 | case errors.Is(err, store.ErrHostNotEnrolled): | ||
| 629 | http.Error(w, "host is not accepting new VMs", http.StatusConflict) | ||
| 563 | default: | 630 | default: |
| 564 | httpError(w, "internal error", http.StatusInternalServerError) | 631 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 565 | } | 632 | } |
| 566 | return | 633 | return |
| 567 | } | 634 | } |
| @@ -572,50 +639,57 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 572 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) | 639 | writeJSON(w, http.StatusCreated, types.CreateVMResponse{ID: id, Name: req.Name}) |
| 573 | } | 640 | } |
| 574 | 641 | ||
| 575 | func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) { | 642 | // mutateVM implements the choreography shared by every VM mutation endpoint |
| 576 | id := r.PathValue("id") | 643 | // (patch/delete/restore): run mutate; a sql.ErrNoRows becomes the caller's |
| 577 | var req types.PatchVMRequest | 644 | // not-found response, any other error a generic 500. On success the row |
| 578 | if !decodeJSON(w, r, &req) { | 645 | // always survives the mutation (power change, tombstone, and restore all |
| 579 | return | 646 | // keep it), so it is scanned for name/host: a best-effort audit row is |
| 580 | } | 647 | // appended and the host's desired-state stream poked (a lookup miss is |
| 581 | if req.PowerState != "running" && req.PowerState != "stopped" { | 648 | // non-fatal — the mutation already succeeded). Finally SSE watchers are |
| 582 | httpError(w, "power_state must be running or stopped", http.StatusBadRequest) | 649 | // notified and the response is written as 204, unconditionally. |
| 583 | return | 650 | func (a *API) mutateVM(w http.ResponseWriter, id string, mutate func(id string) error, |
| 584 | } | 651 | notFoundMsg string, notFoundStatus int, |
| 585 | if err := a.st.SetVMPower(id, req.PowerState); err != nil { | 652 | auditAction string, auditDetail func(vm store.VM) map[string]string) { |
| 586 | if err == sql.ErrNoRows { | 653 | if err := mutate(id); err != nil { |
| 587 | httpError(w, "not found", http.StatusNotFound) | 654 | if errors.Is(err, sql.ErrNoRows) { |
| 655 | http.Error(w, notFoundMsg, notFoundStatus) | ||
| 588 | return | 656 | return |
| 589 | } | 657 | } |
| 590 | httpError(w, "internal error", http.StatusInternalServerError) | 658 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 591 | return | 659 | return |
| 592 | } | 660 | } |
| 593 | // The row survives a power change, so we can still scan it for name/host. | ||
| 594 | if vm, ok := a.vmByID(id); ok { | 661 | if vm, ok := a.vmByID(id); ok { |
| 595 | a.audit("vm.power", map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState}) | 662 | a.audit(auditAction, auditDetail(vm)) |
| 596 | a.hub.Poke(vm.HostID) | 663 | a.hub.Poke(vm.HostID) |
| 597 | } | 664 | } |
| 598 | a.notif.notify() | 665 | a.notif.notify() |
| 599 | w.WriteHeader(http.StatusNoContent) | 666 | w.WriteHeader(http.StatusNoContent) |
| 600 | } | 667 | } |
| 601 | 668 | ||
| 602 | func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | 669 | func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) { |
| 603 | id := r.PathValue("id") | 670 | id := r.PathValue("id") |
| 604 | if err := a.st.TombstoneVM(id); err != nil { | 671 | var req types.PatchVMRequest |
| 605 | if err == sql.ErrNoRows { | 672 | if !decodeJSON(w, r, &req) { |
| 606 | httpError(w, "not found", http.StatusNotFound) | ||
| 607 | return | ||
| 608 | } | ||
| 609 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 610 | return | 673 | return |
| 611 | } | 674 | } |
| 612 | // TombstoneVM keeps the row, so we can still scan for name/host. | 675 | if req.PowerState != "running" && req.PowerState != "stopped" { |
| 613 | if vm, ok := a.vmByID(id); ok { | 676 | http.Error(w, "power_state must be running or stopped", http.StatusBadRequest) |
| 614 | a.audit("vm.delete", map[string]string{"vm_id": id, "name": vm.Name}) | 677 | return |
| 615 | a.hub.Poke(vm.HostID) | ||
| 616 | } | 678 | } |
| 617 | a.notif.notify() | 679 | a.mutateVM(w, id, func(id string) error { return a.st.SetVMPower(id, req.PowerState) }, |
| 618 | w.WriteHeader(http.StatusNoContent) | 680 | "not found", http.StatusNotFound, |
| 681 | "vm.power", func(vm store.VM) map[string]string { | ||
| 682 | return map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState} | ||
| 683 | }) | ||
| 684 | } | ||
| 685 | |||
| 686 | func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | ||
| 687 | id := r.PathValue("id") | ||
| 688 | a.mutateVM(w, id, a.st.TombstoneVM, | ||
| 689 | "not found", http.StatusNotFound, | ||
| 690 | "vm.delete", func(vm store.VM) map[string]string { | ||
| 691 | return map[string]string{"vm_id": id, "name": vm.Name} | ||
| 692 | }) | ||
| 619 | } | 693 | } |
| 620 | 694 | ||
| 621 | // handleRestoreVM un-tombstones a VM that is still within the teardown grace | 695 | // handleRestoreVM un-tombstones a VM that is still within the teardown grace |
| @@ -624,35 +698,20 @@ func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | |||
| 624 | // converges it back toward its power_state — so the server only pokes. | 698 | // converges it back toward its power_state — so the server only pokes. |
| 625 | func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { | 699 | func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { |
| 626 | id := r.PathValue("id") | 700 | id := r.PathValue("id") |
| 627 | if err := a.st.RestoreVM(id); err != nil { | 701 | a.mutateVM(w, id, a.st.RestoreVM, |
| 628 | if errors.Is(err, sql.ErrNoRows) { | 702 | "vm not restorable (already destroyed or not deleted)", http.StatusConflict, |
| 629 | httpError(w, "vm not restorable (already destroyed or not deleted)", http.StatusConflict) | 703 | "vm.restore", func(vm store.VM) map[string]string { |
| 630 | return | 704 | return map[string]string{"vm_id": id, "name": vm.Name} |
| 631 | } | 705 | }) |
| 632 | httpError(w, "internal error", http.StatusInternalServerError) | ||
| 633 | return | ||
| 634 | } | ||
| 635 | // RestoreVM clears deleted_at, so the row is back in desired state; scan it | ||
| 636 | // for name/host. A lookup miss is non-fatal — the restore already succeeded. | ||
| 637 | if vm, ok := a.vmByID(id); ok { | ||
| 638 | a.audit("vm.restore", map[string]string{"vm_id": id, "name": vm.Name}) | ||
| 639 | a.hub.Poke(vm.HostID) | ||
| 640 | } | ||
| 641 | a.notif.notify() | ||
| 642 | w.WriteHeader(http.StatusNoContent) | ||
| 643 | } | 706 | } |
| 644 | 707 | ||
| 645 | // vmByID scans ListVMs for the desired-state VM row with the given id. | 708 | // vmByID fetches the VM row with the given id via an indexed primary-key |
| 646 | // Phase 1 scale: linear scan is acceptable (callers need name + host_id). | 709 | // lookup (callers need name + host_id). Any error — including a missing row — |
| 710 | // reports ok=false, matching the callers' non-fatal not-found handling. | ||
| 647 | func (a *API) vmByID(vmID string) (store.VM, bool) { | 711 | func (a *API) vmByID(vmID string) (store.VM, bool) { |
| 648 | vms, err := a.st.ListVMs() | 712 | vm, err := a.st.GetVM(vmID) |
| 649 | if err != nil { | 713 | if err != nil { |
| 650 | return store.VM{}, false | 714 | return store.VM{}, false |
| 651 | } | 715 | } |
| 652 | for _, vm := range vms { | 716 | return vm, true |
| 653 | if vm.ID == vmID { | ||
| 654 | return vm, true | ||
| 655 | } | ||
| 656 | } | ||
| 657 | return store.VM{}, false | ||
| 658 | } | 717 | } |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -131,6 +131,52 @@ func TestCreateVMUnknownHostReturns400(t *testing.T) { | |||
| 131 | assert.Equal(t, 400, resp.StatusCode) | 131 | assert.Equal(t, 400, resp.StatusCode) |
| 132 | } | 132 | } |
| 133 | 133 | ||
| 134 | // TestHostResponseSurfacesSyncHealth pins the sync-health mapping in | ||
| 135 | // toHostResponse: a live host surfaces last_seen, seconds_since_last_seen, | ||
| 136 | // stale and sessions; a host with no registry entry (never connected) and a | ||
| 137 | // host that connected but never reported both leave the age fields null. | ||
| 138 | func TestHostResponseSurfacesSyncHealth(t *testing.T) { | ||
| 139 | h := store.Host{ID: "h1", Name: "host-1"} | ||
| 140 | |||
| 141 | t.Run("online and stale", func(t *testing.T) { | ||
| 142 | st := registry.HostState{ | ||
| 143 | LastSeen: time.Unix(1700000000, 0), | ||
| 144 | SinceLastSeen: 20 * time.Second, | ||
| 145 | Online: true, | ||
| 146 | Stale: true, | ||
| 147 | Sessions: 3, | ||
| 148 | } | ||
| 149 | hr := toHostResponse(h, st, true, store.Alloc{}) | ||
| 150 | require.NotNil(t, hr.LastSeen) | ||
| 151 | assert.True(t, hr.LastSeen.Equal(time.Unix(1700000000, 0))) | ||
| 152 | require.NotNil(t, hr.SecondsSinceLastSeen) | ||
| 153 | assert.Equal(t, int64(20), *hr.SecondsSinceLastSeen) | ||
| 154 | assert.True(t, hr.Online) | ||
| 155 | assert.True(t, hr.Stale, "past half the online window ⇒ stale") | ||
| 156 | assert.Equal(t, 3, hr.Sessions) | ||
| 157 | }) | ||
| 158 | |||
| 159 | t.Run("connected but never reported", func(t *testing.T) { | ||
| 160 | // RecordConnect created an entry (ok=true, Sessions>0) but LastSeen is | ||
| 161 | // unset, so the age fields must stay null rather than emit the zero time. | ||
| 162 | st := registry.HostState{Sessions: 1} | ||
| 163 | hr := toHostResponse(h, st, true, store.Alloc{}) | ||
| 164 | assert.Nil(t, hr.LastSeen) | ||
| 165 | assert.Nil(t, hr.SecondsSinceLastSeen) | ||
| 166 | assert.False(t, hr.Online) | ||
| 167 | assert.Equal(t, 1, hr.Sessions) | ||
| 168 | }) | ||
| 169 | |||
| 170 | t.Run("no registry entry", func(t *testing.T) { | ||
| 171 | hr := toHostResponse(h, registry.HostState{}, false, store.Alloc{}) | ||
| 172 | assert.Nil(t, hr.LastSeen) | ||
| 173 | assert.Nil(t, hr.SecondsSinceLastSeen) | ||
| 174 | assert.False(t, hr.Online) | ||
| 175 | assert.False(t, hr.Stale) | ||
| 176 | assert.Equal(t, 0, hr.Sessions) | ||
| 177 | }) | ||
| 178 | } | ||
| 179 | |||
| 134 | // newServer is the shared builder. It also returns the *API itself for tests | 180 | // newServer is the shared builder. It also returns the *API itself for tests |
| 135 | // that need post-construction wiring (SetConsoleDialer, SetCertMinter). | 181 | // that need post-construction wiring (SetConsoleDialer, SetCertMinter). |
| 136 | func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) { | 182 | func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) { |
| @@ -152,6 +198,7 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr | |||
| 152 | }, st, reg, h) | 198 | }, st, reg, h) |
| 153 | ts := httptest.NewServer(a.Handler()) | 199 | ts := httptest.NewServer(a.Handler()) |
| 154 | t.Cleanup(ts.Close) | 200 | t.Cleanup(ts.Close) |
| 201 | t.Cleanup(a.Close) // stop the snapshot hub goroutine | ||
| 155 | return ts, st, h, reg, a | 202 | return ts, st, h, reg, a |
| 156 | } | 203 | } |
| 157 | 204 | ||
internal/server/api/console.go
| Old | New | ||
|---|---|---|---|
| @@ -32,17 +32,19 @@ const consoleOpenTimeout = 10 * time.Second | |||
| 32 | // the admin token never appears in a URL. | 32 | // the admin token never appears in a URL. |
| 33 | func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { | 33 | func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { |
| 34 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { | 34 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { |
| 35 | httpError(w, "unauthorized", http.StatusUnauthorized) | 35 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 36 | return | 36 | return |
| 37 | } | 37 | } |
| 38 | if a.console == nil { | 38 | if a.console == nil { |
| 39 | httpError(w, "console unavailable", http.StatusServiceUnavailable) | 39 | http.Error(w, "console unavailable", http.StatusServiceUnavailable) |
| 40 | return | 40 | return |
| 41 | } | 41 | } |
| 42 | id := r.PathValue("id") | 42 | id := r.PathValue("id") |
| 43 | vm, ok := a.vmByID(id) | 43 | vm, ok := a.vmByID(id) |
| 44 | if !ok { | 44 | if !ok || vm.DeletedAt != nil { |
| 45 | httpError(w, "not found", http.StatusNotFound) | 45 | // GetVM does not filter tombstones; a console to a VM being decommissioned |
| 46 | // or deleted must not open (the agent leg would refuse it anyway). | ||
| 47 | http.Error(w, "not found", http.StatusNotFound) | ||
| 46 | return | 48 | return |
| 47 | } | 49 | } |
| 48 | 50 | ||
internal/server/api/console_test.go
| Old | New | ||
|---|---|---|---|
| @@ -93,6 +93,21 @@ func TestConsoleWSRequiresTicket(t *testing.T) { | |||
| 93 | assert.Equal(t, 401, resp.StatusCode) | 93 | assert.Equal(t, 401, resp.StatusCode) |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | func TestConsoleWSRejectsDeletedVM(t *testing.T) { | ||
| 97 | a, fix := newConsoleAPI(t) | ||
| 98 | a.SetConsoleDialer(&fakeConsole{}) | ||
| 99 | |||
| 100 | // Tombstone the VM (GetVM does not filter tombstones). | ||
| 101 | resp := do(t, "DELETE", fix.ts.URL+"/api/v1/vms/"+fix.vmID, "admintok", nil) | ||
| 102 | require.Equal(t, 204, resp.StatusCode) | ||
| 103 | |||
| 104 | ticket := mintTicket(t, fix.ts.URL, "admintok") | ||
| 105 | r, err := fix.ts.Client().Get(fix.ts.URL + "/api/v1/vms/" + fix.vmID + "/console/ws?ticket=" + ticket) | ||
| 106 | require.NoError(t, err) | ||
| 107 | defer r.Body.Close() | ||
| 108 | assert.Equal(t, 404, r.StatusCode, "console to a tombstoned VM must be rejected") | ||
| 109 | } | ||
| 110 | |||
| 96 | func TestConsoleWSBridgesBytes(t *testing.T) { | 111 | func TestConsoleWSBridgesBytes(t *testing.T) { |
| 97 | a, fix := newConsoleAPI(t) | 112 | a, fix := newConsoleAPI(t) |
| 98 | fc := &fakeConsole{opened: make(chan struct{}, 1)} | 113 | fc := &fakeConsole{opened: make(chan struct{}, 1)} |
internal/server/api/decommission_poke_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,66 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // TestDecommissionPokesAgent pins the fix for the decommission stall: the | ||
| 13 | // handler must wake the host's desired-state stream (like every VM mutation | ||
| 14 | // handler does), otherwise an online host never learns its VMs were tombstoned | ||
| 15 | // and stalls in `decommissioning` forever. | ||
| 16 | func TestDecommissionPokesAgent(t *testing.T) { | ||
| 17 | ts, a, _ := apiServer(t) | ||
| 18 | out := enroll(t, ts) | ||
| 19 | hostID := out["host_id"] | ||
| 20 | |||
| 21 | ch, cancel := a.hub.Subscribe(hostID) | ||
| 22 | defer cancel() | ||
| 23 | |||
| 24 | resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) | ||
| 25 | require.Equal(t, http.StatusAccepted, resp.StatusCode) | ||
| 26 | |||
| 27 | select { | ||
| 28 | case <-ch: | ||
| 29 | case <-time.After(2 * time.Second): | ||
| 30 | t.Fatal("decommission must poke the host's desired-state stream") | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | // TestForceDecommissionRemovesHostWithVMs pins the dead-hardware escape hatch: | ||
| 35 | // ?force=true purges VM rows and removes the host immediately, without waiting | ||
| 36 | // for an agent drain that (for dead hardware) can never happen. | ||
| 37 | func TestForceDecommissionRemovesHostWithVMs(t *testing.T) { | ||
| 38 | ts, _, _ := apiServer(t) | ||
| 39 | out := enroll(t, ts) | ||
| 40 | hostID := out["host_id"] | ||
| 41 | |||
| 42 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-a"}) | ||
| 43 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 44 | |||
| 45 | // Graceful delete would only tombstone and wait; force removes now. | ||
| 46 | resp = do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", "admintok", nil) | ||
| 47 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 48 | |||
| 49 | hosts := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/hosts", "admintok", nil)) | ||
| 50 | assert.Empty(t, hosts, "force must remove the host immediately") | ||
| 51 | } | ||
| 52 | |||
| 53 | // TestCreateVMRejectedOnDecommissioningHost pins that a host mid-decommission | ||
| 54 | // no longer accepts new VMs (previously only the FK was enforced, so a create | ||
| 55 | // could land on a host being torn down). | ||
| 56 | func TestCreateVMRejectedOnDecommissioningHost(t *testing.T) { | ||
| 57 | ts, _, _ := apiServer(t) | ||
| 58 | out := enroll(t, ts) | ||
| 59 | hostID := out["host_id"] | ||
| 60 | |||
| 61 | resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID, "admintok", nil) | ||
| 62 | require.Equal(t, http.StatusAccepted, resp.StatusCode) | ||
| 63 | |||
| 64 | resp = do(t, "POST", ts.URL+"/api/v1/vms", "admintok", map[string]any{"host_id": hostID, "name": "vm-late"}) | ||
| 65 | assert.Equal(t, http.StatusConflict, resp.StatusCode, "create on a decommissioning host must be rejected") | ||
| 66 | } | ||
internal/server/api/events.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,6 @@ | |||
| 1 | package api | 1 | package api |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | ||
| 5 | "database/sql" | 4 | "database/sql" |
| 6 | "encoding/json" | 5 | "encoding/json" |
| 7 | "errors" | 6 | "errors" |
| @@ -11,25 +10,68 @@ import ( | |||
| 11 | "time" | 10 | "time" |
| 12 | 11 | ||
| 13 | "github.com/a73x/eitri/internal/server/api/types" | 12 | "github.com/a73x/eitri/internal/server/api/types" |
| 13 | "github.com/a73x/eitri/internal/server/store" | ||
| 14 | ) | 14 | ) |
| 15 | 15 | ||
| 16 | // handleDecommissionHost begins graceful host decommission: its VMs are | 16 | // handleDecommissionHost begins graceful host decommission: its VMs are |
| 17 | // tombstoned and reaped, then the sweeper removes the host and frees its CIDR. | 17 | // tombstoned and reaped by the agent, then the sweeper removes the host and |
| 18 | // frees its CIDR. The agent only learns of the tombstones when its desired-state | ||
| 19 | // stream is poked, so we poke here exactly as the VM mutation handlers do — | ||
| 20 | // without it a healthy host never drains and stalls in `decommissioning`. | ||
| 21 | // | ||
| 22 | // ?force=true is the escape hatch for dead hardware whose agent will never | ||
| 23 | // report: it purges the VM rows and removes the host immediately (their compute | ||
| 24 | // is gone with the box), reclaiming the CIDR without waiting for a drain that | ||
| 25 | // can never happen. | ||
| 18 | func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { | 26 | func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { |
| 19 | id := r.PathValue("id") | 27 | id := r.PathValue("id") |
| 28 | |||
| 29 | if forceParam(r) { | ||
| 30 | purged, err := a.st.ForceRemoveHost(id) | ||
| 31 | switch { | ||
| 32 | case errors.Is(err, sql.ErrNoRows): | ||
| 33 | http.Error(w, "host not found", http.StatusNotFound) | ||
| 34 | return | ||
| 35 | case err != nil: | ||
| 36 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 37 | return | ||
| 38 | } | ||
| 39 | a.audit("host.decommission", map[string]string{ | ||
| 40 | "host_id": id, "remote": clientIP(r), | ||
| 41 | "force": "true", "vms_purged": strconv.Itoa(purged), | ||
| 42 | }) | ||
| 43 | a.hub.Poke(id) | ||
| 44 | a.notif.notify() | ||
| 45 | w.WriteHeader(http.StatusOK) | ||
| 46 | return | ||
| 47 | } | ||
| 48 | |||
| 20 | switch err := a.st.DecommissionHost(id); { | 49 | switch err := a.st.DecommissionHost(id); { |
| 21 | case errors.Is(err, sql.ErrNoRows): | 50 | case errors.Is(err, sql.ErrNoRows): |
| 22 | httpError(w, "host not found", http.StatusNotFound) | 51 | http.Error(w, "host not found", http.StatusNotFound) |
| 23 | return | 52 | return |
| 24 | case err != nil: | 53 | case err != nil: |
| 25 | httpError(w, "internal error", http.StatusInternalServerError) | 54 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 26 | return | 55 | return |
| 27 | } | 56 | } |
| 28 | a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)}) | 57 | a.audit("host.decommission", map[string]string{"host_id": id, "remote": clientIP(r)}) |
| 58 | a.hub.Poke(id) | ||
| 29 | a.notif.notify() | 59 | a.notif.notify() |
| 30 | w.WriteHeader(http.StatusAccepted) | 60 | w.WriteHeader(http.StatusAccepted) |
| 31 | } | 61 | } |
| 32 | 62 | ||
| 63 | // forceParam reports whether the request opts into forced removal via ?force, | ||
| 64 | // accepting a bare ?force or ?force=true. An absent key, or any other value, is | ||
| 65 | // false (the default graceful path). | ||
| 66 | func forceParam(r *http.Request) bool { | ||
| 67 | q := r.URL.Query() | ||
| 68 | if !q.Has("force") { | ||
| 69 | return false | ||
| 70 | } | ||
| 71 | v := q.Get("force") | ||
| 72 | return v == "" || v == "true" | ||
| 73 | } | ||
| 74 | |||
| 33 | // handleRevokeCredential bumps the host's credential generation, revoking its | 75 | // handleRevokeCredential bumps the host's credential generation, revoking its |
| 34 | // outstanding credential WITHOUT rotating the fleet secret. The agent's live | 76 | // outstanding credential WITHOUT rotating the fleet secret. The agent's live |
| 35 | // session is closed by syncsvc within one report tick; the host stays dark | 77 | // session is closed by syncsvc within one report tick; the host stays dark |
| @@ -41,33 +83,35 @@ func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) { | |||
| 41 | _, err := a.st.BumpCredGeneration(id, clientIP(r)) | 83 | _, err := a.st.BumpCredGeneration(id, clientIP(r)) |
| 42 | switch { | 84 | switch { |
| 43 | case errors.Is(err, sql.ErrNoRows): | 85 | case errors.Is(err, sql.ErrNoRows): |
| 44 | httpError(w, "host not found", http.StatusNotFound) | 86 | http.Error(w, "host not found", http.StatusNotFound) |
| 45 | return | 87 | return |
| 46 | case err != nil: | 88 | case err != nil: |
| 47 | httpError(w, "internal error", http.StatusInternalServerError) | 89 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 48 | return | 90 | return |
| 49 | } | 91 | } |
| 50 | w.WriteHeader(http.StatusNoContent) | 92 | w.WriteHeader(http.StatusNoContent) |
| 51 | } | 93 | } |
| 52 | 94 | ||
| 53 | // handleListAudit returns the newest audit rows (default 100, ?limit=N caps | 95 | // parseLimit reads the shared ?limit=N query param (default 100, capped to |
| 54 | // at 1000). Completes the forensic story: rows were previously reachable only | 96 | // 1..1000). On a bad value it writes the 400 and returns ok=false so the caller |
| 55 | // by opening the SQLite file. | 97 | // just returns. |
| 56 | func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | 98 | func parseLimit(w http.ResponseWriter, r *http.Request) (int, bool) { |
| 57 | limit := 100 | 99 | limit := 100 |
| 58 | if v := r.URL.Query().Get("limit"); v != "" { | 100 | if v := r.URL.Query().Get("limit"); v != "" { |
| 59 | n, err := strconv.Atoi(v) | 101 | n, err := strconv.Atoi(v) |
| 60 | if err != nil || n < 1 || n > 1000 { | 102 | if err != nil || n < 1 || n > 1000 { |
| 61 | httpError(w, "limit must be 1..1000", http.StatusBadRequest) | 103 | http.Error(w, "limit must be 1..1000", http.StatusBadRequest) |
| 62 | return | 104 | return 0, false |
| 63 | } | 105 | } |
| 64 | limit = n | 106 | limit = n |
| 65 | } | 107 | } |
| 66 | rows, err := a.st.ListAudit(limit) | 108 | return limit, true |
| 67 | if err != nil { | 109 | } |
| 68 | httpError(w, "internal error", http.StatusInternalServerError) | 110 | |
| 69 | return | 111 | // auditRowsToResponse maps store rows to the wire shape, embedding each detail |
| 70 | } | 112 | // as raw JSON and defensively re-marshalling anything that isn't valid JSON so |
| 113 | // the endpoint never emits a malformed body. | ||
| 114 | func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent { | ||
| 71 | out := make([]types.AuditEvent, len(rows)) | 115 | out := make([]types.AuditEvent, len(rows)) |
| 72 | for i, e := range rows { | 116 | for i, e := range rows { |
| 73 | detail := json.RawMessage(e.Detail) | 117 | detail := json.RawMessage(e.Detail) |
| @@ -76,7 +120,23 @@ func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | |||
| 76 | } | 120 | } |
| 77 | out[i] = types.AuditEvent{At: e.At, Action: e.Action, Detail: detail} | 121 | out[i] = types.AuditEvent{At: e.At, Action: e.Action, Detail: detail} |
| 78 | } | 122 | } |
| 79 | writeJSON(w, http.StatusOK, out) | 123 | return out |
| 124 | } | ||
| 125 | |||
| 126 | // handleListAudit returns the newest audit rows (default 100, ?limit=N caps | ||
| 127 | // at 1000). Completes the forensic story: rows were previously reachable only | ||
| 128 | // by opening the SQLite file. | ||
| 129 | func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | ||
| 130 | limit, ok := parseLimit(w, r) | ||
| 131 | if !ok { | ||
| 132 | return | ||
| 133 | } | ||
| 134 | rows, err := a.st.ListAudit(limit) | ||
| 135 | if err != nil { | ||
| 136 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 137 | return | ||
| 138 | } | ||
| 139 | writeJSON(w, http.StatusOK, auditRowsToResponse(rows)) | ||
| 80 | } | 140 | } |
| 81 | 141 | ||
| 82 | // handleListVMEvents returns one VM's lifecycle timeline: the audit rows whose | 142 | // handleListVMEvents returns one VM's lifecycle timeline: the audit rows whose |
| @@ -86,29 +146,16 @@ func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | |||
| 86 | // handleListAudit. | 146 | // handleListAudit. |
| 87 | func (a *API) handleListVMEvents(w http.ResponseWriter, r *http.Request) { | 147 | func (a *API) handleListVMEvents(w http.ResponseWriter, r *http.Request) { |
| 88 | id := r.PathValue("id") | 148 | id := r.PathValue("id") |
| 89 | limit := 100 | 149 | limit, ok := parseLimit(w, r) |
| 90 | if v := r.URL.Query().Get("limit"); v != "" { | 150 | if !ok { |
| 91 | n, err := strconv.Atoi(v) | 151 | return |
| 92 | if err != nil || n < 1 || n > 1000 { | ||
| 93 | httpError(w, "limit must be 1..1000", http.StatusBadRequest) | ||
| 94 | return | ||
| 95 | } | ||
| 96 | limit = n | ||
| 97 | } | 152 | } |
| 98 | rows, err := a.st.ListVMEvents(id, limit) | 153 | rows, err := a.st.ListVMEvents(id, limit) |
| 99 | if err != nil { | 154 | if err != nil { |
| 100 | httpError(w, "internal error", http.StatusInternalServerError) | 155 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 101 | return | 156 | return |
| 102 | } | 157 | } |
| 103 | out := make([]types.AuditEvent, len(rows)) | 158 | writeJSON(w, http.StatusOK, auditRowsToResponse(rows)) |
| 104 | for i, e := range rows { | ||
| 105 | detail := json.RawMessage(e.Detail) | ||
| 106 | if !json.Valid(detail) { // defensive: never emit invalid JSON | ||
| 107 | detail, _ = json.Marshal(e.Detail) | ||
| 108 | } | ||
| 109 | out[i] = types.AuditEvent{At: e.At, Action: e.Action, Detail: detail} | ||
| 110 | } | ||
| 111 | writeJSON(w, http.StatusOK, out) | ||
| 112 | } | 159 | } |
| 113 | 160 | ||
| 114 | // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE | 161 | // handleMintStreamTicket issues a one-time short-TTL ticket for the SSE |
| @@ -118,40 +165,37 @@ func (a *API) handleMintStreamTicket(w http.ResponseWriter, r *http.Request) { | |||
| 118 | writeJSON(w, http.StatusCreated, types.StreamTicketResponse{Ticket: a.tickets.mint()}) | 165 | writeJSON(w, http.StatusCreated, types.StreamTicketResponse{Ticket: a.tickets.mint()}) |
| 119 | } | 166 | } |
| 120 | 167 | ||
| 121 | // handleEvents streams the fleet snapshot as Server-Sent Events. It pushes on | 168 | // handleEvents streams the fleet snapshot as Server-Sent Events. It subscribes |
| 122 | // every desired-state change (via the notifier) and re-checks on a 1s tick to | 169 | // to the central snapshot hub — which marshals ONE shared snapshot on a 1s tick |
| 123 | // catch agent-reported actual-state changes, sending only when the snapshot | 170 | // / desired-state wake and fans the identical bytes to every client — and writes |
| 124 | // actually changed. A periodic comment keeps the connection alive. | 171 | // each delivered snapshot as an `event: state` frame. The hub delivers the |
| 172 | // current snapshot immediately on subscribe, so a new client gets initial state | ||
| 173 | // without doing its own marshal, and suppresses unchanged snapshots so no frame | ||
| 174 | // is pushed when nothing changed. A periodic comment keeps the connection alive. | ||
| 125 | func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { | 175 | func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { |
| 126 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { | 176 | // Assert streaming support BEFORE consuming the one-time ticket, so a 500 on |
| 127 | http.Error(w, "unauthorized", http.StatusUnauthorized) | 177 | // a non-flushing ResponseWriter doesn't burn the client's ticket. |
| 128 | return | ||
| 129 | } | ||
| 130 | flusher, ok := w.(http.Flusher) | 178 | flusher, ok := w.(http.Flusher) |
| 131 | if !ok { | 179 | if !ok { |
| 132 | http.Error(w, "streaming unsupported", http.StatusInternalServerError) | 180 | http.Error(w, "streaming unsupported", http.StatusInternalServerError) |
| 133 | return | 181 | return |
| 134 | } | 182 | } |
| 183 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { | ||
| 184 | http.Error(w, "unauthorized", http.StatusUnauthorized) | ||
| 185 | return | ||
| 186 | } | ||
| 135 | 187 | ||
| 136 | w.Header().Set("Content-Type", "text/event-stream") | 188 | w.Header().Set("Content-Type", "text/event-stream") |
| 137 | w.Header().Set("Cache-Control", "no-cache") | 189 | w.Header().Set("Cache-Control", "no-cache") |
| 138 | w.Header().Set("Connection", "keep-alive") | 190 | w.Header().Set("Connection", "keep-alive") |
| 139 | 191 | ||
| 140 | wake, unsub := a.notif.subscribe() | 192 | snaps, unsub := a.snap.subscribe() |
| 141 | defer unsub() | 193 | defer unsub() |
| 142 | 194 | ||
| 143 | tick := time.NewTicker(time.Second) | ||
| 144 | defer tick.Stop() | ||
| 145 | heartbeat := time.NewTicker(15 * time.Second) | 195 | heartbeat := time.NewTicker(15 * time.Second) |
| 146 | defer heartbeat.Stop() | 196 | defer heartbeat.Stop() |
| 147 | 197 | ||
| 148 | var last []byte | 198 | sendState := func(payload []byte) bool { |
| 149 | sendIfChanged := func() bool { | ||
| 150 | payload, err := a.marshalSnapshot() | ||
| 151 | if err != nil || bytes.Equal(payload, last) { | ||
| 152 | return err == nil | ||
| 153 | } | ||
| 154 | last = payload | ||
| 155 | if _, err := fmt.Fprintf(w, "event: state\ndata: %s\n\n", payload); err != nil { | 199 | if _, err := fmt.Fprintf(w, "event: state\ndata: %s\n\n", payload); err != nil { |
| 156 | return false | 200 | return false |
| 157 | } | 201 | } |
| @@ -159,18 +203,13 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { | |||
| 159 | return true | 203 | return true |
| 160 | } | 204 | } |
| 161 | 205 | ||
| 162 | sendIfChanged() // initial snapshot | ||
| 163 | ctx := r.Context() | 206 | ctx := r.Context() |
| 164 | for { | 207 | for { |
| 165 | select { | 208 | select { |
| 166 | case <-ctx.Done(): | 209 | case <-ctx.Done(): |
| 167 | return | 210 | return |
| 168 | case <-wake: | 211 | case payload := <-snaps: |
| 169 | if !sendIfChanged() { | 212 | if !sendState(payload) { |
| 170 | return | ||
| 171 | } | ||
| 172 | case <-tick.C: | ||
| 173 | if !sendIfChanged() { | ||
| 174 | return | 213 | return |
| 175 | } | 214 | } |
| 176 | case <-heartbeat.C: | 215 | case <-heartbeat.C: |
| @@ -184,14 +223,17 @@ func (a *API) handleEvents(w http.ResponseWriter, r *http.Request) { | |||
| 184 | 223 | ||
| 185 | // marshalSnapshot builds and JSON-encodes the current fleet snapshot. The | 224 | // marshalSnapshot builds and JSON-encodes the current fleet snapshot. The |
| 186 | // store reads happen in one transaction (store.Snapshot) so hosts, allocation | 225 | // store reads happen in one transaction (store.Snapshot) so hosts, allocation |
| 187 | // and VMs can never mix state from two different epochs. | 226 | // and VMs can never mix state from two different epochs. Each referenced host's |
| 227 | // registry state is fetched ONCE (not once per VM), then shared by both the host | ||
| 228 | // and VM builders — registry.Get deep-clones the whole host report per call. | ||
| 188 | func (a *API) marshalSnapshot() ([]byte, error) { | 229 | func (a *API) marshalSnapshot() ([]byte, error) { |
| 189 | hosts, alloc, vms, err := a.st.Snapshot() | 230 | hosts, alloc, vms, err := a.st.Snapshot() |
| 190 | if err != nil { | 231 | if err != nil { |
| 191 | return nil, err | 232 | return nil, err |
| 192 | } | 233 | } |
| 234 | states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) | ||
| 193 | return json.Marshal(types.StateSnapshot{ | 235 | return json.Marshal(types.StateSnapshot{ |
| 194 | Hosts: a.buildHostResponses(hosts, alloc), | 236 | Hosts: a.buildHostResponses(hosts, alloc, states), |
| 195 | VMs: a.buildVMResponses(vms), | 237 | VMs: a.buildVMResponses(vms, states), |
| 196 | }) | 238 | }) |
| 197 | } | 239 | } |
internal/server/api/snapshot_hub.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,130 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "sync" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // snapshotHub computes the fleet SSE snapshot ONCE, centrally, and fans the | ||
| 10 | // identical bytes out to every connected client. Before this, each SSE client | ||
| 11 | // goroutine ran its own 1s ticker and marshalled its own snapshot (a DB tx + | ||
| 12 | // full build + json.Marshal), so M clients meant M full snapshots every second. | ||
| 13 | // The hub collapses that to one build per tick/wake regardless of client count. | ||
| 14 | // | ||
| 15 | // Fan-out is latest-wins / non-blocking: each subscriber holds a buffer-1 | ||
| 16 | // channel carrying only the newest snapshot. A lagging client drops intermediate | ||
| 17 | // snapshots (correct — SSE state is a full snapshot, so the newest supersedes any | ||
| 18 | // it missed), and the central loop NEVER blocks on a slow reader, so a stuck | ||
| 19 | // client can neither wedge the hub nor grow memory unbounded. | ||
| 20 | type snapshotHub struct { | ||
| 21 | build func() ([]byte, error) // marshals the current snapshot (a.marshalSnapshot) | ||
| 22 | wake <-chan struct{} // desired-state wake source (single subscription) | ||
| 23 | notifUnsub func() // releases the notifier subscription on Close | ||
| 24 | |||
| 25 | mu sync.Mutex | ||
| 26 | current []byte // latest marshalled snapshot; delivered to new subscribers immediately | ||
| 27 | subs map[chan []byte]struct{} | ||
| 28 | |||
| 29 | stop chan struct{} | ||
| 30 | done chan struct{} | ||
| 31 | stopOnce sync.Once | ||
| 32 | } | ||
| 33 | |||
| 34 | // newSnapshotHub builds the hub, subscribes to the notifier SYNCHRONOUSLY (so no | ||
| 35 | // wake can be lost in the window before run's goroutine is scheduled), and | ||
| 36 | // computes the INITIAL snapshot, so a client that subscribes before the first | ||
| 37 | // tick still gets current state. The caller must launch run() (once). | ||
| 38 | func newSnapshotHub(build func() ([]byte, error), notif *notifier) *snapshotHub { | ||
| 39 | wake, unsub := notif.subscribe() | ||
| 40 | h := &snapshotHub{ | ||
| 41 | build: build, | ||
| 42 | wake: wake, | ||
| 43 | notifUnsub: unsub, | ||
| 44 | subs: make(map[chan []byte]struct{}), | ||
| 45 | stop: make(chan struct{}), | ||
| 46 | done: make(chan struct{}), | ||
| 47 | } | ||
| 48 | if b, err := build(); err == nil { | ||
| 49 | h.current = b | ||
| 50 | } | ||
| 51 | return h | ||
| 52 | } | ||
| 53 | |||
| 54 | // run is the hub's single goroutine: it recomputes the snapshot on a 1s tick (to | ||
| 55 | // catch agent-reported actual-state changes) or on a desired-state wake, at most | ||
| 56 | // once per event. It returns when Close is called. | ||
| 57 | func (h *snapshotHub) run() { | ||
| 58 | defer close(h.done) | ||
| 59 | tick := time.NewTicker(time.Second) | ||
| 60 | defer tick.Stop() | ||
| 61 | for { | ||
| 62 | select { | ||
| 63 | case <-h.stop: | ||
| 64 | return | ||
| 65 | case <-h.wake: | ||
| 66 | h.recompute() | ||
| 67 | case <-tick.C: | ||
| 68 | h.recompute() | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | // recompute builds the snapshot once and, if the bytes changed, stores them and | ||
| 74 | // fans them to all subscribers. Unchanged bytes are suppressed (no push), matching | ||
| 75 | // the prior per-client diff behaviour. | ||
| 76 | func (h *snapshotHub) recompute() { | ||
| 77 | b, err := h.build() | ||
| 78 | if err != nil { | ||
| 79 | return // transient store error: keep serving the last good snapshot | ||
| 80 | } | ||
| 81 | h.mu.Lock() | ||
| 82 | defer h.mu.Unlock() | ||
| 83 | if bytes.Equal(b, h.current) { | ||
| 84 | return | ||
| 85 | } | ||
| 86 | h.current = b | ||
| 87 | for ch := range h.subs { | ||
| 88 | // Latest-wins: drain any stale pending snapshot, then send the newest. | ||
| 89 | // Both sends are non-blocking; because subscribers only ever receive | ||
| 90 | // (never send) and we hold h.mu, the post-drain buffer has room and the | ||
| 91 | // hub can never block on a slow client. | ||
| 92 | select { | ||
| 93 | case <-ch: | ||
| 94 | default: | ||
| 95 | } | ||
| 96 | select { | ||
| 97 | case ch <- b: | ||
| 98 | default: | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | // subscribe registers a client and immediately delivers the CURRENT snapshot so | ||
| 104 | // a new connection gets initial state without doing its own marshal. It returns | ||
| 105 | // the client's buffer-1 channel and an unsubscribe func. | ||
| 106 | func (h *snapshotHub) subscribe() (<-chan []byte, func()) { | ||
| 107 | ch := make(chan []byte, 1) | ||
| 108 | h.mu.Lock() | ||
| 109 | if h.current != nil { | ||
| 110 | ch <- h.current // buffer-1 and empty ⇒ never blocks under the lock | ||
| 111 | } | ||
| 112 | h.subs[ch] = struct{}{} | ||
| 113 | h.mu.Unlock() | ||
| 114 | return ch, func() { | ||
| 115 | h.mu.Lock() | ||
| 116 | delete(h.subs, ch) | ||
| 117 | h.mu.Unlock() | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | // Close stops the hub goroutine, waits for it to exit, and releases the notifier | ||
| 122 | // subscription. Idempotent — safe to call more than once (e.g. a shutdown path | ||
| 123 | // plus a test cleanup). | ||
| 124 | func (h *snapshotHub) Close() { | ||
| 125 | h.stopOnce.Do(func() { | ||
| 126 | close(h.stop) | ||
| 127 | <-h.done | ||
| 128 | h.notifUnsub() | ||
| 129 | }) | ||
| 130 | } | ||
internal/server/api/snapshot_hub_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,153 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "sync/atomic" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // waitFor polls cond until it is true or the deadline elapses. | ||
| 14 | func waitFor(t *testing.T, d time.Duration, cond func() bool) { | ||
| 15 | t.Helper() | ||
| 16 | deadline := time.Now().Add(d) | ||
| 17 | for time.Now().Before(deadline) { | ||
| 18 | if cond() { | ||
| 19 | return | ||
| 20 | } | ||
| 21 | time.Sleep(time.Millisecond) | ||
| 22 | } | ||
| 23 | require.True(t, cond(), "condition not met within %s", d) | ||
| 24 | } | ||
| 25 | |||
| 26 | // TestSnapshotHubCloseIdempotent proves Close can be called more than once | ||
| 27 | // without panicking (close-of-closed-channel) — a shutdown path plus a test | ||
| 28 | // cleanup must both be safe. | ||
| 29 | func TestSnapshotHubCloseIdempotent(t *testing.T) { | ||
| 30 | h := newSnapshotHub(func() ([]byte, error) { return []byte("x"), nil }, newNotifier()) | ||
| 31 | go h.run() | ||
| 32 | h.Close() | ||
| 33 | h.Close() // must not panic | ||
| 34 | } | ||
| 35 | |||
| 36 | // TestSnapshotHubSingleBuildFanout proves the whole point of the hub: one | ||
| 37 | // underlying snapshot build is fanned to every subscriber as identical bytes, | ||
| 38 | // and a wake drives exactly one recompute regardless of how many clients are | ||
| 39 | // attached (M clients must NOT cause M builds). | ||
| 40 | func TestSnapshotHubSingleBuildFanout(t *testing.T) { | ||
| 41 | var builds int64 | ||
| 42 | build := func() ([]byte, error) { | ||
| 43 | n := atomic.AddInt64(&builds, 1) | ||
| 44 | return []byte(fmt.Sprintf("snap-%d", n)), nil | ||
| 45 | } | ||
| 46 | notif := newNotifier() | ||
| 47 | h := newSnapshotHub(build, notif) // computes the initial snapshot (build #1) | ||
| 48 | require.Equal(t, int64(1), atomic.LoadInt64(&builds)) | ||
| 49 | go h.run() | ||
| 50 | defer h.Close() | ||
| 51 | |||
| 52 | // Three subscribers each get the CURRENT snapshot immediately, without | ||
| 53 | // triggering their own build. | ||
| 54 | const n = 3 | ||
| 55 | chans := make([]<-chan []byte, n) | ||
| 56 | for i := 0; i < n; i++ { | ||
| 57 | ch, unsub := h.subscribe() | ||
| 58 | defer unsub() | ||
| 59 | chans[i] = ch | ||
| 60 | } | ||
| 61 | for i, ch := range chans { | ||
| 62 | select { | ||
| 63 | case b := <-ch: | ||
| 64 | assert.Equal(t, "snap-1", string(b), "subscriber %d initial snapshot", i) | ||
| 65 | case <-time.After(time.Second): | ||
| 66 | t.Fatalf("subscriber %d got no initial snapshot", i) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | // Still exactly one build despite three subscribers. | ||
| 70 | assert.Equal(t, int64(1), atomic.LoadInt64(&builds), "subscribing must not build") | ||
| 71 | |||
| 72 | // One wake ⇒ exactly one recompute, fanned identically to all three. | ||
| 73 | // (The 1s ticker cannot fire within this sub-second window, so builds is | ||
| 74 | // driven solely by the wake here.) | ||
| 75 | notif.notify() | ||
| 76 | waitFor(t, time.Second, func() bool { return atomic.LoadInt64(&builds) == 2 }) | ||
| 77 | for i, ch := range chans { | ||
| 78 | select { | ||
| 79 | case b := <-ch: | ||
| 80 | assert.Equal(t, "snap-2", string(b), "subscriber %d wake snapshot", i) | ||
| 81 | case <-time.After(time.Second): | ||
| 82 | t.Fatalf("subscriber %d got no wake snapshot", i) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | assert.Equal(t, int64(2), atomic.LoadInt64(&builds), | ||
| 86 | "one wake must drive exactly one build regardless of client count") | ||
| 87 | } | ||
| 88 | |||
| 89 | // TestSnapshotHubSuppressesUnchanged pins change-suppression: identical bytes | ||
| 90 | // on recompute produce no push to subscribers. | ||
| 91 | func TestSnapshotHubSuppressesUnchanged(t *testing.T) { | ||
| 92 | var builds int64 | ||
| 93 | build := func() ([]byte, error) { | ||
| 94 | atomic.AddInt64(&builds, 1) | ||
| 95 | return []byte("constant"), nil // never changes | ||
| 96 | } | ||
| 97 | notif := newNotifier() | ||
| 98 | h := newSnapshotHub(build, notif) | ||
| 99 | go h.run() | ||
| 100 | defer h.Close() | ||
| 101 | |||
| 102 | ch, unsub := h.subscribe() | ||
| 103 | defer unsub() | ||
| 104 | // Drain the immediate initial delivery. | ||
| 105 | select { | ||
| 106 | case b := <-ch: | ||
| 107 | assert.Equal(t, "constant", string(b)) | ||
| 108 | case <-time.After(time.Second): | ||
| 109 | t.Fatal("no initial snapshot") | ||
| 110 | } | ||
| 111 | |||
| 112 | notif.notify() | ||
| 113 | waitFor(t, time.Second, func() bool { return atomic.LoadInt64(&builds) >= 2 }) | ||
| 114 | // Bytes are unchanged, so nothing new must be delivered. | ||
| 115 | select { | ||
| 116 | case b := <-ch: | ||
| 117 | t.Fatalf("unexpected push of unchanged snapshot: %q", b) | ||
| 118 | case <-time.After(100 * time.Millisecond): | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | // TestSnapshotHubLatestWins proves the fan-out is non-blocking / latest-wins: a | ||
| 123 | // slow (never-draining) subscriber's channel holds only the newest snapshot and | ||
| 124 | // the central loop never blocks on it. | ||
| 125 | func TestSnapshotHubLatestWins(t *testing.T) { | ||
| 126 | var n int64 | ||
| 127 | build := func() ([]byte, error) { | ||
| 128 | return []byte(fmt.Sprintf("v%d", atomic.AddInt64(&n, 1))), nil | ||
| 129 | } | ||
| 130 | notif := newNotifier() | ||
| 131 | h := newSnapshotHub(build, notif) | ||
| 132 | go h.run() | ||
| 133 | defer h.Close() | ||
| 134 | |||
| 135 | // Subscribe but never read: the buffer-1 channel already holds the initial | ||
| 136 | // snapshot. Further recomputes must overwrite it (drain-stale-then-send), | ||
| 137 | // never block the hub. | ||
| 138 | ch, unsub := h.subscribe() | ||
| 139 | defer unsub() | ||
| 140 | |||
| 141 | for i := 0; i < 5; i++ { | ||
| 142 | notif.notify() | ||
| 143 | waitFor(t, time.Second, func() bool { return atomic.LoadInt64(&n) >= int64(i+2) }) | ||
| 144 | } | ||
| 145 | // The lone buffered value must be the LATEST, not a stale early one. | ||
| 146 | last := fmt.Sprintf("v%d", atomic.LoadInt64(&n)) | ||
| 147 | select { | ||
| 148 | case b := <-ch: | ||
| 149 | assert.Equal(t, last, string(b), "slow subscriber must hold the newest snapshot") | ||
| 150 | case <-time.After(time.Second): | ||
| 151 | t.Fatal("slow subscriber holds nothing") | ||
| 152 | } | ||
| 153 | } | ||
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -105,7 +105,7 @@ func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line } | |||
| 105 | // 404s when the jump gate is off. | 105 | // 404s when the jump gate is off. |
| 106 | func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { | 106 | func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) { |
| 107 | if a.sshCAKey == "" { | 107 | if a.sshCAKey == "" { |
| 108 | httpError(w, "ssh jump gate not enabled", http.StatusNotFound) | 108 | http.Error(w, "ssh jump gate not enabled", http.StatusNotFound) |
| 109 | return | 109 | return |
| 110 | } | 110 | } |
| 111 | writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey}) | 111 | writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey}) |
| @@ -142,7 +142,7 @@ func (m *HostMinter) MintHostCert(principal string) (keyPEM, cert string, err er | |||
| 142 | // gate is off (no CA wired). | 142 | // gate is off (no CA wired). |
| 143 | func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { | 143 | func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { |
| 144 | if a.certs == nil { | 144 | if a.certs == nil { |
| 145 | httpError(w, "ssh jump gate not enabled", http.StatusNotFound) | 145 | http.Error(w, "ssh jump gate not enabled", http.StatusNotFound) |
| 146 | return | 146 | return |
| 147 | } | 147 | } |
| 148 | var req types.SSHCertRequest | 148 | var req types.SSHCertRequest |
| @@ -151,12 +151,12 @@ func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 151 | } | 151 | } |
| 152 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey)) | 152 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey)) |
| 153 | if err != nil { | 153 | if err != nil { |
| 154 | httpError(w, "invalid public_key", http.StatusBadRequest) | 154 | http.Error(w, "invalid public_key", http.StatusBadRequest) |
| 155 | return | 155 | return |
| 156 | } | 156 | } |
| 157 | cert, err := a.certs.Mint(pub) | 157 | cert, err := a.certs.Mint(pub) |
| 158 | if err != nil { | 158 | if err != nil { |
| 159 | httpError(w, "internal error", http.StatusInternalServerError) | 159 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 160 | return | 160 | return |
| 161 | } | 161 | } |
| 162 | a.audit("ssh-cert.mint", map[string]string{ | 162 | a.audit("ssh-cert.mint", map[string]string{ |
| @@ -189,24 +189,24 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 189 | // signature is checked. A non-cert key line is a clear 400. | 189 | // signature is checked. A non-cert key line is a clear 400. |
| 190 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate)) | 190 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate)) |
| 191 | if err != nil { | 191 | if err != nil { |
| 192 | httpError(w, "invalid certificate", http.StatusBadRequest) | 192 | http.Error(w, "invalid certificate", http.StatusBadRequest) |
| 193 | return | 193 | return |
| 194 | } | 194 | } |
| 195 | cert, ok := pk.(*ssh.Certificate) | 195 | cert, ok := pk.(*ssh.Certificate) |
| 196 | if !ok { | 196 | if !ok { |
| 197 | httpError(w, "not a certificate", http.StatusBadRequest) | 197 | http.Error(w, "not a certificate", http.StatusBadRequest) |
| 198 | return | 198 | return |
| 199 | } | 199 | } |
| 200 | serial = cert.Serial | 200 | serial = cert.Serial |
| 201 | case req.Serial != nil: | 201 | case req.Serial != nil: |
| 202 | serial = *req.Serial | 202 | serial = *req.Serial |
| 203 | default: | 203 | default: |
| 204 | httpError(w, "serial or certificate required", http.StatusBadRequest) | 204 | http.Error(w, "serial or certificate required", http.StatusBadRequest) |
| 205 | return | 205 | return |
| 206 | } | 206 | } |
| 207 | 207 | ||
| 208 | if err := a.st.RevokeSSHCert(serial, req.Reason); err != nil { | 208 | if err := a.st.RevokeSSHCert(serial, req.Reason); err != nil { |
| 209 | httpError(w, "internal error", http.StatusInternalServerError) | 209 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 210 | return | 210 | return |
| 211 | } | 211 | } |
| 212 | a.audit("ssh-cert.revoke", map[string]string{ | 212 | a.audit("ssh-cert.revoke", map[string]string{ |
| @@ -222,7 +222,7 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 222 | func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { | 222 | func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { |
| 223 | revoked, err := a.st.ListRevokedSSHCerts() | 223 | revoked, err := a.st.ListRevokedSSHCerts() |
| 224 | if err != nil { | 224 | if err != nil { |
| 225 | httpError(w, "internal error", http.StatusInternalServerError) | 225 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 226 | return | 226 | return |
| 227 | } | 227 | } |
| 228 | out := make([]types.RevokedCert, len(revoked)) | 228 | out := make([]types.RevokedCert, len(revoked)) |
internal/server/api/testdata/host.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,10 @@ | |||
| 8 | "status": "active", | 8 | "status": "active", |
| 9 | "enrolled_at": "2026-07-27T12:00:00Z", | 9 | "enrolled_at": "2026-07-27T12:00:00Z", |
| 10 | "online": true, | 10 | "online": true, |
| 11 | "last_seen": null, | ||
| 12 | "seconds_since_last_seen": null, | ||
| 13 | "stale": false, | ||
| 14 | "sessions": 0, | ||
| 11 | "capacity": { | 15 | "capacity": { |
| 12 | "vcpus": 16, | 16 | "vcpus": 16, |
| 13 | "mem_mb": 32768, | 17 | "mem_mb": 32768, |
internal/server/api/testdata/snapshot.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,10 @@ | |||
| 10 | "status": "active", | 10 | "status": "active", |
| 11 | "enrolled_at": "2026-07-27T12:00:00Z", | 11 | "enrolled_at": "2026-07-27T12:00:00Z", |
| 12 | "online": true, | 12 | "online": true, |
| 13 | "last_seen": null, | ||
| 14 | "seconds_since_last_seen": null, | ||
| 15 | "stale": false, | ||
| 16 | "sessions": 0, | ||
| 13 | "capacity": { | 17 | "capacity": { |
| 14 | "vcpus": 16, | 18 | "vcpus": 16, |
| 15 | "mem_mb": 32768, | 19 | "mem_mb": 32768, |
internal/server/api/ticket.go
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | "sync" | 4 | "sync" |
| 5 | "time" | 5 | "time" |
| 6 | 6 | ||
| 7 | "github.com/a73x/eitri/internal/server/store" | 7 | "github.com/a73x/eitri/internal/random" |
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| 10 | // streamTicketTTL bounds how long a minted stream ticket stays redeemable. | 10 | // streamTicketTTL bounds how long a minted stream ticket stays redeemable. |
| @@ -40,7 +40,7 @@ func (t *ticketStore) mint() string { | |||
| 40 | delete(t.tickets, k) | 40 | delete(t.tickets, k) |
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 43 | tick := store.RandHex(16) | 43 | tick := random.Hex(16) |
| 44 | t.tickets[tick] = now.Add(streamTicketTTL) | 44 | t.tickets[tick] = now.Add(streamTicketTTL) |
| 45 | return tick | 45 | return tick |
| 46 | } | 46 | } |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -32,8 +32,17 @@ type Host struct { | |||
| 32 | Status string `json:"status"` | 32 | Status string `json:"status"` |
| 33 | EnrolledAt time.Time `json:"enrolled_at"` | 33 | EnrolledAt time.Time `json:"enrolled_at"` |
| 34 | Online bool `json:"online"` | 34 | Online bool `json:"online"` |
| 35 | Capacity Capacity `json:"capacity"` // host TOTALS (when online) | 35 | // Sync-health signals so degradation is visible at a glance. LastSeen |
| 36 | Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed) | 36 | // and SecondsSinceLastSeen are nil until the host has reported at least |
| 37 | // once (why clients can see last_seen: null). Stale trips before Online | ||
| 38 | // clears — the early "sync is degrading" warning. Sessions is the agent | ||
| 39 | // (re)connect count: rising while Online means the link is flapping. | ||
| 40 | LastSeen *time.Time `json:"last_seen"` | ||
| 41 | SecondsSinceLastSeen *int64 `json:"seconds_since_last_seen"` | ||
| 42 | Stale bool `json:"stale"` | ||
| 43 | Sessions int `json:"sessions"` | ||
| 44 | Capacity Capacity `json:"capacity"` // host TOTALS (when online) | ||
| 45 | Allocated Capacity `json:"allocated"` // committed to live VMs (server-computed) | ||
| 37 | } | 46 | } |
| 38 | 47 | ||
| 39 | // VM is the explicit snake_case wire representation of a VM, served by | 48 | // VM is the explicit snake_case wire representation of a VM, served by |
internal/server/config/config.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,48 @@ | |||
| 1 | // Package config defines the eitri-server on-disk JSON configuration schema. | ||
| 2 | // It is shared between the server binary (cmd/eitri-server) and the | ||
| 3 | // integration harness (internal/integration/harness), which renders a config | ||
| 4 | // file for the server subprocess it launches — sharing the type makes the | ||
| 5 | // compiler, not a comment, keep the two in agreement. | ||
| 6 | package config | ||
| 7 | |||
| 8 | // Config is the eitri-server config file schema (decoded from JSON). | ||
| 9 | type Config struct { | ||
| 10 | HTTPListen string `json:"http_listen"` | ||
| 11 | QUICListen string `json:"quic_listen"` | ||
| 12 | DBPath string `json:"db_path"` | ||
| 13 | AdminToken string `json:"admin_token"` | ||
| 14 | HostSecret string `json:"host_secret"` | ||
| 15 | CIDRPool string `json:"cidr_pool"` | ||
| 16 | DefaultImageURL string `json:"default_image_url"` | ||
| 17 | DefaultImageSHA string `json:"default_image_sha256"` | ||
| 18 | AdvertiseHTTP string `json:"advertise_http"` | ||
| 19 | AdvertiseQUIC string `json:"advertise_quic"` | ||
| 20 | // CredentialMaxAge optionally bounds host credential age (Go duration, | ||
| 21 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via | ||
| 22 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; | ||
| 23 | // max-age forces periodic re-enrollment and is opt-in defense-in-depth. | ||
| 24 | CredentialMaxAge string `json:"credential_max_age"` | ||
| 25 | // AuditRetention bounds the audit_log age (Go duration; default "2160h" = | ||
| 26 | // 90 days; "0" disables pruning). Pruned at startup and daily. | ||
| 27 | AuditRetention string `json:"audit_retention"` | ||
| 28 | // SSHCAKey is the path to the persistent SSH user CA private key | ||
| 29 | // (auto-created 0600 if absent, handled like AdminToken — never logged). | ||
| 30 | // The CA signs the short-lived certs the jump gate accepts. | ||
| 31 | SSHCAKey string `json:"ssh_ca_key"` | ||
| 32 | // SSHHostKey is the path to the gate's persistent SSH host key | ||
| 33 | // (auto-created 0600 if absent, never regenerated on restart so users | ||
| 34 | // don't see host-key-changed warnings). | ||
| 35 | SSHHostKey string `json:"ssh_host_key"` | ||
| 36 | // SSHListen is the jump-gate listen address. Empty ⇒ gate is OFF (no | ||
| 37 | // key material is loaded and no listener is started). | ||
| 38 | SSHListen string `json:"ssh_listen"` | ||
| 39 | // SSHGateDomain is the hostname clients dial the gate as (the principal put | ||
| 40 | // on the gate's signed HOST certificate). Empty ⇒ derived from SSHListen's | ||
| 41 | // host part; if that is also empty (e.g. ":2222") it falls back to | ||
| 42 | // "localhost". It must match the host in EITRI_GATE so `@cert-authority` | ||
| 43 | // verification accepts the presented host cert. | ||
| 44 | SSHGateDomain string `json:"ssh_gate_domain"` | ||
| 45 | // SSHCertTTL bounds minted user-cert validity (Go duration; default 10m). | ||
| 46 | // Set server-side; client-requested validity is never honored. | ||
| 47 | SSHCertTTL string `json:"ssh_cert_ttl"` | ||
| 48 | } | ||
internal/server/registry/registry.go
| Old | New | ||
|---|---|---|---|
| @@ -9,8 +9,21 @@ import ( | |||
| 9 | "time" | 9 | "time" |
| 10 | ) | 10 | ) |
| 11 | 11 | ||
| 12 | // OnlineWindow is how long a host may go without a report before it is marked | ||
| 13 | // offline. It is COUPLED to the agent's report cadence | ||
| 14 | // (syncclient.DefaultTickInterval, 10s): the invariant | ||
| 15 | // OnlineWindow >= 3*DefaultTickInterval keeps a healthy host from flapping | ||
| 16 | // Online/Offline on a couple of jittered/missed reports. Lowering this (or | ||
| 17 | // raising the agent tick) without preserving that margin re-introduces flapping; | ||
| 18 | // the two live in different packages, so an invariant test in syncclient guards | ||
| 19 | // the relationship. | ||
| 12 | const OnlineWindow = 30 * time.Second | 20 | const OnlineWindow = 30 * time.Second |
| 13 | 21 | ||
| 22 | // StaleWindow is the "degrading" threshold: a host whose last report is older | ||
| 23 | // than this — but still within OnlineWindow — is Online yet Stale, the early | ||
| 24 | // warning that sync is flapping or falling behind before it drops fully offline. | ||
| 25 | const StaleWindow = OnlineWindow / 2 | ||
| 26 | |||
| 14 | type Capacity struct{ VCPUs, MemMB, DiskGB int64 } | 27 | type Capacity struct{ VCPUs, MemMB, DiskGB int64 } |
| 15 | 28 | ||
| 16 | type ActualVM struct { | 29 | type ActualVM struct { |
| @@ -34,7 +47,19 @@ type Report struct { | |||
| 34 | type HostState struct { | 47 | type HostState struct { |
| 35 | Report | 48 | Report |
| 36 | LastSeen time.Time | 49 | LastSeen time.Time |
| 37 | Online bool | 50 | // Sessions counts agent (re)connects since server start. A steadily rising |
| 51 | // value with a live LastSeen means the agent is churning/flapping its QUIC | ||
| 52 | // session even though it looks online; a flat value means a stable link. | ||
| 53 | Sessions int | ||
| 54 | // The following are derived on each Get from LastSeen and the clock; they | ||
| 55 | // are not stored. | ||
| 56 | Online bool | ||
| 57 | // Stale trips before Online clears (last report older than StaleWindow): the | ||
| 58 | // at-a-glance "sync is degrading" signal. | ||
| 59 | Stale bool | ||
| 60 | // SinceLastSeen is the age of the last report at read time. Zero when the | ||
| 61 | // host has never reported (LastSeen unset). | ||
| 62 | SinceLastSeen time.Duration | ||
| 38 | } | 63 | } |
| 39 | 64 | ||
| 40 | type Registry struct { | 65 | type Registry struct { |
| @@ -50,7 +75,21 @@ func New(now func() time.Time) *Registry { | |||
| 50 | func (r *Registry) UpdateReport(hostID string, rep Report) { | 75 | func (r *Registry) UpdateReport(hostID string, rep Report) { |
| 51 | r.mu.Lock() | 76 | r.mu.Lock() |
| 52 | defer r.mu.Unlock() | 77 | defer r.mu.Unlock() |
| 53 | r.m[hostID] = HostState{Report: rep, LastSeen: r.now()} | 78 | // Preserve the session counter across reports: UpdateReport replaces the |
| 79 | // whole HostState, and Sessions is owned by RecordConnect, not the report. | ||
| 80 | sessions := r.m[hostID].Sessions | ||
| 81 | r.m[hostID] = HostState{Report: rep, LastSeen: r.now(), Sessions: sessions} | ||
| 82 | } | ||
| 83 | |||
| 84 | // RecordConnect increments the host's session counter, marking one agent | ||
| 85 | // (re)connect. It preserves any existing report/LastSeen so a reconnect that | ||
| 86 | // arrives before the first fresh report does not blank live state. | ||
| 87 | func (r *Registry) RecordConnect(hostID string) { | ||
| 88 | r.mu.Lock() | ||
| 89 | defer r.mu.Unlock() | ||
| 90 | st := r.m[hostID] | ||
| 91 | st.Sessions++ | ||
| 92 | r.m[hostID] = st | ||
| 54 | } | 93 | } |
| 55 | 94 | ||
| 56 | func (r *Registry) Get(hostID string) (HostState, bool) { | 95 | func (r *Registry) Get(hostID string) (HostState, bool) { |
| @@ -60,7 +99,15 @@ func (r *Registry) Get(hostID string) (HostState, bool) { | |||
| 60 | if !ok { | 99 | if !ok { |
| 61 | return st, false | 100 | return st, false |
| 62 | } | 101 | } |
| 63 | st.Online = r.now().Sub(st.LastSeen) < OnlineWindow | 102 | // Derive liveness from the last report's age. A host that has connected but |
| 103 | // never reported (LastSeen unset) is neither online nor meaningfully "stale | ||
| 104 | // for N seconds", so leave SinceLastSeen zero and report it offline. | ||
| 105 | if !st.LastSeen.IsZero() { | ||
| 106 | elapsed := r.now().Sub(st.LastSeen) | ||
| 107 | st.SinceLastSeen = elapsed | ||
| 108 | st.Online = elapsed < OnlineWindow | ||
| 109 | st.Stale = elapsed >= StaleWindow | ||
| 110 | } | ||
| 64 | // Deep-copy slices so callers cannot corrupt registry state. | 111 | // Deep-copy slices so callers cannot corrupt registry state. |
| 65 | st.Report.VMs = slices.Clone(st.Report.VMs) | 112 | st.Report.VMs = slices.Clone(st.Report.VMs) |
| 66 | quarantined := slices.Clone(st.Report.Quarantined) | 113 | quarantined := slices.Clone(st.Report.Quarantined) |
internal/server/registry/registry_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package registry | |||
| 3 | import ( | 3 | import ( |
| 4 | "testing" | 4 | "testing" |
| 5 | "time" | 5 | "time" |
| 6 | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" |
| 7 | ) | 8 | ) |
| 8 | 9 | ||
| @@ -43,3 +44,65 @@ func TestUnknownHostNotFound(t *testing.T) { | |||
| 43 | _, ok := r.Get("nope") | 44 | _, ok := r.Get("nope") |
| 44 | assert.False(t, ok) | 45 | assert.False(t, ok) |
| 45 | } | 46 | } |
| 47 | |||
| 48 | // TestStalenessDerivation pins the online/stale/age signals derived from the age | ||
| 49 | // of the last report: fresh is online and not stale; past half the window it is | ||
| 50 | // still online but stale (the early "degrading" warning); past the full window | ||
| 51 | // it is offline (and remains stale). | ||
| 52 | func TestStalenessDerivation(t *testing.T) { | ||
| 53 | cases := []struct { | ||
| 54 | name string | ||
| 55 | age time.Duration | ||
| 56 | wantOnline bool | ||
| 57 | wantStale bool | ||
| 58 | }{ | ||
| 59 | {"fresh", 0, true, false}, | ||
| 60 | {"just under stale", StaleWindow - time.Second, true, false}, | ||
| 61 | {"at stale window", StaleWindow, true, true}, | ||
| 62 | {"online but stale", OnlineWindow - time.Second, true, true}, | ||
| 63 | {"just offline", OnlineWindow, false, true}, | ||
| 64 | {"long offline", 5 * OnlineWindow, false, true}, | ||
| 65 | } | ||
| 66 | for _, tc := range cases { | ||
| 67 | t.Run(tc.name, func(t *testing.T) { | ||
| 68 | now := time.Now() | ||
| 69 | r := New(func() time.Time { return now }) | ||
| 70 | r.UpdateReport("h1", Report{}) | ||
| 71 | now = now.Add(tc.age) | ||
| 72 | st, ok := r.Get("h1") | ||
| 73 | assert.True(t, ok) | ||
| 74 | assert.Equal(t, tc.wantOnline, st.Online, "online") | ||
| 75 | assert.Equal(t, tc.wantStale, st.Stale, "stale") | ||
| 76 | assert.Equal(t, tc.age, st.SinceLastSeen, "age since last seen") | ||
| 77 | }) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | // TestSessionsCountAndReportPreservation pins that RecordConnect counts agent | ||
| 82 | // (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. | ||
| 84 | func TestSessionsCountAndReportPreservation(t *testing.T) { | ||
| 85 | now := time.Now() | ||
| 86 | r := New(func() time.Time { return now }) | ||
| 87 | |||
| 88 | // Connect before any report: counted, but offline with no last-seen age. | ||
| 89 | r.RecordConnect("h1") | ||
| 90 | st, ok := r.Get("h1") | ||
| 91 | assert.True(t, ok) | ||
| 92 | assert.Equal(t, 1, st.Sessions) | ||
| 93 | assert.False(t, st.Online) | ||
| 94 | assert.True(t, st.LastSeen.IsZero()) | ||
| 95 | assert.Zero(t, st.SinceLastSeen) | ||
| 96 | |||
| 97 | // A report does not reset the session counter. | ||
| 98 | r.UpdateReport("h1", Report{Capacity: Capacity{VCPUs: 4}}) | ||
| 99 | st, _ = r.Get("h1") | ||
| 100 | assert.Equal(t, 1, st.Sessions, "report must preserve session count") | ||
| 101 | assert.True(t, st.Online) | ||
| 102 | |||
| 103 | // A reconnect increments and keeps the live report. | ||
| 104 | r.RecordConnect("h1") | ||
| 105 | st, _ = r.Get("h1") | ||
| 106 | assert.Equal(t, 2, st.Sessions) | ||
| 107 | assert.Equal(t, int64(4), st.Capacity.VCPUs, "reconnect must not blank live state") | ||
| 108 | } | ||
internal/server/sshgate/gate.go
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | "log/slog" | 17 | "log/slog" |
| 18 | "net" | 18 | "net" |
| 19 | "strings" | 19 | "strings" |
| 20 | "time" | ||
| 20 | 21 | ||
| 21 | "golang.org/x/crypto/ssh" | 22 | "golang.org/x/crypto/ssh" |
| 22 | ) | 23 | ) |
| @@ -135,13 +136,24 @@ func (g *Gate) Serve(l net.Listener) error { | |||
| 135 | } | 136 | } |
| 136 | } | 137 | } |
| 137 | 138 | ||
| 139 | // handshakeGrace bounds the unauthenticated SSH handshake, mirroring sshd's | ||
| 140 | // LoginGraceTime. Without it a client that connects and then stalls (no or | ||
| 141 | // partial banner) parks a goroutine + fd indefinitely, and enough such | ||
| 142 | // connections starve the bastion of legitimate admin logins. A var (not const) | ||
| 143 | // so tests can shrink it; not part of the public API. | ||
| 144 | var handshakeGrace = 30 * time.Second | ||
| 145 | |||
| 138 | // handleConn runs the SSH handshake and dispatches channels for one connection. | 146 | // handleConn runs the SSH handshake and dispatches channels for one connection. |
| 139 | func (g *Gate) handleConn(nConn net.Conn) { | 147 | func (g *Gate) handleConn(nConn net.Conn) { |
| 140 | defer nConn.Close() | 148 | defer nConn.Close() |
| 149 | // Deadline covers only the pre-auth handshake; cleared once it completes so | ||
| 150 | // it never applies to the long-lived tunnel that follows. | ||
| 151 | _ = nConn.SetDeadline(time.Now().Add(handshakeGrace)) | ||
| 141 | sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) | 152 | sConn, chans, reqs, err := ssh.NewServerConn(nConn, g.cfg) |
| 142 | if err != nil { | 153 | if err != nil { |
| 143 | return // handshake or auth failure — nothing to serve | 154 | return // handshake, auth failure, or grace timeout — nothing to serve |
| 144 | } | 155 | } |
| 156 | _ = nConn.SetDeadline(time.Time{}) | ||
| 145 | defer sConn.Close() | 157 | defer sConn.Close() |
| 146 | 158 | ||
| 147 | // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn | 159 | // Refuse EVERY out-of-band global request. tcpip-forward (`ssh -R`) would turn |
internal/server/sshgate/gate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -296,6 +296,33 @@ func TestGateDirectTCPIPToPort22RoundTrips(t *testing.T) { | |||
| 296 | assert.Equal(t, want, got) | 296 | assert.Equal(t, want, got) |
| 297 | } | 297 | } |
| 298 | 298 | ||
| 299 | // TestGateHandshakeGraceDropsStalledConn pins the pre-auth DoS guard: a client | ||
| 300 | // that connects and then never completes the SSH handshake must be dropped by | ||
| 301 | // the handshake grace, not parked forever holding a goroutine + fd. | ||
| 302 | func TestGateHandshakeGraceDropsStalledConn(t *testing.T) { | ||
| 303 | orig := handshakeGrace | ||
| 304 | handshakeGrace = 150 * time.Millisecond | ||
| 305 | t.Cleanup(func() { handshakeGrace = orig }) | ||
| 306 | |||
| 307 | ca := newSigner(t) | ||
| 308 | tg := startGate(t, ca.PublicKey(), true) | ||
| 309 | |||
| 310 | conn, err := net.Dial("tcp", tg.addr) | ||
| 311 | require.NoError(t, err) | ||
| 312 | defer conn.Close() | ||
| 313 | |||
| 314 | // Never send a client identification string. The gate emits its banner then | ||
| 315 | // blocks reading ours; the grace deadline must fire and close the connection, | ||
| 316 | // so our read drains the banner and hits a clean EOF well before this generous | ||
| 317 | // client deadline. Without the grace the server would block forever and we'd | ||
| 318 | // instead trip our own read timeout. | ||
| 319 | start := time.Now() | ||
| 320 | require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) | ||
| 321 | _, err = io.ReadAll(conn) | ||
| 322 | require.NoError(t, err, "server must close the stalled conn (EOF), not leave us to time out") | ||
| 323 | assert.Less(t, time.Since(start), 3*time.Second, "stalled conn must drop near the handshake grace") | ||
| 324 | } | ||
| 325 | |||
| 299 | func TestGateDirectTCPIPToNonSSHPortRejected(t *testing.T) { | 326 | func TestGateDirectTCPIPToNonSSHPortRejected(t *testing.T) { |
| 300 | ca := newSigner(t) | 327 | ca := newSigner(t) |
| 301 | tg := startGate(t, ca.PublicKey(), true) | 328 | tg := startGate(t, ca.PublicKey(), true) |
internal/server/store/allocation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 4 | "testing" | 4 | "testing" |
| 5 | 5 | ||
| 6 | "github.com/a73x/eitri/internal/random" | ||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" |
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" |
| 8 | ) | 9 | ) |
| @@ -10,7 +11,7 @@ import ( | |||
| 10 | func vmWithResources(t *testing.T, s *Store, h Host, name string, vcpus, mem, disk int64) VM { | 11 | func vmWithResources(t *testing.T, s *Store, h Host, name string, vcpus, mem, disk int64) VM { |
| 11 | t.Helper() | 12 | t.Helper() |
| 12 | vm := VM{ | 13 | vm := VM{ |
| 13 | ID: RandHex(8), HostID: h.ID, Name: name, | 14 | ID: random.Hex(8), HostID: h.ID, Name: name, |
| 14 | ImageURL: "http://img", ImageSHA256: "abc", | 15 | ImageURL: "http://img", ImageSHA256: "abc", |
| 15 | VCPUs: vcpus, MemMB: mem, DiskGB: disk, PowerState: "running", | 16 | VCPUs: vcpus, MemMB: mem, DiskGB: disk, PowerState: "running", |
| 16 | } | 17 | } |
| @@ -59,7 +60,7 @@ func TestAllocatedByHostSeparatesHosts(t *testing.T) { | |||
| 59 | 60 | ||
| 60 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { | 61 | func (s *Store) mustAllocated(t *testing.T) map[string]Alloc { |
| 61 | t.Helper() | 62 | t.Helper() |
| 62 | a, err := s.AllocatedByHost() | 63 | _, alloc, _, err := s.Snapshot() |
| 63 | require.NoError(t, err) | 64 | require.NoError(t, err) |
| 64 | return a | 65 | return alloc |
| 65 | } | 66 | } |
internal/server/store/decommission_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 4 | "testing" | 4 | "testing" |
| 5 | 5 | ||
| 6 | "github.com/a73x/eitri/internal/random" | ||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" |
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" |
| 8 | ) | 9 | ) |
| @@ -10,7 +11,7 @@ import ( | |||
| 10 | func makeVM(t *testing.T, s *Store, host Host, name string) VM { | 11 | func makeVM(t *testing.T, s *Store, host Host, name string) VM { |
| 11 | t.Helper() | 12 | t.Helper() |
| 12 | vm := VM{ | 13 | vm := VM{ |
| 13 | ID: RandHex(8), HostID: host.ID, Name: name, | 14 | ID: random.Hex(8), HostID: host.ID, Name: name, |
| 14 | ImageURL: "http://img", ImageSHA256: "abc", | 15 | ImageURL: "http://img", ImageSHA256: "abc", |
| 15 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", | 16 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", |
| 16 | } | 17 | } |
| @@ -79,21 +80,27 @@ func TestRemoveHostRefusesWhileVMsRemain(t *testing.T) { | |||
| 79 | assert.Error(t, err, "RemoveHost must refuse while VM rows remain") | 80 | assert.Error(t, err, "RemoveHost must refuse while VM rows remain") |
| 80 | } | 81 | } |
| 81 | 82 | ||
| 82 | func TestHostVMCount(t *testing.T) { | 83 | // TestRemoveHostDrainSequence pins the same VM-row-counting behavior the |
| 84 | // deleted HostVMCount used to expose directly, but through RemoveHost (its | ||
| 85 | // only production caller): decommission refuses to finalize while a VM row | ||
| 86 | // remains — live or merely tombstoned-but-not-yet-reaped — and succeeds the | ||
| 87 | // instant the row is hard-deleted. | ||
| 88 | func TestRemoveHostDrainSequence(t *testing.T) { | ||
| 83 | s := newStore(t) | 89 | s := newStore(t) |
| 84 | h := enrollHost(t, s) | 90 | h := enrollHost(t, s) |
| 85 | assert.Equal(t, 0, s.mustHostVMCount(t, h.ID)) | 91 | require.NoError(t, s.DecommissionHost(h.ID)) |
| 86 | vm := makeVM(t, s, h, "vm-a") | 92 | require.NoError(t, s.RemoveHost(h.ID), "no VMs: RemoveHost should succeed immediately") |
| 87 | assert.Equal(t, 1, s.mustHostVMCount(t, h.ID)) | ||
| 88 | require.NoError(t, s.TombstoneVM(vm.ID)) | ||
| 89 | assert.Equal(t, 1, s.mustHostVMCount(t, h.ID), "tombstoned but not reaped still counts") | ||
| 90 | require.NoError(t, s.HardDeleteVM(vm.ID)) | ||
| 91 | assert.Equal(t, 0, s.mustHostVMCount(t, h.ID)) | ||
| 92 | } | ||
| 93 | 93 | ||
| 94 | func (s *Store) mustHostVMCount(t *testing.T, id string) int { | 94 | s2 := newStore(t) |
| 95 | t.Helper() | 95 | h2 := enrollHost(t, s2) |
| 96 | n, err := s.HostVMCount(id) | 96 | vm := makeVM(t, s2, h2, "vm-a") |
| 97 | require.NoError(t, err) | 97 | assert.Error(t, s2.RemoveHost(h2.ID), "live VM row: RemoveHost must refuse") |
| 98 | return n | 98 | |
| 99 | // DecommissionHost tombstones the host's VMs but leaves the rows in place | ||
| 100 | // until the agent acks their destroy — still not drained. | ||
| 101 | require.NoError(t, s2.DecommissionHost(h2.ID)) | ||
| 102 | assert.Error(t, s2.RemoveHost(h2.ID), "tombstoned but not reaped: RemoveHost must still refuse") | ||
| 103 | |||
| 104 | require.NoError(t, s2.HardDeleteVM(vm.ID)) | ||
| 105 | require.NoError(t, s2.RemoveHost(h2.ID), "reaped: RemoveHost should now succeed") | ||
| 99 | } | 106 | } |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,6 @@ package store | |||
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
| 8 | "context" | 8 | "context" |
| 9 | "crypto/rand" | ||
| 10 | "crypto/sha256" | 9 | "crypto/sha256" |
| 11 | "database/sql" | 10 | "database/sql" |
| 12 | "encoding/hex" | 11 | "encoding/hex" |
| @@ -20,6 +19,7 @@ import ( | |||
| 20 | "strings" | 19 | "strings" |
| 21 | "time" | 20 | "time" |
| 22 | 21 | ||
| 22 | "github.com/a73x/eitri/internal/random" | ||
| 23 | "github.com/a73x/eitri/internal/transport" | 23 | "github.com/a73x/eitri/internal/transport" |
| 24 | _ "modernc.org/sqlite" | 24 | _ "modernc.org/sqlite" |
| 25 | ) | 25 | ) |
| @@ -30,13 +30,9 @@ var ErrNameTaken = errors.New("vm name already in use") | |||
| 30 | // ErrHostNotFound is returned by CreateVM when the host_id does not exist. | 30 | // ErrHostNotFound is returned by CreateVM when the host_id does not exist. |
| 31 | var ErrHostNotFound = errors.New("host not found") | 31 | var ErrHostNotFound = errors.New("host not found") |
| 32 | 32 | ||
| 33 | // RandHex returns n cryptographically-random bytes encoded as hex. rand.Read is | 33 | // ErrHostNotEnrolled is returned by CreateVM when the target host exists but is |
| 34 | // documented never to fail (Go 1.24+), so its error is intentionally ignored. | 34 | // not accepting new VMs (e.g. it is decommissioning). |
| 35 | func RandHex(n int) string { | 35 | var ErrHostNotEnrolled = errors.New("host not accepting new VMs") |
| 36 | b := make([]byte, n) | ||
| 37 | rand.Read(b) //nolint:errcheck // crypto/rand.Read never returns an error | ||
| 38 | return hex.EncodeToString(b) | ||
| 39 | } | ||
| 40 | 36 | ||
| 41 | type Store struct { | 37 | type Store struct { |
| 42 | db *sql.DB | 38 | db *sql.DB |
| @@ -64,8 +60,9 @@ type VM struct { | |||
| 64 | // never returned in vmResponse and never logged. SSHHostCert is the matching | 60 | // never returned in vmResponse and never logged. SSHHostCert is the matching |
| 65 | // CA-signed host cert (authorized_keys form); public, but grouped here. | 61 | // CA-signed host cert (authorized_keys form); public, but grouped here. |
| 66 | SSHHostKey, SSHHostCert string | 62 | SSHHostKey, SSHHostCert string |
| 67 | CreatedAt time.Time | 63 | // The VM's reachable address is AssignedIP (the agent-reported bridge IP). |
| 68 | DeletedAt *time.Time | 64 | CreatedAt time.Time |
| 65 | DeletedAt *time.Time | ||
| 69 | } | 66 | } |
| 70 | 67 | ||
| 71 | const schema = ` | 68 | const schema = ` |
| @@ -179,6 +176,10 @@ func (s *Store) Close() error { return s.db.Close() } | |||
| 179 | // the readiness probe; the context bounds a wedged driver. | 176 | // the readiness probe; the context bounds a wedged driver. |
| 180 | func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } | 177 | func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } |
| 181 | 178 | ||
| 179 | // Epoch reads the current epoch value directly. Production callers get the | ||
| 180 | // epoch via DesiredForHost (paired with a matching desired-VM read in the | ||
| 181 | // same tx); this exists as a test/observability hook for asserting exactly | ||
| 182 | // which mutations bump the epoch. | ||
| 182 | func (s *Store) Epoch() (uint64, error) { | 183 | func (s *Store) Epoch() (uint64, error) { |
| 183 | var v uint64 | 184 | var v uint64 |
| 184 | err := s.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&v) | 185 | err := s.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM meta WHERE key='epoch'`).Scan(&v) |
| @@ -210,7 +211,7 @@ func subnetForIndex(pool netip.Prefix, idx int64) (string, error) { | |||
| 210 | } | 211 | } |
| 211 | 212 | ||
| 212 | func (s *Store) CreateEnrollmentToken() (string, error) { | 213 | func (s *Store) CreateEnrollmentToken() (string, error) { |
| 213 | tok := RandHex(32) | 214 | tok := random.Hex(32) |
| 214 | h := sha256.Sum256([]byte(tok)) | 215 | h := sha256.Sum256([]byte(tok)) |
| 215 | hash := hex.EncodeToString(h[:]) | 216 | hash := hex.EncodeToString(h[:]) |
| 216 | expiresAt := time.Now().UTC().Add(15 * time.Minute) | 217 | expiresAt := time.Now().UTC().Add(15 * time.Minute) |
| @@ -284,7 +285,7 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo | |||
| 284 | return Host{}, fmt.Errorf("read freed_cidrs: %w", err) | 285 | return Host{}, fmt.Errorf("read freed_cidrs: %w", err) |
| 285 | } | 286 | } |
| 286 | 287 | ||
| 287 | id := RandHex(16) | 288 | id := random.Hex(16) |
| 288 | 289 | ||
| 289 | if _, err := tx.Exec( | 290 | if _, err := tx.Exec( |
| 290 | `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?)`, | 291 | `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?)`, |
| @@ -296,10 +297,9 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo | |||
| 296 | // Audit in the SAME tx: the durable record of who enrolled cannot be lost | 297 | // Audit in the SAME tx: the durable record of who enrolled cannot be lost |
| 297 | // once the enrollment itself commits. The token appears only as a hash | 298 | // once the enrollment itself commits. The token appears only as a hash |
| 298 | // prefix (matches the enrollment_tokens.token_hash the mint row logs). | 299 | // prefix (matches the enrollment_tokens.token_hash the mint row logs). |
| 299 | tokSum := sha256.Sum256([]byte(tok)) | ||
| 300 | detail, _ := json.Marshal(map[string]string{ | 300 | detail, _ := json.Marshal(map[string]string{ |
| 301 | "host_id": id, "name": name, "os": osName, "arch": arch, | 301 | "host_id": id, "name": name, "os": osName, "arch": arch, |
| 302 | "remote": remote, "token_hash_prefix": hex.EncodeToString(tokSum[:])[:8], | 302 | "remote": remote, "token_hash_prefix": hash[:8], |
| 303 | }) | 303 | }) |
| 304 | if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`, | 304 | if _, err := tx.Exec(`INSERT INTO audit_log(at, action, detail) VALUES (?, ?, ?)`, |
| 305 | now.Format(time.RFC3339), "host.enroll", string(detail)); err != nil { | 305 | now.Format(time.RFC3339), "host.enroll", string(detail)); err != nil { |
| @@ -399,7 +399,19 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 399 | 399 | ||
| 400 | now := time.Now().UTC() | 400 | now := time.Now().UTC() |
| 401 | if vm.ID == "" { | 401 | if vm.ID == "" { |
| 402 | vm.ID = RandHex(16) | 402 | vm.ID = random.Hex(16) |
| 403 | } | ||
| 404 | |||
| 405 | // Refuse to place a VM on a host that is not enrolled (e.g. mid-decommission); | ||
| 406 | // the FK below only proves the host row exists, not that it accepts new VMs. | ||
| 407 | var hostStatus string | ||
| 408 | switch err := tx.QueryRow(`SELECT status FROM hosts WHERE id=?`, vm.HostID).Scan(&hostStatus); { | ||
| 409 | case errors.Is(err, sql.ErrNoRows): | ||
| 410 | return ErrHostNotFound | ||
| 411 | case err != nil: | ||
| 412 | return fmt.Errorf("lookup host status: %w", err) | ||
| 413 | case hostStatus != "enrolled": | ||
| 414 | return ErrHostNotEnrolled | ||
| 403 | } | 415 | } |
| 404 | 416 | ||
| 405 | _, err = tx.Exec( | 417 | _, err = tx.Exec( |
| @@ -476,24 +488,7 @@ func (s *Store) RestoreVM(id string) error { | |||
| 476 | // sql.ErrNoRows otherwise) and bumps the epoch. Called after the agent acks the | 488 | // sql.ErrNoRows otherwise) and bumps the epoch. Called after the agent acks the |
| 477 | // destroy. | 489 | // destroy. |
| 478 | func (s *Store) HardDeleteVM(id string) error { | 490 | func (s *Store) HardDeleteVM(id string) error { |
| 479 | tx, err := s.db.Begin() | 491 | return s.mutate(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id) |
| 480 | if err != nil { | ||
| 481 | return err | ||
| 482 | } | ||
| 483 | defer tx.Rollback() | ||
| 484 | |||
| 485 | res, err := tx.Exec(`DELETE FROM vms WHERE id=? AND deleted_at IS NOT NULL`, id) | ||
| 486 | if err != nil { | ||
| 487 | return fmt.Errorf("delete vm: %w", err) | ||
| 488 | } | ||
| 489 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 490 | return sql.ErrNoRows | ||
| 491 | } | ||
| 492 | |||
| 493 | if err := bumpEpoch(tx); err != nil { | ||
| 494 | return err | ||
| 495 | } | ||
| 496 | return tx.Commit() | ||
| 497 | } | 492 | } |
| 498 | 493 | ||
| 499 | // DecommissionHost marks a host as decommissioning and tombstones all its live | 494 | // DecommissionHost marks a host as decommissioning and tombstones all its live |
| @@ -528,10 +523,11 @@ func (s *Store) DecommissionHost(id string) error { | |||
| 528 | // Alloc is the sum of resources committed to live VMs on a host. | 523 | // Alloc is the sum of resources committed to live VMs on a host. |
| 529 | type Alloc struct{ VCPUs, MemMB, DiskGB int64 } | 524 | type Alloc struct{ VCPUs, MemMB, DiskGB int64 } |
| 530 | 525 | ||
| 531 | // AllocatedByHost returns, per host, the resources allocated to its live | 526 | // allocatedByHost returns, per host, the resources allocated to its live |
| 532 | // (non-tombstoned) VMs. Hosts with no live VMs are absent from the map. | 527 | // (non-tombstoned) VMs. Hosts with no live VMs are absent from the map. Used |
| 533 | func (s *Store) AllocatedByHost() (map[string]Alloc, error) { return allocatedByHost(s.db) } | 528 | // by Snapshot; there is no standalone exported accessor (nothing outside the |
| 534 | 529 | // package needs allocation without hosts/VMs, and Snapshot is the consistent | |
| 530 | // way to get all three together). | ||
| 535 | func allocatedByHost(q querier) (map[string]Alloc, error) { | 531 | func allocatedByHost(q querier) (map[string]Alloc, error) { |
| 536 | rows, err := q.Query(` | 532 | rows, err := q.Query(` |
| 537 | SELECT host_id, COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0) | 533 | SELECT host_id, COALESCE(SUM(vcpus),0), COALESCE(SUM(mem_mb),0), COALESCE(SUM(disk_gb),0) |
| @@ -567,12 +563,10 @@ func (s *Store) AppendAudit(action, detail string) error { | |||
| 567 | return err | 563 | return err |
| 568 | } | 564 | } |
| 569 | 565 | ||
| 570 | // ListAudit returns up to limit audit entries, newest first. | 566 | // scanAuditRows drains an `at, action, detail`-shaped result set into |
| 571 | func (s *Store) ListAudit(limit int) ([]AuditEntry, error) { | 567 | // AuditEntry values, newest-first per the caller's ORDER BY. Both audit queries |
| 572 | rows, err := s.db.Query(`SELECT at, action, detail FROM audit_log ORDER BY id DESC LIMIT ?`, limit) | 568 | // share the same three columns and RFC3339 at-parse, so they share this. |
| 573 | if err != nil { | 569 | func scanAuditRows(rows *sql.Rows) ([]AuditEntry, error) { |
| 574 | return nil, err | ||
| 575 | } | ||
| 576 | defer rows.Close() | 570 | defer rows.Close() |
| 577 | var out []AuditEntry | 571 | var out []AuditEntry |
| 578 | for rows.Next() { | 572 | for rows.Next() { |
| @@ -587,6 +581,15 @@ func (s *Store) ListAudit(limit int) ([]AuditEntry, error) { | |||
| 587 | return out, rows.Err() | 581 | return out, rows.Err() |
| 588 | } | 582 | } |
| 589 | 583 | ||
| 584 | // ListAudit returns up to limit audit entries, newest first. | ||
| 585 | func (s *Store) ListAudit(limit int) ([]AuditEntry, error) { | ||
| 586 | rows, err := s.db.Query(`SELECT at, action, detail FROM audit_log ORDER BY id DESC LIMIT ?`, limit) | ||
| 587 | if err != nil { | ||
| 588 | return nil, err | ||
| 589 | } | ||
| 590 | return scanAuditRows(rows) | ||
| 591 | } | ||
| 592 | |||
| 590 | // ListVMEvents returns up to limit audit rows whose detail JSON carries the | 593 | // ListVMEvents returns up to limit audit rows whose detail JSON carries the |
| 591 | // given vm_id (the lifecycle timeline for one VM), newest first. It filters on | 594 | // given vm_id (the lifecycle timeline for one VM), newest first. It filters on |
| 592 | // json_extract(detail,'$.vm_id'), so every lifecycle emitter must key the VM id | 595 | // json_extract(detail,'$.vm_id'), so every lifecycle emitter must key the VM id |
| @@ -600,18 +603,7 @@ func (s *Store) ListVMEvents(vmID string, limit int) ([]AuditEntry, error) { | |||
| 600 | if err != nil { | 603 | if err != nil { |
| 601 | return nil, err | 604 | return nil, err |
| 602 | } | 605 | } |
| 603 | defer rows.Close() | 606 | return scanAuditRows(rows) |
| 604 | var out []AuditEntry | ||
| 605 | for rows.Next() { | ||
| 606 | var e AuditEntry | ||
| 607 | var at string | ||
| 608 | if err := rows.Scan(&at, &e.Action, &e.Detail); err != nil { | ||
| 609 | return nil, err | ||
| 610 | } | ||
| 611 | e.At, _ = time.Parse(time.RFC3339, at) | ||
| 612 | out = append(out, e) | ||
| 613 | } | ||
| 614 | return out, rows.Err() | ||
| 615 | } | 607 | } |
| 616 | 608 | ||
| 617 | // PruneAudit deletes audit rows older than olderThan and reports how many | 609 | // PruneAudit deletes audit rows older than olderThan and reports how many |
| @@ -691,18 +683,9 @@ func (s *Store) ListRevokedSSHCerts() ([]RevokedCert, error) { | |||
| 691 | return out, rows.Err() | 683 | return out, rows.Err() |
| 692 | } | 684 | } |
| 693 | 685 | ||
| 694 | // HostVMCount returns the number of VM rows for a host (live + tombstoned). | ||
| 695 | // Rows are hard-deleted only after the agent acks destroy, so a count of 0 means | ||
| 696 | // the host is fully drained. | ||
| 697 | func (s *Store) HostVMCount(id string) (int, error) { | ||
| 698 | var n int | ||
| 699 | err := s.db.QueryRow(`SELECT COUNT(*) FROM vms WHERE host_id=?`, id).Scan(&n) | ||
| 700 | return n, err | ||
| 701 | } | ||
| 702 | |||
| 703 | // RemoveHost finalizes decommission: it returns the host's bridge CIDR to the | 686 | // RemoveHost finalizes decommission: it returns the host's bridge CIDR to the |
| 704 | // pool and deletes the host row. It refuses while any VM rows remain (not yet | 687 | // pool and deletes the host row. It refuses (in-transaction) while any VM rows |
| 705 | // reaped), so it must be called only after HostVMCount reaches 0. | 688 | // remain for the host (not yet reaped). |
| 706 | func (s *Store) RemoveHost(id string) error { | 689 | func (s *Store) RemoveHost(id string) error { |
| 707 | tx, err := s.db.Begin() | 690 | tx, err := s.db.Begin() |
| 708 | if err != nil { | 691 | if err != nil { |
| @@ -738,26 +721,89 @@ func (s *Store) RemoveHost(id string) error { | |||
| 738 | return tx.Commit() | 721 | return tx.Commit() |
| 739 | } | 722 | } |
| 740 | 723 | ||
| 724 | // ForceRemoveHost finalizes a host whose agent will never drain it (dead | ||
| 725 | // hardware): it purges every VM row for the host, returns the bridge CIDR to | ||
| 726 | // the pool, and deletes the host row — all in one transaction. Unlike the | ||
| 727 | // graceful path it does NOT wait for the agent to ack destroys, so it must only | ||
| 728 | // be used when the host is known gone; any VMs still physically running are | ||
| 729 | // orphaned with the hardware. Returns the number of VM rows purged. | ||
| 730 | func (s *Store) ForceRemoveHost(id string) (int, error) { | ||
| 731 | tx, err := s.db.Begin() | ||
| 732 | if err != nil { | ||
| 733 | return 0, err | ||
| 734 | } | ||
| 735 | defer tx.Rollback() | ||
| 736 | |||
| 737 | var bridgeCIDR string | ||
| 738 | switch err := tx.QueryRow(`SELECT bridge_cidr FROM hosts WHERE id=?`, id).Scan(&bridgeCIDR); { | ||
| 739 | case errors.Is(err, sql.ErrNoRows): | ||
| 740 | return 0, sql.ErrNoRows | ||
| 741 | case err != nil: | ||
| 742 | return 0, fmt.Errorf("lookup host cidr: %w", err) | ||
| 743 | } | ||
| 744 | |||
| 745 | res, err := tx.Exec(`DELETE FROM vms WHERE host_id=?`, id) | ||
| 746 | if err != nil { | ||
| 747 | return 0, fmt.Errorf("purge host vms: %w", err) | ||
| 748 | } | ||
| 749 | purged, _ := res.RowsAffected() | ||
| 750 | |||
| 751 | if _, err := tx.Exec(`INSERT INTO freed_cidrs(bridge_cidr) VALUES(?) ON CONFLICT DO NOTHING`, bridgeCIDR); err != nil { | ||
| 752 | return 0, fmt.Errorf("free cidr: %w", err) | ||
| 753 | } | ||
| 754 | if _, err := tx.Exec(`DELETE FROM hosts WHERE id=?`, id); err != nil { | ||
| 755 | return 0, fmt.Errorf("delete host: %w", err) | ||
| 756 | } | ||
| 757 | if err := bumpEpoch(tx); err != nil { | ||
| 758 | return 0, fmt.Errorf("bump epoch: %w", err) | ||
| 759 | } | ||
| 760 | if err := tx.Commit(); err != nil { | ||
| 761 | return 0, err | ||
| 762 | } | ||
| 763 | return int(purged), nil | ||
| 764 | } | ||
| 765 | |||
| 766 | // ipWithinHostCIDR reports whether ip falls inside the owning host's bridge_cidr. | ||
| 767 | // A missing row, an unparseable stored CIDR, or an unparseable ip all yield | ||
| 768 | // (false, nil) — the IP simply cannot be validated, so callers drop it. Only a | ||
| 769 | // genuine DB error on the lookup is surfaced. | ||
| 770 | func (s *Store) ipWithinHostCIDR(vmID, ip string) (bool, error) { | ||
| 771 | var cidrStr string | ||
| 772 | switch err := s.db.QueryRow( | ||
| 773 | `SELECT h.bridge_cidr FROM vms v JOIN hosts h ON h.id = v.host_id WHERE v.id=?`, vmID, | ||
| 774 | ).Scan(&cidrStr); { | ||
| 775 | case errors.Is(err, sql.ErrNoRows): | ||
| 776 | return false, nil | ||
| 777 | case err != nil: | ||
| 778 | return false, fmt.Errorf("lookup host cidr: %w", err) | ||
| 779 | } | ||
| 780 | prefix, err := netip.ParsePrefix(cidrStr) | ||
| 781 | if err != nil { | ||
| 782 | return false, nil | ||
| 783 | } | ||
| 784 | addr, err := netip.ParseAddr(ip) | ||
| 785 | if err != nil { | ||
| 786 | return false, nil | ||
| 787 | } | ||
| 788 | return prefix.Contains(addr), nil | ||
| 789 | } | ||
| 790 | |||
| 741 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | 791 | func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { |
| 792 | // A reported IP outside the owning host's bridge_cidr (a DHCP hiccup, a | ||
| 793 | // link-local/APIPA address, or an agent bug) must NOT block the (status, | ||
| 794 | // last_error) transition: a genuinely-failed VM still needs status=failed | ||
| 795 | // persisted durably. So an out-of-CIDR (or unparseable) IP is DROPPED — | ||
| 796 | // assigned_ip keeps its prior value via the CASE below — while the status | ||
| 797 | // still writes. This preserves the integrity guarantee (a bogus IP never | ||
| 798 | // lands in the row) without stalling the transition or spamming per-tick | ||
| 799 | // rejection warnings from syncsvc.applyReport. | ||
| 742 | if ip != "" { | 800 | if ip != "" { |
| 743 | // Validate IP is within the owning host's bridge_cidr. | 801 | within, err := s.ipWithinHostCIDR(id, ip) |
| 744 | var cidrStr string | ||
| 745 | err := s.db.QueryRow( | ||
| 746 | `SELECT h.bridge_cidr FROM vms v JOIN hosts h ON h.id = v.host_id WHERE v.id=?`, id, | ||
| 747 | ).Scan(&cidrStr) | ||
| 748 | if err != nil { | ||
| 749 | return fmt.Errorf("lookup host cidr: %w", err) | ||
| 750 | } | ||
| 751 | prefix, err := netip.ParsePrefix(cidrStr) | ||
| 752 | if err != nil { | 802 | if err != nil { |
| 753 | return fmt.Errorf("parse bridge_cidr: %w", err) | 803 | return err |
| 754 | } | 804 | } |
| 755 | addr, err := netip.ParseAddr(ip) | 805 | if !within { |
| 756 | if err != nil { | 806 | ip = "" |
| 757 | return fmt.Errorf("parse ip: %w", err) | ||
| 758 | } | ||
| 759 | if !prefix.Contains(addr) { | ||
| 760 | return fmt.Errorf("ip %s is outside host cidr %s", ip, cidrStr) | ||
| 761 | } | 807 | } |
| 762 | } | 808 | } |
| 763 | 809 | ||
| @@ -775,8 +821,18 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | |||
| 775 | return nil | 821 | return nil |
| 776 | } | 822 | } |
| 777 | 823 | ||
| 778 | // scanVM's column order must match the SELECT lists in listVMs and | 824 | // vmColumns is the positional column list every VM SELECT must use, so the |
| 779 | // DesiredForHost exactly — it is positional, not name-based. | 825 | // order stays locked to scanVM's Scan below (which is positional, not |
| 826 | // name-based). queryVMs is the sole caller, so adding a column is a single | ||
| 827 | // edit here plus scanVM — every VM query goes through it and can't drift out | ||
| 828 | // of lockstep. | ||
| 829 | const vmColumns = `id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | ||
| 830 | ssh_host_key, ssh_host_cert, | ||
| 831 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | ||
| 832 | created_at, deleted_at` | ||
| 833 | |||
| 834 | // scanVM's column order must match vmColumns exactly — it is positional, not | ||
| 835 | // name-based. | ||
| 780 | func scanVM(rows *sql.Rows) (VM, error) { | 836 | func scanVM(rows *sql.Rows) (VM, error) { |
| 781 | var vm VM | 837 | var vm VM |
| 782 | var createdAt string | 838 | var createdAt string |
| @@ -799,15 +855,13 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 799 | return vm, nil | 855 | return vm, nil |
| 800 | } | 856 | } |
| 801 | 857 | ||
| 802 | func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) } | 858 | // queryVMs runs a vmColumns-projected SELECT against vms, with where appended |
| 803 | 859 | // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args | |
| 804 | func listVMs(q querier) ([]VM, error) { | 860 | // bound in order, scanning every matching row. listVMs, VMByName, GetVM, and |
| 805 | rows, err := q.Query( | 861 | // DesiredForHost all share this — they differ only in WHERE clause, row-count |
| 806 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 862 | // expectations, and whether q is *sql.DB or an in-flight *sql.Tx. |
| 807 | ssh_host_key, ssh_host_cert, | 863 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { |
| 808 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 864 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) |
| 809 | created_at, deleted_at FROM vms`, | ||
| 810 | ) | ||
| 811 | if err != nil { | 865 | if err != nil { |
| 812 | return nil, err | 866 | return nil, err |
| 813 | } | 867 | } |
| @@ -823,28 +877,38 @@ func listVMs(q querier) ([]VM, error) { | |||
| 823 | return vms, rows.Err() | 877 | return vms, rows.Err() |
| 824 | } | 878 | } |
| 825 | 879 | ||
| 880 | func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) } | ||
| 881 | |||
| 882 | func listVMs(q querier) ([]VM, error) { return queryVMs(q, "") } | ||
| 883 | |||
| 826 | // VMByName returns the live (non-tombstoned) VM with the given name. The | 884 | // VMByName returns the live (non-tombstoned) VM with the given name. The |
| 827 | // vms_name unique index guarantees at most one match. sql.ErrNoRows ⇒ no such | 885 | // vms_name unique index guarantees at most one match. sql.ErrNoRows ⇒ no such |
| 828 | // VM. Read-only; used by the SSH jump gate to resolve `ssh -J gate user@<name>` | 886 | // VM. Read-only; used by the SSH jump gate to resolve `ssh -J gate user@<name>` |
| 829 | // to a host/VM ID pair. | 887 | // to a host/VM ID pair. |
| 830 | func (s *Store) VMByName(name string) (VM, error) { | 888 | func (s *Store) VMByName(name string) (VM, error) { |
| 831 | rows, err := s.db.Query( | 889 | vms, err := queryVMs(s.db, ` WHERE name=? AND deleted_at IS NULL`, name) |
| 832 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | ||
| 833 | ssh_host_key, ssh_host_cert, | ||
| 834 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | ||
| 835 | created_at, deleted_at FROM vms WHERE name=? AND deleted_at IS NULL`, name, | ||
| 836 | ) | ||
| 837 | if err != nil { | 890 | if err != nil { |
| 838 | return VM{}, err | 891 | return VM{}, err |
| 839 | } | 892 | } |
| 840 | defer rows.Close() | 893 | if len(vms) == 0 { |
| 841 | if !rows.Next() { | 894 | return VM{}, sql.ErrNoRows |
| 842 | if err := rows.Err(); err != nil { | 895 | } |
| 843 | return VM{}, err | 896 | return vms[0], nil |
| 844 | } | 897 | } |
| 898 | |||
| 899 | // GetVM returns the VM with the given id via a single indexed lookup on the | ||
| 900 | // primary key. Unlike VMByName it does NOT filter on deleted_at: the | ||
| 901 | // patch/delete/restore callers operate on VMs that may be tombstoned, so the | ||
| 902 | // row must be found regardless of tombstone state. sql.ErrNoRows ⇒ no such VM. | ||
| 903 | func (s *Store) GetVM(id string) (VM, error) { | ||
| 904 | vms, err := queryVMs(s.db, ` WHERE id=?`, id) | ||
| 905 | if err != nil { | ||
| 906 | return VM{}, err | ||
| 907 | } | ||
| 908 | if len(vms) == 0 { | ||
| 845 | return VM{}, sql.ErrNoRows | 909 | return VM{}, sql.ErrNoRows |
| 846 | } | 910 | } |
| 847 | return scanVM(rows) | 911 | return vms[0], nil |
| 848 | } | 912 | } |
| 849 | 913 | ||
| 850 | // Snapshot reads hosts, per-host allocation, and VMs in a single read | 914 | // Snapshot reads hosts, per-host allocation, and VMs in a single read |
| @@ -884,28 +948,10 @@ func (s *Store) DesiredForHost(hostID string) (uint64, []VM, error) { | |||
| 884 | return 0, nil, fmt.Errorf("read epoch: %w", err) | 948 | return 0, nil, fmt.Errorf("read epoch: %w", err) |
| 885 | } | 949 | } |
| 886 | 950 | ||
| 887 | rows, err := tx.Query( | 951 | vms, err := queryVMs(tx, ` WHERE host_id=?`, hostID) |
| 888 | `SELECT id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | ||
| 889 | ssh_host_key, ssh_host_cert, | ||
| 890 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | ||
| 891 | created_at, deleted_at FROM vms WHERE host_id=?`, hostID, | ||
| 892 | ) | ||
| 893 | if err != nil { | 952 | if err != nil { |
| 894 | return 0, nil, err | 953 | return 0, nil, err |
| 895 | } | 954 | } |
| 896 | defer rows.Close() | ||
| 897 | |||
| 898 | var vms []VM | ||
| 899 | for rows.Next() { | ||
| 900 | vm, err := scanVM(rows) | ||
| 901 | if err != nil { | ||
| 902 | return 0, nil, err | ||
| 903 | } | ||
| 904 | vms = append(vms, vm) | ||
| 905 | } | ||
| 906 | if err := rows.Err(); err != nil { | ||
| 907 | return 0, nil, err | ||
| 908 | } | ||
| 909 | 955 | ||
| 910 | if err := tx.Commit(); err != nil { | 956 | if err := tx.Commit(); err != nil { |
| 911 | return 0, nil, err | 957 | return 0, nil, err |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "testing" | 7 | "testing" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
| 10 | "github.com/a73x/eitri/internal/random" | ||
| 10 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" |
| 11 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" |
| 12 | ) | 13 | ) |
| @@ -56,6 +57,30 @@ func TestVMByName(t *testing.T) { | |||
| 56 | assert.ErrorIs(t, err, sql.ErrNoRows) | 57 | assert.ErrorIs(t, err, sql.ErrNoRows) |
| 57 | } | 58 | } |
| 58 | 59 | ||
| 60 | func TestGetVM(t *testing.T) { | ||
| 61 | s := newStore(t) | ||
| 62 | h := enrollHost(t, s) | ||
| 63 | vm := makeVM(t, s, h, "web-1") | ||
| 64 | |||
| 65 | got, err := s.GetVM(vm.ID) | ||
| 66 | require.NoError(t, err) | ||
| 67 | assert.Equal(t, vm.ID, got.ID) | ||
| 68 | assert.Equal(t, vm.Name, got.Name) | ||
| 69 | assert.Equal(t, h.ID, got.HostID) | ||
| 70 | |||
| 71 | // Unknown id ⇒ ErrNoRows (callers read this as not-found). | ||
| 72 | _, err = s.GetVM("no-such-id") | ||
| 73 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 74 | |||
| 75 | // Unlike VMByName, GetVM must still find a tombstoned row — the | ||
| 76 | // patch/delete/restore callers operate on VMs that may be tombstoned. | ||
| 77 | require.NoError(t, s.TombstoneVM(vm.ID)) | ||
| 78 | got, err = s.GetVM(vm.ID) | ||
| 79 | require.NoError(t, err) | ||
| 80 | assert.Equal(t, vm.ID, got.ID) | ||
| 81 | assert.NotNil(t, got.DeletedAt) | ||
| 82 | } | ||
| 83 | |||
| 59 | func TestVMHostKeyAndCertPersist(t *testing.T) { | 84 | func TestVMHostKeyAndCertPersist(t *testing.T) { |
| 60 | s := newStore(t) | 85 | s := newStore(t) |
| 61 | h := enrollHost(t, s) | 86 | h := enrollHost(t, s) |
| @@ -206,14 +231,27 @@ func TestDesiredForHostIncludesTombstonedAndEpochConsistently(t *testing.T) { | |||
| 206 | assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked") | 231 | assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked") |
| 207 | } | 232 | } |
| 208 | 233 | ||
| 209 | func TestRecordVMStatusValidatesIPWithinHostCIDR(t *testing.T) { | 234 | func TestRecordVMStatusDropsOutOfCIDRIPButKeepsStatus(t *testing.T) { |
| 210 | s := newStore(t) | 235 | s := newStore(t) |
| 211 | h := enrollHost(t, s) // 10.77.1.0/24 | 236 | h := enrollHost(t, s) // 10.77.1.0/24 |
| 212 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", | 237 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", |
| 213 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | 238 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) |
| 214 | assert.Error(t, s.RecordVMStatus("vm1", "ready", "", "10.77.2.9"), | 239 | |
| 215 | "IP outside the host's CIDR must be rejected") | 240 | // A good IP lands. |
| 216 | assert.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.9")) | 241 | require.NoError(t, s.RecordVMStatus("vm1", "ready", "", "10.77.1.9")) |
| 242 | vm, err := s.GetVM("vm1") | ||
| 243 | require.NoError(t, err) | ||
| 244 | assert.Equal(t, "10.77.1.9", vm.AssignedIP) | ||
| 245 | |||
| 246 | // An out-of-CIDR IP alongside a failed transition must NOT block the status | ||
| 247 | // write, and must NOT overwrite the prior good IP with the bogus value. | ||
| 248 | require.NoError(t, s.RecordVMStatus("vm1", "failed", "boom", "10.77.2.9"), | ||
| 249 | "out-of-CIDR IP must not fail the status write") | ||
| 250 | vm, err = s.GetVM("vm1") | ||
| 251 | require.NoError(t, err) | ||
| 252 | assert.Equal(t, "failed", vm.Status, "status transition must persist despite the bad IP") | ||
| 253 | assert.Equal(t, "boom", vm.LastError) | ||
| 254 | assert.Equal(t, "10.77.1.9", vm.AssignedIP, "bogus IP must be dropped, prior kept") | ||
| 217 | } | 255 | } |
| 218 | 256 | ||
| 219 | func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { | 257 | func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { |
| @@ -245,6 +283,55 @@ func TestRecordVMStatusUnknownVMErrors(t *testing.T) { | |||
| 245 | assert.Error(t, err) | 283 | assert.Error(t, err) |
| 246 | } | 284 | } |
| 247 | 285 | ||
| 286 | func TestForceRemoveHostPurgesVMsAndFreesCIDR(t *testing.T) { | ||
| 287 | s := newStore(t) | ||
| 288 | h := enrollHost(t, s) | ||
| 289 | makeVM(t, s, h, "vm-a") | ||
| 290 | vmB := makeVM(t, s, h, "vm-b") | ||
| 291 | // Tombstone one of the two so the purge is exercised against both live and | ||
| 292 | // tombstoned rows (ForceRemoveHost deletes by host_id with no deleted_at | ||
| 293 | // filter, so a tombstoned row is purged — and counted — like a live one). | ||
| 294 | require.NoError(t, s.TombstoneVM(vmB.ID)) | ||
| 295 | |||
| 296 | purged, err := s.ForceRemoveHost(h.ID) | ||
| 297 | require.NoError(t, err) | ||
| 298 | assert.Equal(t, 2, purged, "both VM rows — live and tombstoned — must be purged") | ||
| 299 | |||
| 300 | _, err = s.GetHost(h.ID) | ||
| 301 | assert.Error(t, err, "host row must be gone") | ||
| 302 | |||
| 303 | // Independently verify against the DB, not just the return value: no VM | ||
| 304 | // row (including tombstoned ones) may remain for the purged host. | ||
| 305 | vms, err := s.ListVMs() | ||
| 306 | require.NoError(t, err) | ||
| 307 | for _, vm := range vms { | ||
| 308 | assert.NotEqual(t, h.ID, vm.HostID, "no VM rows should remain for the force-removed host") | ||
| 309 | } | ||
| 310 | |||
| 311 | // The freed CIDR is consulted before the monotonic allocator, so a fresh | ||
| 312 | // enrollment reuses it. | ||
| 313 | tok, err := s.CreateEnrollmentToken() | ||
| 314 | require.NoError(t, err) | ||
| 315 | h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "") | ||
| 316 | require.NoError(t, err) | ||
| 317 | assert.Equal(t, h.BridgeCIDR, h2.BridgeCIDR, "force-removed host's CIDR must return to the pool") | ||
| 318 | } | ||
| 319 | |||
| 320 | func TestForceRemoveHostUnknownIsNoRows(t *testing.T) { | ||
| 321 | s := newStore(t) | ||
| 322 | _, err := s.ForceRemoveHost("nope") | ||
| 323 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 324 | } | ||
| 325 | |||
| 326 | func TestCreateVMRejectsNonEnrolledHost(t *testing.T) { | ||
| 327 | s := newStore(t) | ||
| 328 | h := enrollHost(t, s) | ||
| 329 | require.NoError(t, s.DecommissionHost(h.ID)) | ||
| 330 | err := s.CreateVM(VM{ID: "late", HostID: h.ID, Name: "late", ImageURL: "u", | ||
| 331 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}) | ||
| 332 | assert.ErrorIs(t, err, ErrHostNotEnrolled) | ||
| 333 | } | ||
| 334 | |||
| 248 | func TestServerCertLoadOrCreatePersists(t *testing.T) { | 335 | func TestServerCertLoadOrCreatePersists(t *testing.T) { |
| 249 | dir := t.TempDir() | 336 | dir := t.TempDir() |
| 250 | st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16") | 337 | st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16") |
| @@ -282,10 +369,22 @@ func TestSnapshotReadsAllThreeConsistently(t *testing.T) { | |||
| 282 | 369 | ||
| 283 | wantHosts, err := s.ListHosts() | 370 | wantHosts, err := s.ListHosts() |
| 284 | require.NoError(t, err) | 371 | require.NoError(t, err) |
| 285 | wantAlloc, err := s.AllocatedByHost() | ||
| 286 | require.NoError(t, err) | ||
| 287 | wantVMs, err := s.ListVMs() | 372 | wantVMs, err := s.ListVMs() |
| 288 | require.NoError(t, err) | 373 | require.NoError(t, err) |
| 374 | // Derive the expected allocation from the independently-read VM list | ||
| 375 | // (rather than a second Snapshot() call, which would just check Snapshot | ||
| 376 | // against itself) — the same pattern TestSnapshotIsAtomicUnderConcurrentWrites | ||
| 377 | // uses to pin that alloc is exactly the sum of live VM resources per host. | ||
| 378 | wantAlloc := map[string]Alloc{} | ||
| 379 | for _, vm := range wantVMs { | ||
| 380 | if vm.DeletedAt == nil { | ||
| 381 | a := wantAlloc[vm.HostID] | ||
| 382 | a.VCPUs += vm.VCPUs | ||
| 383 | a.MemMB += vm.MemMB | ||
| 384 | a.DiskGB += vm.DiskGB | ||
| 385 | wantAlloc[vm.HostID] = a | ||
| 386 | } | ||
| 387 | } | ||
| 289 | 388 | ||
| 290 | assert.Equal(t, wantHosts, hosts) | 389 | assert.Equal(t, wantHosts, hosts) |
| 291 | assert.Equal(t, wantAlloc, alloc) | 390 | assert.Equal(t, wantAlloc, alloc) |
| @@ -306,7 +405,7 @@ func TestSnapshotIsAtomicUnderConcurrentWrites(t *testing.T) { | |||
| 306 | go func() { | 405 | go func() { |
| 307 | defer close(done) | 406 | defer close(done) |
| 308 | for i := 0; i < 300; i++ { | 407 | for i := 0; i < 300; i++ { |
| 309 | id := RandHex(8) | 408 | id := random.Hex(8) |
| 310 | _ = s.CreateVM(VM{ID: id, HostID: h.ID, Name: "vm-" + id, | 409 | _ = s.CreateVM(VM{ID: id, HostID: h.ID, Name: "vm-" + id, |
| 311 | ImageURL: "http://x/i", ImageSHA256: "abc", | 410 | ImageURL: "http://x/i", ImageSHA256: "abc", |
| 312 | VCPUs: 1, MemMB: 256, DiskGB: 1, PowerState: "running"}) | 411 | VCPUs: 1, MemMB: 256, DiskGB: 1, PowerState: "running"}) |
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -26,12 +26,26 @@ import ( | |||
| 26 | // fills. | 26 | // fills. |
| 27 | const defaultWriteTimeout = 30 * time.Second | 27 | const defaultWriteTimeout = 30 * time.Second |
| 28 | 28 | ||
| 29 | // vmStatusRecorder is the durable-write seam applyReport uses to record VM | ||
| 30 | // lifecycle status. *store.Store satisfies it. Keeping it a field (rather than a | ||
| 31 | // direct s.st call) lets tests substitute a counting fake to prove that | ||
| 32 | // unchanged reports perform no write. | ||
| 33 | type vmStatusRecorder interface { | ||
| 34 | RecordVMStatus(id, status, lastErr, ip string) error | ||
| 35 | } | ||
| 36 | |||
| 29 | // Service is the QUIC server end of the agent reconcile stream. | 37 | // Service is the QUIC server end of the agent reconcile stream. |
| 30 | type Service struct { | 38 | type Service struct { |
| 31 | st *store.Store | 39 | st *store.Store |
| 32 | reg *registry.Registry | 40 | reg *registry.Registry |
| 33 | hub *hub.Hub | 41 | hub *hub.Hub |
| 34 | secret []byte | 42 | secret []byte |
| 43 | // recorder is the durable VM-status write seam (defaults to st). tracker | ||
| 44 | // remembers the last durably-written status per VM so applyReport can skip | ||
| 45 | // RecordVMStatus (a SELECT+UPDATE on the single SQLite conn) when nothing | ||
| 46 | // changed. | ||
| 47 | recorder vmStatusRecorder | ||
| 48 | tracker *statusTracker | ||
| 35 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. | 49 | // maxCredAge, when non-zero, rejects credentials whose issued-at is older. |
| 36 | // Zero disables the age check (default: expiry without an auto-renewal | 50 | // Zero disables the age check (default: expiry without an auto-renewal |
| 37 | // channel would force periodic re-enrolls; per-host generation revocation | 51 | // channel would force periodic re-enrolls; per-host generation revocation |
| @@ -72,7 +86,7 @@ func newWithWriteTimeout(st *store.Store, reg *registry.Registry, h *hub.Hub, se | |||
| 72 | writeTimeout = defaultWriteTimeout | 86 | writeTimeout = defaultWriteTimeout |
| 73 | } | 87 | } |
| 74 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, | 88 | return &Service{st: st, reg: reg, hub: h, secret: secret, maxCredAge: maxCredAge, writeTimeout: writeTimeout, |
| 75 | conns: map[string]quic.Connection{}} | 89 | conns: map[string]quic.Connection{}, recorder: st, tracker: newStatusTracker()} |
| 76 | } | 90 | } |
| 77 | 91 | ||
| 78 | // Serve accepts QUIC connections until ctx is cancelled. | 92 | // Serve accepts QUIC connections until ctx is cancelled. |
| @@ -86,6 +100,19 @@ func (s *Service) Serve(ctx context.Context, lis *quic.Listener) error { | |||
| 86 | } | 100 | } |
| 87 | } | 101 | } |
| 88 | 102 | ||
| 103 | // credStale evaluates a credential's claims against the host row's current | ||
| 104 | // generation: stale is true when the credential has been revoked (its | ||
| 105 | // generation no longer matches the host's current one) or has exceeded | ||
| 106 | // maxCredAge (expired, reported separately so callers that log an "expired" | ||
| 107 | // field can distinguish the reason). It is pure — no I/O — so callers decide | ||
| 108 | // when to fetch hostRow and what to do with a stale verdict (initial auth | ||
| 109 | // rejects; the per-report re-check closes the session). | ||
| 110 | func (s *Service) credStale(claims hosttoken.Claims, hostRow store.Host) (stale, expired bool) { | ||
| 111 | expired = s.maxCredAge > 0 && time.Since(claims.IssuedAt) > s.maxCredAge | ||
| 112 | stale = expired || claims.Generation != hostRow.CredGeneration | ||
| 113 | return stale, expired | ||
| 114 | } | ||
| 115 | |||
| 89 | // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and | 116 | // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and |
| 90 | // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+). | 117 | // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+). |
| 91 | func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | 118 | func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { |
| @@ -112,20 +139,27 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 112 | return | 139 | return |
| 113 | } | 140 | } |
| 114 | hostID := claims.HostID | 141 | hostID := claims.HostID |
| 115 | if s.maxCredAge > 0 && time.Since(claims.IssuedAt) > s.maxCredAge { | ||
| 116 | _ = conn.CloseWithError(transport.CodeAuthRejected, "credential expired — re-enroll this host") | ||
| 117 | return | ||
| 118 | } | ||
| 119 | hostRow, err := s.st.GetHost(hostID) | 142 | hostRow, err := s.st.GetHost(hostID) |
| 120 | if err != nil { | 143 | if err != nil { |
| 121 | _ = conn.CloseWithError(transport.CodeAuthRejected, "host not found") | 144 | _ = conn.CloseWithError(transport.CodeAuthRejected, "host not found") |
| 122 | return | 145 | return |
| 123 | } | 146 | } |
| 124 | // Per-host revocation: a credential minted at an older generation is dead. | 147 | // credStale covers both per-host revocation (a credential minted at an |
| 125 | if claims.Generation != hostRow.CredGeneration { | 148 | // older generation is dead) and max-age expiry in a single predicate; the |
| 126 | _ = conn.CloseWithError(transport.CodeAuthRejected, "credential revoked — re-enroll this host") | 149 | // close message distinguishes the two so an operator reading the close |
| 150 | // reason can tell a routine re-enroll (expiry) from something more | ||
| 151 | // suspicious (revocation). The ordering is deliberate: host-not-found is | ||
| 152 | // checked BEFORE expiry, so it wins for a purged host — earlier code | ||
| 153 | // rejected an expired credential before ever touching the DB. | ||
| 154 | if stale, expired := s.credStale(claims, hostRow); stale { | ||
| 155 | msg := "credential revoked — re-enroll this host" | ||
| 156 | if expired { | ||
| 157 | msg = "credential expired — re-enroll this host" | ||
| 158 | } | ||
| 159 | _ = conn.CloseWithError(transport.CodeAuthRejected, msg) | ||
| 127 | return | 160 | return |
| 128 | } | 161 | } |
| 162 | s.reg.RecordConnect(hostID) | ||
| 129 | slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch()) | 163 | slog.Info("agent connected", "host", hostID, "provisioner", h.GetProvisioner(), "last_seen_epoch", h.GetLastSeenEpoch()) |
| 130 | 164 | ||
| 131 | // Down-stream: server opens it; first write makes it visible to the agent. | 165 | // Down-stream: server opens it; first write makes it visible to the agent. |
| @@ -192,8 +226,7 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 192 | _ = conn.CloseWithError(0, "credential re-check unavailable") | 226 | _ = conn.CloseWithError(0, "credential re-check unavailable") |
| 193 | return | 227 | return |
| 194 | } | 228 | } |
| 195 | expired := s.maxCredAge > 0 && time.Since(claims.IssuedAt) > s.maxCredAge | 229 | if stale, expired := s.credStale(claims, row); stale { |
| 196 | if row.CredGeneration != claims.Generation || expired { | ||
| 197 | slog.Info("credential no longer valid mid-session; closing", "host", hostID, "expired", expired) | 230 | slog.Info("credential no longer valid mid-session; closing", "host", hostID, "expired", expired) |
| 198 | _ = conn.CloseWithError(transport.CodeAuthRejected, "credential revoked or expired — re-enroll this host") | 231 | _ = conn.CloseWithError(transport.CodeAuthRejected, "credential revoked or expired — re-enroll this host") |
| 199 | return | 232 | return |
| @@ -267,13 +300,27 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 267 | s.reg.UpdateReport(hostID, r) | 300 | s.reg.UpdateReport(hostID, r) |
| 268 | 301 | ||
| 269 | // Write-through durable status for lifecycle phases ready/failed only. | 302 | // Write-through durable status for lifecycle phases ready/failed only. |
| 303 | // Skip the SELECT+UPDATE when the durable triple (status, last_error, | ||
| 304 | // effective assigned_ip) is unchanged from the last write we recorded. | ||
| 305 | // writeThrough runs decide→write→commit atomically per VM so overlapping | ||
| 306 | // reports for the same host can never reorder the write and the cache commit; | ||
| 307 | // the cache is updated only after a successful write, so a rejected write | ||
| 308 | // never suppresses the next retry. | ||
| 270 | for _, v := range rep.GetVms() { | 309 | for _, v := range rep.GetVms() { |
| 271 | phase := v.GetPhase() | 310 | phase := v.GetPhase() |
| 272 | if phase != "ready" && phase != "failed" { | 311 | if phase != "ready" && phase != "failed" { |
| 273 | continue | 312 | continue |
| 274 | } | 313 | } |
| 275 | if err := s.st.RecordVMStatus(v.GetVmId(), phase, v.GetLastError(), v.GetIp()); err != nil { | 314 | vmID := v.GetVmId() |
| 276 | slog.Warn("RecordVMStatus rejected", "vm", v.GetVmId(), "host", hostID, "err", err) | 315 | // Pass the RAW reported ip to RecordVMStatus to preserve its |
| 316 | // empty-ip-keeps-prior UPDATE semantics. | ||
| 317 | err := s.tracker.writeThrough(vmID, phase, v.GetLastError(), v.GetIp(), func() error { | ||
| 318 | return s.recorder.RecordVMStatus(vmID, phase, v.GetLastError(), v.GetIp()) | ||
| 319 | }) | ||
| 320 | if err != nil { | ||
| 321 | // ErrNoRows (row gone) or a validation error: the write did not land | ||
| 322 | // and the cache is unchanged, so the next report retries. | ||
| 323 | slog.Warn("RecordVMStatus rejected", "vm", vmID, "host", hostID, "err", err) | ||
| 277 | } | 324 | } |
| 278 | } | 325 | } |
| 279 | 326 | ||
| @@ -289,6 +336,11 @@ func (s *Service) applyReport(hostID string, rep *pb.ActualStateReport) { | |||
| 289 | // and re-acks every tick forever (log spam) until some other edit pokes it. | 336 | // and re-acks every tick forever (log spam) until some other edit pokes it. |
| 290 | anyDeleted := false | 337 | anyDeleted := false |
| 291 | for _, id := range rep.GetDestroyed() { | 338 | for _, id := range rep.GetDestroyed() { |
| 339 | // Prune the dedup cache: this VM's row is being hard-deleted, so its | ||
| 340 | // tracked status is dead weight (and a future id reuse must not inherit | ||
| 341 | // a stale cached triple — ids are unique, but forgetting is the correct, | ||
| 342 | // memory-bounding thing regardless of whether the delete succeeds). | ||
| 343 | s.tracker.forget(id) | ||
| 292 | if err := s.st.HardDeleteVM(id); err != nil { | 344 | if err := s.st.HardDeleteVM(id); err != nil { |
| 293 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) | 345 | slog.Warn("HardDeleteVM failed", "vm", id, "host", hostID, "err", err) |
| 294 | } else { | 346 | } else { |
| @@ -362,14 +414,19 @@ var ErrAgentOffline = errors.New("agent not connected") | |||
| 362 | // deadline — consoles are long-lived. | 414 | // deadline — consoles are long-lived. |
| 363 | const consoleHandshakeTimeout = 10 * time.Second | 415 | const consoleHandshakeTimeout = 10 * time.Second |
| 364 | 416 | ||
| 365 | // OpenConsole opens a console stream to vmID's agent on the live sync | 417 | // openStream is the shared server-initiated stream procedure behind OpenConsole |
| 366 | // connection: sends ConsoleOpen, awaits ConsoleOpened, and returns the stream | 418 | // and OpenTCP: find the live agent conn, open a QUIC stream, send `open` under a |
| 367 | // as a raw byte pipe. The returned Close tears down both directions. | 419 | // handshake deadline, await the reply, and return the stream as a raw byte pipe |
| 420 | // once the agent acks. checkReply extracts the typed ack — present=false when | ||
| 421 | // the reply wasn't the expected ack message, ok=false with a reason when the | ||
| 422 | // agent refused. label ("console"/"tcp") prefixes the error messages. The | ||
| 423 | // returned Close tears down both directions. | ||
| 368 | // | 424 | // |
| 369 | // ctx bounds stream OPENING only; the handshake that follows is bounded by | 425 | // ctx bounds stream OPENING only; the handshake that follows is bounded by |
| 370 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by | 426 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by |
| 371 | // up to that long (10s) before returning. | 427 | // up to that long (10s) before returning. |
| 372 | func (s *Service) OpenConsole(ctx context.Context, hostID, vmID string) (io.ReadWriteCloser, error) { | 428 | func (s *Service) openStream(ctx context.Context, hostID, label string, open *pb.ServerMessage, |
| 429 | checkReply func(*pb.AgentMessage) (ok bool, reason string, present bool)) (io.ReadWriteCloser, error) { | ||
| 373 | s.consoleMu.Lock() | 430 | s.consoleMu.Lock() |
| 374 | conn, ok := s.conns[hostID] | 431 | conn, ok := s.conns[hostID] |
| 375 | s.consoleMu.Unlock() | 432 | s.consoleMu.Unlock() |
| @@ -378,31 +435,30 @@ func (s *Service) OpenConsole(ctx context.Context, hostID, vmID string) (io.Read | |||
| 378 | } | 435 | } |
| 379 | st, err := conn.OpenStreamSync(ctx) | 436 | st, err := conn.OpenStreamSync(ctx) |
| 380 | if err != nil { | 437 | if err != nil { |
| 381 | return nil, fmt.Errorf("open console stream: %w", err) | 438 | return nil, fmt.Errorf("open %s stream: %w", label, err) |
| 382 | } | 439 | } |
| 383 | cs := consoleStream{st} | 440 | cs := consoleStream{st} |
| 384 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { | 441 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { |
| 385 | cs.Close() | 442 | cs.Close() |
| 386 | return nil, err | 443 | return nil, err |
| 387 | } | 444 | } |
| 388 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_ConsoleOpen{ConsoleOpen: &pb.ConsoleOpen{VmId: vmID}}} | ||
| 389 | if err := transport.WriteMsg(st, open); err != nil { | 445 | if err := transport.WriteMsg(st, open); err != nil { |
| 390 | cs.Close() | 446 | cs.Close() |
| 391 | return nil, fmt.Errorf("console open: %w", err) | 447 | return nil, fmt.Errorf("%s open: %w", label, err) |
| 392 | } | 448 | } |
| 393 | var reply pb.AgentMessage | 449 | var reply pb.AgentMessage |
| 394 | if err := transport.ReadMsg(st, &reply, transport.DefaultMaxFrame); err != nil { | 450 | if err := transport.ReadMsg(st, &reply, transport.DefaultMaxFrame); err != nil { |
| 395 | cs.Close() | 451 | cs.Close() |
| 396 | return nil, fmt.Errorf("console reply: %w", err) | 452 | return nil, fmt.Errorf("%s reply: %w", label, err) |
| 397 | } | 453 | } |
| 398 | co := reply.GetConsoleOpened() | 454 | replyOK, reason, present := checkReply(&reply) |
| 399 | if co == nil { | 455 | if !present { |
| 400 | cs.Close() | 456 | cs.Close() |
| 401 | return nil, errors.New("console refused: unexpected reply") | 457 | return nil, fmt.Errorf("%s refused: unexpected reply", label) |
| 402 | } | 458 | } |
| 403 | if !co.GetOk() { | 459 | if !replyOK { |
| 404 | cs.Close() | 460 | cs.Close() |
| 405 | return nil, fmt.Errorf("console refused: %s", co.GetError()) | 461 | return nil, fmt.Errorf("%s refused: %s", label, reason) |
| 406 | } | 462 | } |
| 407 | // Handshake done — clear the deadline; the session is long-lived. | 463 | // Handshake done — clear the deadline; the session is long-lived. |
| 408 | if err := st.SetDeadline(time.Time{}); err != nil { | 464 | if err := st.SetDeadline(time.Time{}); err != nil { |
| @@ -412,56 +468,32 @@ func (s *Service) OpenConsole(ctx context.Context, hostID, vmID string) (io.Read | |||
| 412 | return cs, nil | 468 | return cs, nil |
| 413 | } | 469 | } |
| 414 | 470 | ||
| 471 | // OpenConsole opens a console stream to vmID's agent on the live sync | ||
| 472 | // connection: sends ConsoleOpen, awaits ConsoleOpened, and returns the stream | ||
| 473 | // as a raw byte pipe. | ||
| 474 | func (s *Service) OpenConsole(ctx context.Context, hostID, vmID string) (io.ReadWriteCloser, error) { | ||
| 475 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_ConsoleOpen{ConsoleOpen: &pb.ConsoleOpen{VmId: vmID}}} | ||
| 476 | return s.openStream(ctx, hostID, "console", open, func(m *pb.AgentMessage) (bool, string, bool) { | ||
| 477 | co := m.GetConsoleOpened() | ||
| 478 | if co == nil { | ||
| 479 | return false, "", false | ||
| 480 | } | ||
| 481 | return co.GetOk(), co.GetError(), true | ||
| 482 | }) | ||
| 483 | } | ||
| 484 | |||
| 415 | // OpenTCP opens a tunnel stream to vmID's agent on the live sync connection: | 485 | // OpenTCP opens a tunnel stream to vmID's agent on the live sync connection: |
| 416 | // sends TCPOpen (naming the VM and guest TCP port), awaits TCPOpened, and | 486 | // sends TCPOpen (naming the VM and guest TCP port), awaits TCPOpened, and |
| 417 | // returns the stream as a raw byte pipe. The returned Close tears down both | 487 | // returns the stream as a raw byte pipe. |
| 418 | // directions. It mirrors OpenConsole exactly, differing only in the handshake | ||
| 419 | // message pair. | ||
| 420 | // | ||
| 421 | // ctx bounds stream OPENING only; the handshake that follows is bounded by | ||
| 422 | // consoleHandshakeTimeout instead, so a call can outlive ctx cancellation by | ||
| 423 | // up to that long (10s) before returning. | ||
| 424 | func (s *Service) OpenTCP(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | 488 | func (s *Service) OpenTCP(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { |
| 425 | s.consoleMu.Lock() | ||
| 426 | conn, ok := s.conns[hostID] | ||
| 427 | s.consoleMu.Unlock() | ||
| 428 | if !ok { | ||
| 429 | return nil, ErrAgentOffline | ||
| 430 | } | ||
| 431 | st, err := conn.OpenStreamSync(ctx) | ||
| 432 | if err != nil { | ||
| 433 | return nil, fmt.Errorf("open tcp stream: %w", err) | ||
| 434 | } | ||
| 435 | cs := consoleStream{st} | ||
| 436 | if err := st.SetDeadline(time.Now().Add(consoleHandshakeTimeout)); err != nil { | ||
| 437 | cs.Close() | ||
| 438 | return nil, err | ||
| 439 | } | ||
| 440 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_TcpOpen{TcpOpen: &pb.TCPOpen{VmId: vmID, Port: port}}} | 489 | open := &pb.ServerMessage{Msg: &pb.ServerMessage_TcpOpen{TcpOpen: &pb.TCPOpen{VmId: vmID, Port: port}}} |
| 441 | if err := transport.WriteMsg(st, open); err != nil { | 490 | return s.openStream(ctx, hostID, "tcp", open, func(m *pb.AgentMessage) (bool, string, bool) { |
| 442 | cs.Close() | 491 | to := m.GetTcpOpened() |
| 443 | return nil, fmt.Errorf("tcp open: %w", err) | 492 | if to == nil { |
| 444 | } | 493 | return false, "", false |
| 445 | var reply pb.AgentMessage | 494 | } |
| 446 | if err := transport.ReadMsg(st, &reply, transport.DefaultMaxFrame); err != nil { | 495 | return to.GetOk(), to.GetError(), true |
| 447 | cs.Close() | 496 | }) |
| 448 | return nil, fmt.Errorf("tcp reply: %w", err) | ||
| 449 | } | ||
| 450 | to := reply.GetTcpOpened() | ||
| 451 | if to == nil { | ||
| 452 | cs.Close() | ||
| 453 | return nil, errors.New("tcp refused: unexpected reply") | ||
| 454 | } | ||
| 455 | if !to.GetOk() { | ||
| 456 | cs.Close() | ||
| 457 | return nil, fmt.Errorf("tcp refused: %s", to.GetError()) | ||
| 458 | } | ||
| 459 | // Handshake done — clear the deadline; the session is long-lived. | ||
| 460 | if err := st.SetDeadline(time.Time{}); err != nil { | ||
| 461 | cs.Close() | ||
| 462 | return nil, err | ||
| 463 | } | ||
| 464 | return cs, nil | ||
| 465 | } | 497 | } |
| 466 | 498 | ||
| 467 | // consoleStream adapts a quic.Stream to io.ReadWriteCloser with a Close that | 499 | // consoleStream adapts a quic.Stream to io.ReadWriteCloser with a Close that |
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | "io" | 6 | "io" |
| 7 | "runtime" | 7 | "runtime" |
| 8 | "strings" | 8 | "strings" |
| 9 | "sync" | ||
| 9 | "testing" | 10 | "testing" |
| 10 | "time" | 11 | "time" |
| 11 | 12 | ||
| @@ -190,6 +191,77 @@ func TestReportWritesThroughAndHardDeletesAckedTombstones(t *testing.T) { | |||
| 190 | assert.Equal(t, int64(8), st.Capacity.VCPUs) | 191 | assert.Equal(t, int64(8), st.Capacity.VCPUs) |
| 191 | } | 192 | } |
| 192 | 193 | ||
| 194 | // countingRecorder wraps a real recorder and counts (with a mutex, since host | ||
| 195 | // read-loop goroutines call concurrently) how many RecordVMStatus writes reach | ||
| 196 | // the store, so a test can prove that a repeated unchanged report performs none. | ||
| 197 | type countingRecorder struct { | ||
| 198 | mu sync.Mutex | ||
| 199 | inner vmStatusRecorder | ||
| 200 | calls int | ||
| 201 | } | ||
| 202 | |||
| 203 | func (c *countingRecorder) RecordVMStatus(id, status, lastErr, ip string) error { | ||
| 204 | c.mu.Lock() | ||
| 205 | c.calls++ | ||
| 206 | c.mu.Unlock() | ||
| 207 | return c.inner.RecordVMStatus(id, status, lastErr, ip) | ||
| 208 | } | ||
| 209 | |||
| 210 | func (c *countingRecorder) count() int { | ||
| 211 | c.mu.Lock() | ||
| 212 | defer c.mu.Unlock() | ||
| 213 | return c.calls | ||
| 214 | } | ||
| 215 | |||
| 216 | // TestApplyReportSkipsUnchangedWrites proves the dedup: the SAME ready report | ||
| 217 | // sent twice performs exactly ONE durable write; a subsequent report that | ||
| 218 | // changes the ip performs another; and the DB's final (status, assigned_ip) is | ||
| 219 | // correct across the unchanged→changed sequence. | ||
| 220 | func TestApplyReportSkipsUnchangedWrites(t *testing.T) { | ||
| 221 | f := setup(t) | ||
| 222 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | ||
| 223 | ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 224 | |||
| 225 | // Swap in a counting recorder before any report is applied. | ||
| 226 | rec := &countingRecorder{inner: f.svc.recorder} | ||
| 227 | f.svc.recorder = rec | ||
| 228 | |||
| 229 | c := mustDial(t, f) | ||
| 230 | c.recv(t) // initial snapshot | ||
| 231 | |||
| 232 | ready := func(ip string) *pb.AgentMessage { | ||
| 233 | return &pb.AgentMessage{Msg: &pb.AgentMessage_Report{ | ||
| 234 | Report: &pb.ActualStateReport{Vms: []*pb.ActualVM{ | ||
| 235 | {VmId: "vm1", Power: "running", Phase: "ready", Ip: ip}, | ||
| 236 | }}}} | ||
| 237 | } | ||
| 238 | |||
| 239 | // First report writes and lands in the DB. | ||
| 240 | c.send(t, ready("10.77.1.2")) | ||
| 241 | require.Eventually(t, func() bool { | ||
| 242 | vms, _ := f.st.ListVMs() | ||
| 243 | return len(vms) == 1 && vms[0].Status == "ready" && vms[0].AssignedIP == "10.77.1.2" | ||
| 244 | }, 2*time.Second, 20*time.Millisecond, "first report must write through") | ||
| 245 | require.Equal(t, 1, rec.count(), "first report writes exactly once") | ||
| 246 | |||
| 247 | // Identical report: must perform NO additional write. Assert the count stays | ||
| 248 | // at 1 for a sustained window (a redundant write would bump it). | ||
| 249 | c.send(t, ready("10.77.1.2")) | ||
| 250 | require.Never(t, func() bool { | ||
| 251 | return rec.count() != 1 | ||
| 252 | }, 500*time.Millisecond, 25*time.Millisecond, "unchanged report must not write again") | ||
| 253 | |||
| 254 | // A changed ip must write again and update the DB. | ||
| 255 | c.send(t, ready("10.77.1.3")) | ||
| 256 | require.Eventually(t, func() bool { | ||
| 257 | return rec.count() == 2 | ||
| 258 | }, 2*time.Second, 20*time.Millisecond, "changed ip must write again") | ||
| 259 | require.Eventually(t, func() bool { | ||
| 260 | vms, _ := f.st.ListVMs() | ||
| 261 | return len(vms) == 1 && vms[0].Status == "ready" && vms[0].AssignedIP == "10.77.1.3" | ||
| 262 | }, 2*time.Second, 20*time.Millisecond, "changed ip must land in the DB") | ||
| 263 | } | ||
| 264 | |||
| 193 | func TestReportWithValidIPUpdatesRegistryAndStore(t *testing.T) { | 265 | func TestReportWithValidIPUpdatesRegistryAndStore(t *testing.T) { |
| 194 | f := setup(t) | 266 | f := setup(t) |
| 195 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", | 267 | require.NoError(t, f.st.CreateVM(store.VM{ID: "vm1", HostID: f.host.ID, Name: "a", |
internal/server/syncsvc/tracker.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,68 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import "sync" | ||
| 4 | |||
| 5 | // vmStatus is the durable status triple a successful RecordVMStatus persists: | ||
| 6 | // the (status, last_error, assigned_ip) that ends up stored in the vms row. | ||
| 7 | type vmStatus struct { | ||
| 8 | status string | ||
| 9 | lastErr string | ||
| 10 | ip string | ||
| 11 | } | ||
| 12 | |||
| 13 | // statusTracker remembers the last DURABLY-WRITTEN status triple per VM so | ||
| 14 | // applyReport can skip the SELECT+UPDATE that RecordVMStatus does when nothing | ||
| 15 | // changed. Multiple host read-loop goroutines call into it concurrently, so all | ||
| 16 | // access is guarded by mu. Keyed by vmID (globally unique). | ||
| 17 | type statusTracker struct { | ||
| 18 | mu sync.Mutex | ||
| 19 | last map[string]vmStatus | ||
| 20 | } | ||
| 21 | |||
| 22 | func newStatusTracker() *statusTracker { | ||
| 23 | return &statusTracker{last: map[string]vmStatus{}} | ||
| 24 | } | ||
| 25 | |||
| 26 | // writeThrough runs the decide→durable-write→commit sequence for one VM under a | ||
| 27 | // single lock, so it is ATOMIC per VM: concurrent reports for the same host | ||
| 28 | // (e.g. an agent reconnect where the old and new sessions both deliver a report) | ||
| 29 | // cannot interleave the write and the cache update and leave the cache | ||
| 30 | // disagreeing with the row — a divergence that would then suppress every later | ||
| 31 | // identical report indefinitely. | ||
| 32 | // | ||
| 33 | // write performs the durable RecordVMStatus call; it runs ONLY when the durable | ||
| 34 | // triple (status, last_error, effective assigned_ip) changed, and the cache is | ||
| 35 | // updated ONLY if write returns nil, so a rejected write never suppresses the | ||
| 36 | // next retry. write receives no arguments: the caller passes the RAW reported ip | ||
| 37 | // to RecordVMStatus (whose UPDATE keeps the prior ip on empty), while the cache | ||
| 38 | // stores the effective ip computed here. | ||
| 39 | // | ||
| 40 | // The effective ip mirrors RecordVMStatus's UPDATE SET clause | ||
| 41 | // `assigned_ip = CASE WHEN ?=” THEN assigned_ip ELSE ? END`: an EMPTY reported | ||
| 42 | // ip keeps the prior stored ip (prior.ip=="" when there is no prior entry); a | ||
| 43 | // non-empty ip replaces it. | ||
| 44 | func (t *statusTracker) writeThrough(vmID, status, lastErr, ip string, write func() error) error { | ||
| 45 | t.mu.Lock() | ||
| 46 | defer t.mu.Unlock() | ||
| 47 | prior, ok := t.last[vmID] | ||
| 48 | effIP := ip | ||
| 49 | if effIP == "" { | ||
| 50 | effIP = prior.ip | ||
| 51 | } | ||
| 52 | if ok && prior.status == status && prior.lastErr == lastErr && prior.ip == effIP { | ||
| 53 | return nil // unchanged — skip the write | ||
| 54 | } | ||
| 55 | if err := write(); err != nil { | ||
| 56 | return err // rejected — do not cache; let the next report retry | ||
| 57 | } | ||
| 58 | t.last[vmID] = vmStatus{status: status, lastErr: lastErr, ip: effIP} | ||
| 59 | return nil | ||
| 60 | } | ||
| 61 | |||
| 62 | // forget drops a VM's cached state to bound memory. Called when the agent acks | ||
| 63 | // a VM as destroyed (the row is being hard-deleted). | ||
| 64 | func (t *statusTracker) forget(vmID string) { | ||
| 65 | t.mu.Lock() | ||
| 66 | defer t.mu.Unlock() | ||
| 67 | delete(t.last, vmID) | ||
| 68 | } | ||
internal/server/syncsvc/tracker_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,153 @@ | |||
| 1 | package syncsvc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "sync" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // write is a tiny helper that runs writeThrough with a SUCCESSFUL durable write, | ||
| 12 | // returning whether a write actually happened (the triple changed). | ||
| 13 | func (t *statusTracker) write(vmID, status, lastErr, ip string) bool { | ||
| 14 | wrote := false | ||
| 15 | _ = t.writeThrough(vmID, status, lastErr, ip, func() error { wrote = true; return nil }) | ||
| 16 | return wrote | ||
| 17 | } | ||
| 18 | |||
| 19 | func TestStatusTrackerDecide(t *testing.T) { | ||
| 20 | tr := newStatusTracker() | ||
| 21 | |||
| 22 | // First call for a VM must write. | ||
| 23 | if !tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 24 | t.Fatal("first call must write") | ||
| 25 | } | ||
| 26 | // Identical repeat must not write. | ||
| 27 | if tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 28 | t.Fatal("identical repeat must not write") | ||
| 29 | } | ||
| 30 | // Changed phase must write. | ||
| 31 | if !tr.write("vm1", "failed", "", "10.0.0.1") { | ||
| 32 | t.Fatal("changed phase must write") | ||
| 33 | } | ||
| 34 | // Changed lastError must write. | ||
| 35 | if !tr.write("vm1", "failed", "boom", "10.0.0.1") { | ||
| 36 | t.Fatal("changed lastError must write") | ||
| 37 | } | ||
| 38 | // Changed non-empty ip must write. | ||
| 39 | if !tr.write("vm1", "failed", "boom", "10.0.0.2") { | ||
| 40 | t.Fatal("changed non-empty ip must write") | ||
| 41 | } | ||
| 42 | // ip="" with same phase/err keeps the prior ip → unchanged → no write. | ||
| 43 | if tr.write("vm1", "failed", "boom", "") { | ||
| 44 | t.Fatal("empty ip keeps prior; unchanged must not write") | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | func TestStatusTrackerEmptyThenNonEmptyIP(t *testing.T) { | ||
| 49 | tr := newStatusTracker() | ||
| 50 | |||
| 51 | // First report has NO ip: effective ip is empty. | ||
| 52 | if !tr.write("vm1", "ready", "", "") { | ||
| 53 | t.Fatal("first call must write") | ||
| 54 | } | ||
| 55 | // Same phase/err, still empty ip: unchanged (empty keeps prior empty). | ||
| 56 | if tr.write("vm1", "ready", "", "") { | ||
| 57 | t.Fatal("repeat empty-ip must not write") | ||
| 58 | } | ||
| 59 | // Later a non-empty ip arrives with same phase/err: this is a change. | ||
| 60 | if !tr.write("vm1", "ready", "", "10.0.0.5") { | ||
| 61 | t.Fatal("empty→non-empty ip must write") | ||
| 62 | } | ||
| 63 | // Now an empty ip keeps that prior non-empty ip → unchanged. | ||
| 64 | if tr.write("vm1", "ready", "", "") { | ||
| 65 | t.Fatal("empty ip after non-empty keeps prior; must not write") | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestStatusTrackerFailedWriteNotCached(t *testing.T) { | ||
| 70 | tr := newStatusTracker() | ||
| 71 | |||
| 72 | // The durable write FAILS: writeThrough must surface the error and NOT cache. | ||
| 73 | sentinel := errors.New("record rejected") | ||
| 74 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() error { return sentinel }); err != sentinel { | ||
| 75 | t.Fatalf("failed write must surface its error, got %v", err) | ||
| 76 | } | ||
| 77 | // Because the write failed (uncached), the next report must still attempt it — | ||
| 78 | // a failed write must never suppress the retry. | ||
| 79 | attempted := false | ||
| 80 | if err := tr.writeThrough("vm1", "ready", "", "10.0.0.1", func() error { attempted = true; return nil }); err != nil { | ||
| 81 | t.Fatalf("retry write errored: %v", err) | ||
| 82 | } | ||
| 83 | if !attempted { | ||
| 84 | t.Fatal("failed (uncached) write must not suppress the retry") | ||
| 85 | } | ||
| 86 | // After that successful write, an identical report must not write again. | ||
| 87 | if tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 88 | t.Fatal("after a successful write an identical report must not write") | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | // TestStatusTrackerWriteThroughAtomic pins the invariant Fix D restores: under | ||
| 93 | // concurrent conflicting reports for one VM, the cached triple always equals the | ||
| 94 | // LAST durable write. writeThrough holds the lock across write+commit, so the | ||
| 95 | // two can never be reordered across goroutines and leave the cache disagreeing | ||
| 96 | // with the row (the divergence that would suppress every later report). Runs | ||
| 97 | // under -race to exercise the interleavings. | ||
| 98 | func TestStatusTrackerWriteThroughAtomic(t *testing.T) { | ||
| 99 | tr := newStatusTracker() | ||
| 100 | var mu sync.Mutex | ||
| 101 | lastWritten := "" // status of the most recent successful durable write | ||
| 102 | |||
| 103 | writer := func(status string) { | ||
| 104 | _ = tr.writeThrough("vm1", status, "", "10.0.0.1", func() error { | ||
| 105 | mu.Lock() | ||
| 106 | lastWritten = status // recorded inside writeThrough's critical section | ||
| 107 | mu.Unlock() | ||
| 108 | return nil | ||
| 109 | }) | ||
| 110 | } | ||
| 111 | |||
| 112 | var wg sync.WaitGroup | ||
| 113 | for i := 0; i < 300; i++ { | ||
| 114 | wg.Add(2) | ||
| 115 | go func() { defer wg.Done(); writer("ready") }() | ||
| 116 | go func() { defer wg.Done(); writer("failed") }() | ||
| 117 | } | ||
| 118 | wg.Wait() | ||
| 119 | |||
| 120 | tr.mu.Lock() | ||
| 121 | cached := tr.last["vm1"].status | ||
| 122 | tr.mu.Unlock() | ||
| 123 | mu.Lock() | ||
| 124 | dbLast := lastWritten | ||
| 125 | mu.Unlock() | ||
| 126 | require.Equal(t, dbLast, cached, "cache must equal the last durable write") | ||
| 127 | } | ||
| 128 | |||
| 129 | func TestStatusTrackerForget(t *testing.T) { | ||
| 130 | tr := newStatusTracker() | ||
| 131 | if !tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 132 | t.Fatal("first call must write") | ||
| 133 | } | ||
| 134 | tr.forget("vm1") | ||
| 135 | // After forget the VM is unknown again, so the same report writes afresh. | ||
| 136 | if !tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 137 | t.Fatal("after forget the same report must write again") | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | func TestStatusTrackerPerVMKeys(t *testing.T) { | ||
| 142 | tr := newStatusTracker() | ||
| 143 | if !tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 144 | t.Fatal("vm1 first write") | ||
| 145 | } | ||
| 146 | // A different VM with the same triple is independent → must write. | ||
| 147 | if !tr.write("vm2", "ready", "", "10.0.0.1") { | ||
| 148 | t.Fatal("vm2 is a distinct key and must write") | ||
| 149 | } | ||
| 150 | if tr.write("vm1", "ready", "", "10.0.0.1") { | ||
| 151 | t.Fatal("vm1 unchanged must not write") | ||
| 152 | } | ||
| 153 | } | ||
internal/server/web/spa_test.go
| Old | New | ||
|---|---|---|---|
| @@ -31,8 +31,8 @@ func TestSPAServesIndexAtRoot(t *testing.T) { | |||
| 31 | 31 | ||
| 32 | func TestSPAServesRealAsset(t *testing.T) { | 32 | func TestSPAServesRealAsset(t *testing.T) { |
| 33 | h := spaHandler(fstest.MapFS{ | 33 | h := spaHandler(fstest.MapFS{ |
| 34 | "index.html": {Data: []byte("index")}, | 34 | "index.html": {Data: []byte("index")}, |
| 35 | "_app/app.js": {Data: []byte("console.log(1)")}, | 35 | "_app/app.js": {Data: []byte("console.log(1)")}, |
| 36 | }) | 36 | }) |
| 37 | code, body := get(t, h, "/_app/app.js") | 37 | code, body := get(t, h, "/_app/app.js") |
| 38 | assert.Equal(t, http.StatusOK, code) | 38 | assert.Equal(t, http.StatusOK, code) |
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -33,7 +33,8 @@ func classify(rel string) Plane { | |||
| 33 | strings.HasPrefix(rel, "internal/transport"), | 33 | strings.HasPrefix(rel, "internal/transport"), |
| 34 | strings.HasPrefix(rel, "internal/joinblob"), | 34 | strings.HasPrefix(rel, "internal/joinblob"), |
| 35 | strings.HasPrefix(rel, "internal/cloudinit"), | 35 | strings.HasPrefix(rel, "internal/cloudinit"), |
| 36 | strings.HasPrefix(rel, "internal/names"): | 36 | strings.HasPrefix(rel, "internal/names"), |
| 37 | strings.HasPrefix(rel, "internal/random"): | ||
| 37 | return PlaneWire | 38 | return PlaneWire |
| 38 | case strings.HasPrefix(rel, "cmd/"): | 39 | case strings.HasPrefix(rel, "cmd/"): |
| 39 | return PlaneBinaries | 40 | return PlaneBinaries |
internal/shape/classify_test.go
| Old | New | ||
|---|---|---|---|
| @@ -29,6 +29,7 @@ func TestClassifyAssignsPlaneByPrefix(t *testing.T) { | |||
| 29 | "internal/pb": PlaneWire, | 29 | "internal/pb": PlaneWire, |
| 30 | "internal/transport": PlaneWire, | 30 | "internal/transport": PlaneWire, |
| 31 | "internal/names": PlaneWire, | 31 | "internal/names": PlaneWire, |
| 32 | "internal/random": PlaneWire, | ||
| 32 | "cmd/eitri-server": PlaneBinaries, | 33 | "cmd/eitri-server": PlaneBinaries, |
| 33 | "cmd/eitri-shape": PlaneBinaries, | 34 | "cmd/eitri-shape": PlaneBinaries, |
| 34 | "internal/arch": PlaneTooling, | 35 | "internal/arch": PlaneTooling, |
internal/shape/generate.go
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ package shape | |||
| 3 | // Generate runs the real `go list`, builds the model, and returns its JSON | 3 | // Generate runs the real `go list`, builds the model, and returns its JSON |
| 4 | // rendering. It is the one entry point cmd/eitri-shape calls. | 4 | // rendering. It is the one entry point cmd/eitri-shape calls. |
| 5 | func Generate() ([]byte, error) { | 5 | func Generate() ([]byte, error) { |
| 6 | raw, err := goListLister()() | 6 | raw, err := goList() |
| 7 | if err != nil { | 7 | if err != nil { |
| 8 | return nil, err | 8 | return nil, err |
| 9 | } | 9 | } |
internal/shape/golist.go
| Old | New | ||
|---|---|---|---|
| @@ -8,29 +8,27 @@ import ( | |||
| 8 | "os/exec" | 8 | "os/exec" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // goListLister returns a lister backed by `go list -json` over this module's | 11 | // goList returns the raw package list for the module via `go list -json` over |
| 12 | // internal/... and cmd/... packages. GOOS is pinned to linux: the stack is | 12 | // its internal/... and cmd/... packages. GOOS is pinned to linux: the stack is |
| 13 | // Linux-only (netlink, cloud-hypervisor), so pinning keeps output identical | 13 | // Linux-only (netlink, cloud-hypervisor), so pinning keeps output identical |
| 14 | // regardless of the contributor's OS. `go list` only resolves build | 14 | // regardless of the contributor's OS. `go list` only resolves build |
| 15 | // constraints here — it does not compile — so this is safe cross-platform. | 15 | // constraints here — it does not compile — so this is safe cross-platform. |
| 16 | func goListLister() lister { | 16 | func goList() ([]rawPackage, error) { |
| 17 | return func() ([]rawPackage, error) { | 17 | cmd := exec.Command("go", "list", "-json", |
| 18 | cmd := exec.Command("go", "list", "-json", | 18 | module+"/internal/...", module+"/cmd/...") |
| 19 | module+"/internal/...", module+"/cmd/...") | 19 | cmd.Env = append(os.Environ(), "GOOS=linux") |
| 20 | cmd.Env = append(os.Environ(), "GOOS=linux") | 20 | out, err := cmd.Output() |
| 21 | out, err := cmd.Output() | 21 | if err != nil { |
| 22 | if err != nil { | 22 | return nil, fmt.Errorf("go list: %w", err) |
| 23 | return nil, fmt.Errorf("go list: %w", err) | 23 | } |
| 24 | } | 24 | var pkgs []rawPackage |
| 25 | var pkgs []rawPackage | 25 | dec := json.NewDecoder(bytes.NewReader(out)) |
| 26 | dec := json.NewDecoder(bytes.NewReader(out)) | 26 | for dec.More() { |
| 27 | for dec.More() { | 27 | var p rawPackage |
| 28 | var p rawPackage | 28 | if err := dec.Decode(&p); err != nil { |
| 29 | if err := dec.Decode(&p); err != nil { | 29 | return nil, fmt.Errorf("decode go list output: %w", err) |
| 30 | return nil, fmt.Errorf("decode go list output: %w", err) | ||
| 31 | } | ||
| 32 | pkgs = append(pkgs, p) | ||
| 33 | } | 30 | } |
| 34 | return pkgs, nil | 31 | pkgs = append(pkgs, p) |
| 35 | } | 32 | } |
| 33 | return pkgs, nil | ||
| 36 | } | 34 | } |
internal/shape/golist_test.go
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,7 @@ import "testing" | |||
| 6 | // If a new top-level package appears that matches no rule in classify(), it | 6 | // If a new top-level package appears that matches no rule in classify(), it |
| 7 | // lands in Unclassified and this fails — forcing a deliberate classification. | 7 | // lands in Unclassified and this fails — forcing a deliberate classification. |
| 8 | func TestNoUnclassifiedPackagesInModule(t *testing.T) { | 8 | func TestNoUnclassifiedPackagesInModule(t *testing.T) { |
| 9 | raw, err := goListLister()() | 9 | raw, err := goList() |
| 10 | if err != nil { | 10 | if err != nil { |
| 11 | t.Fatalf("go list: %v", err) | 11 | t.Fatalf("go list: %v", err) |
| 12 | } | 12 | } |
internal/shape/shape.go
| Old | New | ||
|---|---|---|---|
| @@ -25,11 +25,6 @@ type rawPackage struct { | |||
| 25 | Imports []string | 25 | Imports []string |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | // lister returns the raw package list for the module. It is a concrete function | ||
| 29 | // type (not an interface) so injecting a fake in tests does not trip the | ||
| 30 | // block-tier ireturn linter, and so production code can shell out to `go list`. | ||
| 31 | type lister func() ([]rawPackage, error) | ||
| 32 | |||
| 33 | // Package is one node in the shape graph. All string fields are module-relative. | 28 | // Package is one node in the shape graph. All string fields are module-relative. |
| 34 | type Package struct { | 29 | type Package struct { |
| 35 | ImportPath string `json:"importPath"` | 30 | ImportPath string `json:"importPath"` |
internal/transport/tlsconf.go
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | "fmt" | 13 | "fmt" |
| 14 | "math/big" | 14 | "math/big" |
| 15 | "time" | 15 | "time" |
| 16 | |||
| 17 | "github.com/quic-go/quic-go" | ||
| 16 | ) | 18 | ) |
| 17 | 19 | ||
| 18 | // ALPN is the QUIC application-layer protocol token. Both ends MUST set it; the | 20 | // ALPN is the QUIC application-layer protocol token. Both ends MUST set it; the |
| @@ -25,6 +27,28 @@ const ( | |||
| 25 | CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop | 27 | CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop |
| 26 | ) | 28 | ) |
| 27 | 29 | ||
| 30 | // Sync QUIC keepalive/idle timings. The agent dialer and the server listener | ||
| 31 | // MUST build their quic.Config from SyncQUICConfig: the effective idle timeout | ||
| 32 | // is the min of the two peers, so tuning one side alone silently shortens it. | ||
| 33 | // 10s keepalive under a 60s idle gives ~5 keepalive attempts before teardown, | ||
| 34 | // so a single missed PING or a brief GC/stall on either side does not kill a | ||
| 35 | // healthy control-plane session (the old 15s/30s allowed only one attempt and | ||
| 36 | // caused reconnect churn). | ||
| 37 | const ( | ||
| 38 | SyncKeepAlivePeriod = 10 * time.Second | ||
| 39 | SyncMaxIdleTimeout = 60 * time.Second | ||
| 40 | ) | ||
| 41 | |||
| 42 | // SyncQUICConfig returns the QUIC transport config shared by the eitri-agent | ||
| 43 | // dialer and the eitri-server listener. Colocating it with ALPN and the TLS | ||
| 44 | // builders is what keeps the two ends from drifting. | ||
| 45 | func SyncQUICConfig() *quic.Config { | ||
| 46 | return &quic.Config{ | ||
| 47 | KeepAlivePeriod: SyncKeepAlivePeriod, | ||
| 48 | MaxIdleTimeout: SyncMaxIdleTimeout, | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 28 | // GenerateServerCert returns a fresh self-signed ECDSA cert+key as PEM. | 52 | // GenerateServerCert returns a fresh self-signed ECDSA cert+key as PEM. |
| 29 | func GenerateServerCert() (certPEM, keyPEM []byte, err error) { | 53 | func GenerateServerCert() (certPEM, keyPEM []byte, err error) { |
| 30 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | 54 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -21,9 +21,13 @@ AGENT_LOG="/var/lib/eitri-agent/agent.log" | |||
| 21 | 21 | ||
| 22 | # Agent launch flags shared by all hosts. | 22 | # Agent launch flags shared by all hosts. |
| 23 | CH_BIN="/usr/local/bin/cloud-hypervisor" | 23 | CH_BIN="/usr/local/bin/cloud-hypervisor" |
| 24 | FIRMWARE="/usr/share/eitri/hypervisor-fw" | 24 | # FIRMWARE is the guest firmware path ON each host. UEFI (CLOUDHV.fd) boots the |
| 25 | MESH_BIN_URL="http://192.168.0.190:8090/ray.bin" | 25 | # guest's own GRUB -> guest kernel+initrd from disk (modern images boot). |
| 26 | MESH_BIN_SHA256="e806d523cf50bec454ef299be3bb5b8aad123ded93911fa86a66d70341915d05" | 26 | FIRMWARE="/usr/share/eitri/CLOUDHV.fd" |
| 27 | # FIRMWARE_SRC (optional): a LOCAL firmware file to scp+install to $FIRMWARE on | ||
| 28 | # every host during deploy. Set it to roll a firmware change with the deploy; | ||
| 29 | # leave unset to keep whatever firmware already exists on each host. | ||
| 30 | # FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd" | ||
| 27 | 31 | ||
| 28 | # Optional overrides (leave unset to use the agent's built-in defaults). | 32 | # Optional overrides (leave unset to use the agent's built-in defaults). |
| 29 | # TOMBSTONE_GRACE — how long a deleted VM lingers (stopped) before destroy. | 33 | # TOMBSTONE_GRACE — how long a deleted VM lingers (stopped) before destroy. |
scripts/deploy.sh
| Old | New | ||
|---|---|---|---|
| @@ -57,8 +57,8 @@ install -m 0755 bin/eitri-server "$SERVER_BIN" | |||
| 57 | setsid "$SERVER_BIN" --config "$SERVER_CONFIG" </dev/null >>"$SERVER_LOG" 2>&1 & | 57 | setsid "$SERVER_BIN" --config "$SERVER_CONFIG" </dev/null >>"$SERVER_LOG" 2>&1 & |
| 58 | # Liveness gate: /livez is 200 as soon as the HTTP mux serves — an honest | 58 | # Liveness gate: /livez is 200 as soon as the HTTP mux serves — an honest |
| 59 | # "process is up" signal (unlike GET /, which returns the SPA even before the | 59 | # "process is up" signal (unlike GET /, which returns the SPA even before the |
| 60 | # app is wired). Readiness (DB + warden) is gated separately below, after the | 60 | # app is wired). Readiness (DB) is gated separately below, after the agents |
| 61 | # agents have had a chance to redial. | 61 | # have had a chance to redial. |
| 62 | for _ in $(seq 1 30); do | 62 | for _ in $(seq 1 30); do |
| 63 | curl -fsS -o /dev/null "$SERVER_URL/livez" 2>/dev/null && break | 63 | curl -fsS -o /dev/null "$SERVER_URL/livez" 2>/dev/null && break |
| 64 | sleep 0.5 | 64 | sleep 0.5 |
| @@ -82,6 +82,14 @@ for entry in $AGENT_HOSTS; do | |||
| 82 | port="${entry##*:}"; [[ "$port" == "$entry" ]] && port=22 | 82 | port="${entry##*:}"; [[ "$port" == "$entry" ]] && port=22 |
| 83 | bold "Rolling agent -> $userhost (port $port)" | 83 | bold "Rolling agent -> $userhost (port $port)" |
| 84 | scp -q -P "$port" -o ConnectTimeout=10 bin/eitri-agent "$userhost:/tmp/eitri-agent-new" | 84 | scp -q -P "$port" -o ConnectTimeout=10 bin/eitri-agent "$userhost:/tmp/eitri-agent-new" |
| 85 | # Optionally ship the guest firmware (e.g. CLOUDHV.fd) so a firmware change | ||
| 86 | # rolls WITH the deploy instead of needing a manual per-host install. When | ||
| 87 | # FIRMWARE_SRC is unset, hosts keep whatever firmware is already at $FIRMWARE. | ||
| 88 | fw_install="" | ||
| 89 | if [[ -n "${FIRMWARE_SRC:-}" ]]; then | ||
| 90 | scp -q -P "$port" -o ConnectTimeout=10 "$FIRMWARE_SRC" "$userhost:/tmp/eitri-firmware-new" | ||
| 91 | fw_install="sudo install -Dm0644 /tmp/eitri-firmware-new \"$FIRMWARE\" && rm -f /tmp/eitri-firmware-new" | ||
| 92 | fi | ||
| 85 | # Unquoted heredoc: local vars expand here; \$(...) runs on the remote. | 93 | # Unquoted heredoc: local vars expand here; \$(...) runs on the remote. |
| 86 | ssh -p "$port" -o BatchMode=yes "$userhost" bash -s <<REMOTE | 94 | ssh -p "$port" -o BatchMode=yes "$userhost" bash -s <<REMOTE |
| 87 | set -e | 95 | set -e |
| @@ -90,6 +98,7 @@ for _ in \$(seq 1 20); do pgrep -x eitri-agent >/dev/null || break; sleep 0.5; d | |||
| 90 | if pgrep -x eitri-agent >/dev/null; then echo "agent did not stop on \$(hostname)" >&2; exit 1; fi | 98 | if pgrep -x eitri-agent >/dev/null; then echo "agent did not stop on \$(hostname)" >&2; exit 1; fi |
| 91 | sudo install -m 0755 /tmp/eitri-agent-new "$AGENT_BIN" | 99 | sudo install -m 0755 /tmp/eitri-agent-new "$AGENT_BIN" |
| 92 | rm -f /tmp/eitri-agent-new | 100 | rm -f /tmp/eitri-agent-new |
| 101 | $fw_install | ||
| 93 | sudo bash -c "setsid $AGENT_BIN $agent_flags </dev/null >>$AGENT_LOG 2>&1 &" | 102 | sudo bash -c "setsid $AGENT_BIN $agent_flags </dev/null >>$AGENT_LOG 2>&1 &" |
| 94 | sleep 2 | 103 | sleep 2 |
| 95 | pgrep -x eitri-agent >/dev/null || { echo "agent FAILED to start on \$(hostname) (see $AGENT_LOG)" >&2; exit 1; } | 104 | pgrep -x eitri-agent >/dev/null || { echo "agent FAILED to start on \$(hostname) (see $AGENT_LOG)" >&2; exit 1; } |
| @@ -99,10 +108,9 @@ done | |||
| 99 | 108 | ||
| 100 | # ── 3. Post-deploy verification ─────────────────────────────────────────────── | 109 | # ── 3. Post-deploy verification ─────────────────────────────────────────────── |
| 101 | bold "Verifying fleet on $SHA" | 110 | bold "Verifying fleet on $SHA" |
| 102 | # Readiness gate: /readyz 200s only when the server's dependencies (DB open + | 111 | # Readiness gate: /readyz 200s only when the server's dependencies (DB open) are |
| 103 | # warden daemon reachable) are healthy. Poll briefly — the warden socket is | 112 | # healthy. Poll briefly — the DB is local and up whenever the box is, so this |
| 104 | # local and up whenever the box is, so this settles fast; a persistent 503 | 113 | # settles fast; a persistent 503 names the failed dependency in its JSON body. |
| 105 | # names the failed dependency in its JSON body. | ||
| 106 | ready="" | 114 | ready="" |
| 107 | for _ in $(seq 1 15); do | 115 | for _ in $(seq 1 15); do |
| 108 | ready="$(curl -fsS "$SERVER_URL/readyz" 2>/dev/null)" && break | 116 | ready="$(curl -fsS "$SERVER_URL/readyz" 2>/dev/null)" && break |
| @@ -136,4 +144,27 @@ if [[ -n "${ADMIN_TOKEN_FILE:-}" && -f "$ADMIN_TOKEN_FILE" ]]; then | |||
| 136 | else | 144 | else |
| 137 | echo "(no ADMIN_TOKEN_FILE set — skipping API verification)" | 145 | echo "(no ADMIN_TOKEN_FILE set — skipping API verification)" |
| 138 | fi | 146 | fi |
| 147 | |||
| 148 | # ── 4. Boot gate ────────────────────────────────────────────────────────────── | ||
| 149 | # readyz + hosts-online prove the CONTROL PLANE is up, but a deploy can still | ||
| 150 | # ship a change that breaks VM BOOT (e.g. an unknown cloud-hypervisor --disk | ||
| 151 | # option that makes CH refuse to start — exactly how the image_type=raw | ||
| 152 | # regression reached the fleet). The only way to catch that is to boot a real | ||
| 153 | # VM. This gate does so and FAILS the deploy if it can't. Set SKIP_BOOT_SMOKE=1 | ||
| 154 | # to bypass for a server-only / docs-only change. | ||
| 155 | if [[ "${SKIP_BOOT_SMOKE:-0}" == "1" ]]; then | ||
| 156 | echo "(boot gate skipped: SKIP_BOOT_SMOKE=1)" | ||
| 157 | elif [[ -n "${ADMIN_TOKEN_FILE:-}" ]]; then | ||
| 158 | bold "Boot gate: create a throwaway VM and confirm it actually boots" | ||
| 159 | if EITRI_DEPLOY_ENV="$ENV_FILE" "$REPO_ROOT/scripts/remote-smoke.sh"; then | ||
| 160 | echo "boot gate: PASS" | ||
| 161 | else | ||
| 162 | echo "DEPLOY FAILED boot gate: a VM did not boot after this deploy." >&2 | ||
| 163 | echo " The fleet is likely serving a boot-breaking change — investigate before relying on it." >&2 | ||
| 164 | exit 1 | ||
| 165 | fi | ||
| 166 | else | ||
| 167 | echo "(boot gate skipped — needs ADMIN_TOKEN_FILE)" | ||
| 168 | fi | ||
| 169 | |||
| 139 | bold "Deploy complete: $SHA" | 170 | bold "Deploy complete: $SHA" |
scripts/remote-smoke.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,101 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Remote smoke: validate the LIVE fleet end-to-end. Creates a VM through the real | ||
| 3 | # API, confirms the GUEST actually booted (reads its serial console on the host), | ||
| 4 | # optionally checks reachability, then reaps it and verifies the hard-delete. | ||
| 5 | # | ||
| 6 | # Unlike scripts/smoke.sh this needs NO local privilege — the agent host does the | ||
| 7 | # privileged tap/nft work. Config is sourced from $EITRI_DEPLOY_ENV (the same | ||
| 8 | # file deploy.sh uses); the admin token is read from $ADMIN_TOKEN_FILE and never | ||
| 9 | # printed. | ||
| 10 | set -euo pipefail | ||
| 11 | |||
| 12 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 13 | [[ -f "$ENV_FILE" ]] || { echo "remote-smoke: config not found: $ENV_FILE" >&2; exit 1; } | ||
| 14 | # shellcheck disable=SC1090 | ||
| 15 | source "$ENV_FILE" | ||
| 16 | : "${SERVER_URL:?}" "${ADMIN_TOKEN_FILE:?}" "${AGENT_HOSTS:?}" "${AGENT_STATE_DIR:?}" | ||
| 17 | |||
| 18 | tok="$(cat "$ADMIN_TOKEN_FILE")" | ||
| 19 | api() { curl -fsS -H "Authorization: Bearer $tok" "$@"; } | ||
| 20 | say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; } | ||
| 21 | |||
| 22 | # First agent host = where we read the serial console. | ||
| 23 | entry="${AGENT_HOSTS%% *}" | ||
| 24 | userhost="${entry%%:*}"; port="${entry##*:}"; [[ "$port" == "$entry" ]] && port=22 | ||
| 25 | ssh_host() { ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=10 "$userhost" "$@"; } | ||
| 26 | |||
| 27 | # Echo the requested fields (space-separated) for THIS VM from the /vms list in a | ||
| 28 | # single query; each field is '' when the VM is absent or the value is null. Used | ||
| 29 | # by the ready-poll (phase + ip together, so no second fetch on the ready | ||
| 30 | # iteration) and the reap-poll (presence check via the id field). | ||
| 31 | vm_fields() { | ||
| 32 | api "$SERVER_URL/api/v1/vms" | python3 -c " | ||
| 33 | import sys,json | ||
| 34 | want='$*'.split() | ||
| 35 | vm=next((v for v in json.load(sys.stdin) if v['id']=='$VM_ID'), {}) | ||
| 36 | print(' '.join('' if vm.get(f) is None else str(vm.get(f)) for f in want)) | ||
| 37 | " | ||
| 38 | } | ||
| 39 | |||
| 40 | say "Fleet" | ||
| 41 | api "$SERVER_URL/api/v1/hosts" | python3 -m json.tool | ||
| 42 | HOST_ID=$(api "$SERVER_URL/api/v1/hosts" \ | ||
| 43 | | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["id"])') | ||
| 44 | |||
| 45 | say "Create VM" | ||
| 46 | SSH_KEY="" | ||
| 47 | for f in ~/.ssh/id_ed25519.pub ~/.ssh/id_rsa.pub; do | ||
| 48 | [[ -f "$f" ]] && { SSH_KEY=$(cat "$f"); break; } | ||
| 49 | done | ||
| 50 | START=$(date +%s) | ||
| 51 | VM_ID=$(api -X POST "$SERVER_URL/api/v1/vms" -H 'Content-Type: application/json' \ | ||
| 52 | -d "{\"host_id\":\"$HOST_ID\",\"ssh_authorized_key\":\"$SSH_KEY\"}" \ | ||
| 53 | | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') | ||
| 54 | echo "VM id: $VM_ID" | ||
| 55 | |||
| 56 | say "Wait for ready (first run downloads the image — allow a few minutes)" | ||
| 57 | DEADLINE=$(( $(date +%s) + 600 )); IP=""; PHASE="" | ||
| 58 | while [[ "$(date +%s)" -lt "$DEADLINE" ]]; do | ||
| 59 | read -r PHASE IP <<<"$(vm_fields phase assigned_ip)" | ||
| 60 | [[ "$PHASE" == "ready" ]] && break | ||
| 61 | echo " phase=$PHASE — waiting..." | ||
| 62 | sleep 5 | ||
| 63 | done | ||
| 64 | COLD=$(( $(date +%s) - START )) | ||
| 65 | [[ "$PHASE" == "ready" && -n "$IP" ]] || { echo "FAIL: VM not ready within 600s (phase=$PHASE)"; exit 1; } | ||
| 66 | echo "ready: ip=$IP cold_start=${COLD}s" | ||
| 67 | |||
| 68 | # Boot GATE — 'ready' only means CH is up + IP assigned; it does NOT mean the | ||
| 69 | # guest booted (a bad firmware/image leaves CH running but the guest dead). So | ||
| 70 | # poll the guest serial console until we see real userspace, and FAIL on a | ||
| 71 | # kernel panic or a 180s timeout. This is the assertion that the UEFI -> | ||
| 72 | # guest-GRUB -> guest-kernel chain actually worked. | ||
| 73 | say "Boot proof — poll guest serial on $userhost until userspace" | ||
| 74 | SERIAL="$AGENT_STATE_DIR/vms/$VM_ID/serial.log" | ||
| 75 | BOOT_DEADLINE=$(( $(date +%s) + 180 )); BOOTED="" | ||
| 76 | while [[ "$(date +%s)" -lt "$BOOT_DEADLINE" ]]; do | ||
| 77 | serial="$(ssh_host "sudo cat '$SERIAL' 2>/dev/null | tr -cd '\\11\\12\\15\\40-\\176'" 2>/dev/null || true)" | ||
| 78 | if grep -qaiE 'Kernel panic|Cannot open root' <<<"$serial"; then | ||
| 79 | echo "FAIL: guest panic / root-mount failure:"; grep -aiE 'panic|Cannot open root' <<<"$serial" | tail -5 | ||
| 80 | exit 1 | ||
| 81 | fi | ||
| 82 | if grep -qaiE 'Welcome to.*Ubuntu|login:|Reached target.*Multi-User' <<<"$serial"; then | ||
| 83 | BOOTED=1 | ||
| 84 | grep -aiE 'BdsDxe|Linux version|Welcome to.*Ubuntu|login:' <<<"$serial" | head -6 | ||
| 85 | break | ||
| 86 | fi | ||
| 87 | echo " guest still booting..."; sleep 6 | ||
| 88 | done | ||
| 89 | [[ -n "$BOOTED" ]] || { echo "FAIL: no userspace boot evidence in serial within 180s"; exit 1; } | ||
| 90 | |||
| 91 | say "Reap — verify quarantine -> destroy -> hard-delete" | ||
| 92 | api -X DELETE "$SERVER_URL/api/v1/vms/$VM_ID" >/dev/null | ||
| 93 | REAP_DEADLINE=$(( $(date +%s) + 120 )); REAPED="" | ||
| 94 | while [[ "$(date +%s)" -lt "$REAP_DEADLINE" ]]; do | ||
| 95 | [[ -z "$(vm_fields id)" ]] && { REAPED=1; break; } | ||
| 96 | echo " vm still present — waiting for destroy+ack..." | ||
| 97 | sleep 5 | ||
| 98 | done | ||
| 99 | [[ -n "$REAPED" ]] || { echo "FAIL: VM not hard-deleted within 120s of tombstone"; exit 1; } | ||
| 100 | |||
| 101 | say "REMOTE SMOKE COMPLETE — booted under UEFI, cold_start=${COLD}s, reaped OK" | ||
web/package-lock.json
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ | |||
| 15 | "@sveltejs/adapter-static": "^3.0.10", | 15 | "@sveltejs/adapter-static": "^3.0.10", |
| 16 | "@sveltejs/kit": "^2.63.0", | 16 | "@sveltejs/kit": "^2.63.0", |
| 17 | "@sveltejs/vite-plugin-svelte": "^7.1.2", | 17 | "@sveltejs/vite-plugin-svelte": "^7.1.2", |
| 18 | "@types/node": "^26.1.1", | ||
| 18 | "openapi-typescript": "^7.13.0", | 19 | "openapi-typescript": "^7.13.0", |
| 19 | "svelte": "^5.56.1", | 20 | "svelte": "^5.56.1", |
| 20 | "svelte-check": "^4.6.0", | 21 | "svelte-check": "^4.6.0", |
| @@ -629,6 +630,16 @@ | |||
| 629 | "dev": true, | 630 | "dev": true, |
| 630 | "license": "MIT" | 631 | "license": "MIT" |
| 631 | }, | 632 | }, |
| 633 | "node_modules/@types/node": { | ||
| 634 | "version": "26.1.1", | ||
| 635 | "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", | ||
| 636 | "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", | ||
| 637 | "dev": true, | ||
| 638 | "license": "MIT", | ||
| 639 | "dependencies": { | ||
| 640 | "undici-types": "~8.3.0" | ||
| 641 | } | ||
| 642 | }, | ||
| 632 | "node_modules/@types/trusted-types": { | 643 | "node_modules/@types/trusted-types": { |
| 633 | "version": "2.0.7", | 644 | "version": "2.0.7", |
| 634 | "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", | 645 | "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", |
| @@ -1668,6 +1679,13 @@ | |||
| 1668 | "node": ">=14.17" | 1679 | "node": ">=14.17" |
| 1669 | } | 1680 | } |
| 1670 | }, | 1681 | }, |
| 1682 | "node_modules/undici-types": { | ||
| 1683 | "version": "8.3.0", | ||
| 1684 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", | ||
| 1685 | "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", | ||
| 1686 | "dev": true, | ||
| 1687 | "license": "MIT" | ||
| 1688 | }, | ||
| 1671 | "node_modules/uri-js-replace": { | 1689 | "node_modules/uri-js-replace": { |
| 1672 | "version": "1.0.1", | 1690 | "version": "1.0.1", |
| 1673 | "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", | 1691 | "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", |
web/package.json
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,7 @@ | |||
| 22 | "@sveltejs/adapter-static": "^3.0.10", | 22 | "@sveltejs/adapter-static": "^3.0.10", |
| 23 | "@sveltejs/kit": "^2.63.0", | 23 | "@sveltejs/kit": "^2.63.0", |
| 24 | "@sveltejs/vite-plugin-svelte": "^7.1.2", | 24 | "@sveltejs/vite-plugin-svelte": "^7.1.2", |
| 25 | "@types/node": "^26.1.1", | ||
| 25 | "openapi-typescript": "^7.13.0", | 26 | "openapi-typescript": "^7.13.0", |
| 26 | "svelte": "^5.56.1", | 27 | "svelte": "^5.56.1", |
| 27 | "svelte-check": "^4.6.0", | 28 | "svelte-check": "^4.6.0", |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -916,10 +916,15 @@ export interface components { | |||
| 916 | /** Format: date-time */ | 916 | /** Format: date-time */ |
| 917 | enrolled_at: string; | 917 | enrolled_at: string; |
| 918 | id: string; | 918 | id: string; |
| 919 | /** Format: date-time */ | ||
| 920 | last_seen?: string | null; | ||
| 919 | name: string; | 921 | name: string; |
| 920 | online: boolean; | 922 | online: boolean; |
| 921 | os: string; | 923 | os: string; |
| 922 | provisioner: string; | 924 | provisioner: string; |
| 925 | seconds_since_last_seen?: number | null; | ||
| 926 | sessions: number; | ||
| 927 | stale: boolean; | ||
| 923 | status: string; | 928 | status: string; |
| 924 | }; | 929 | }; |
| 925 | PatchVMRequest: { | 930 | PatchVMRequest: { |