c994de4a
refactor: one home each for facts the tree stated in several
a73x 2026-08-12 19:17
Commit message
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -138,6 +138,7 @@ | |||
| 138 | "internal/agent/exec", | 138 | "internal/agent/exec", |
| 139 | "internal/agent/hostinfo", | 139 | "internal/agent/hostinfo", |
| 140 | "internal/agent/hyperlog", | 140 | "internal/agent/hyperlog", |
| 141 | "internal/agent/permanent", | ||
| 141 | "internal/agent/pidfile", | 142 | "internal/agent/pidfile", |
| 142 | "internal/agent/state" | 143 | "internal/agent/state" |
| 143 | ] | 144 | ] |
| @@ -189,7 +190,10 @@ | |||
| 189 | "importPath": "internal/agent/imagecache", | 190 | "importPath": "internal/agent/imagecache", |
| 190 | "plane": "data", | 191 | "plane": "data", |
| 191 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", | 192 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", |
| 192 | "imports": [] | 193 | "imports": [ |
| 194 | "internal/agent/permanent", | ||
| 195 | "internal/names" | ||
| 196 | ] | ||
| 193 | }, | 197 | }, |
| 194 | { | 198 | { |
| 195 | "importPath": "internal/agent/ipalloc", | 199 | "importPath": "internal/agent/ipalloc", |
| @@ -204,10 +208,17 @@ | |||
| 204 | "imports": [ | 208 | "imports": [ |
| 205 | "internal/agent/dhcp", | 209 | "internal/agent/dhcp", |
| 206 | "internal/agent/exec", | 210 | "internal/agent/exec", |
| 211 | "internal/agent/permanent", | ||
| 207 | "internal/agent/state" | 212 | "internal/agent/state" |
| 208 | ] | 213 | ] |
| 209 | }, | 214 | }, |
| 210 | { | 215 | { |
| 216 | "importPath": "internal/agent/permanent", | ||
| 217 | "plane": "data", | ||
| 218 | "synopsis": "Package permanent mints agent errors that no retry can fix.", | ||
| 219 | "imports": [] | ||
| 220 | }, | ||
| 221 | { | ||
| 211 | "importPath": "internal/agent/pidfile", | 222 | "importPath": "internal/agent/pidfile", |
| 212 | "plane": "data", | 223 | "plane": "data", |
| 213 | "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", | 224 | "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", |
| @@ -300,6 +311,7 @@ | |||
| 300 | "internal/agent/exec", | 311 | "internal/agent/exec", |
| 301 | "internal/agent/hostinfo", | 312 | "internal/agent/hostinfo", |
| 302 | "internal/agent/hyperlog", | 313 | "internal/agent/hyperlog", |
| 314 | "internal/agent/permanent", | ||
| 303 | "internal/agent/pidfile", | 315 | "internal/agent/pidfile", |
| 304 | "internal/agent/state" | 316 | "internal/agent/state" |
| 305 | ] | 317 | ] |
| @@ -316,6 +328,7 @@ | |||
| 316 | "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", | 328 | "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", |
| 317 | "imports": [ | 329 | "imports": [ |
| 318 | "internal/guest", | 330 | "internal/guest", |
| 331 | "internal/names", | ||
| 319 | "internal/server/api/client" | 332 | "internal/server/api/client" |
| 320 | ] | 333 | ] |
| 321 | }, | 334 | }, |
| @@ -335,7 +348,9 @@ | |||
| 335 | "importPath": "internal/gateclient", | 348 | "importPath": "internal/gateclient", |
| 336 | "plane": "tooling", | 349 | "plane": "tooling", |
| 337 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", | 350 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", |
| 338 | "imports": [] | 351 | "imports": [ |
| 352 | "internal/names" | ||
| 353 | ] | ||
| 339 | }, | 354 | }, |
| 340 | { | 355 | { |
| 341 | "importPath": "internal/guest", | 356 | "importPath": "internal/guest", |
| @@ -461,7 +476,9 @@ | |||
| 461 | "importPath": "internal/server/config", | 476 | "importPath": "internal/server/config", |
| 462 | "plane": "control", | 477 | "plane": "control", |
| 463 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", | 478 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", |
| 464 | "imports": [] | 479 | "imports": [ |
| 480 | "internal/names" | ||
| 481 | ] | ||
| 465 | }, | 482 | }, |
| 466 | { | 483 | { |
| 467 | "importPath": "internal/server/delegation", | 484 | "importPath": "internal/server/delegation", |
| @@ -558,7 +575,9 @@ | |||
| 558 | "importPath": "internal/server/vmssh", | 575 | "importPath": "internal/server/vmssh", |
| 559 | "plane": "control", | 576 | "plane": "control", |
| 560 | "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", | 577 | "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", |
| 561 | "imports": [] | 578 | "imports": [ |
| 579 | "internal/names" | ||
| 580 | ] | ||
| 562 | }, | 581 | }, |
| 563 | { | 582 | { |
| 564 | "importPath": "internal/server/web", | 583 | "importPath": "internal/server/web", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -87,6 +87,7 @@ | |||
| 87 | "internal/agent/exec", | 87 | "internal/agent/exec", |
| 88 | "internal/agent/hostinfo", | 88 | "internal/agent/hostinfo", |
| 89 | "internal/agent/hyperlog", | 89 | "internal/agent/hyperlog", |
| 90 | "internal/agent/permanent", | ||
| 90 | "internal/agent/pidfile", | 91 | "internal/agent/pidfile", |
| 91 | "internal/agent/state" | 92 | "internal/agent/state" |
| 92 | ] | 93 | ] |
| @@ -138,7 +139,10 @@ | |||
| 138 | "importPath": "internal/agent/imagecache", | 139 | "importPath": "internal/agent/imagecache", |
| 139 | "plane": "data", | 140 | "plane": "data", |
| 140 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", | 141 | "synopsis": "Package imagecache downloads and verifies content-addressed base images (decoded to raw in-process, LRU-evicted beyond MaxBytes).", |
| 141 | "imports": [] | 142 | "imports": [ |
| 143 | "internal/agent/permanent", | ||
| 144 | "internal/names" | ||
| 145 | ] | ||
| 142 | }, | 146 | }, |
| 143 | { | 147 | { |
| 144 | "importPath": "internal/agent/ipalloc", | 148 | "importPath": "internal/agent/ipalloc", |
| @@ -153,10 +157,17 @@ | |||
| 153 | "imports": [ | 157 | "imports": [ |
| 154 | "internal/agent/dhcp", | 158 | "internal/agent/dhcp", |
| 155 | "internal/agent/exec", | 159 | "internal/agent/exec", |
| 160 | "internal/agent/permanent", | ||
| 156 | "internal/agent/state" | 161 | "internal/agent/state" |
| 157 | ] | 162 | ] |
| 158 | }, | 163 | }, |
| 159 | { | 164 | { |
| 165 | "importPath": "internal/agent/permanent", | ||
| 166 | "plane": "data", | ||
| 167 | "synopsis": "Package permanent mints agent errors that no retry can fix.", | ||
| 168 | "imports": [] | ||
| 169 | }, | ||
| 170 | { | ||
| 160 | "importPath": "internal/agent/pidfile", | 171 | "importPath": "internal/agent/pidfile", |
| 161 | "plane": "data", | 172 | "plane": "data", |
| 162 | "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", | 173 | "synopsis": "Package pidfile records which process a VM's guest runs as, in a form that survives a reboot without lying about it.", |
| @@ -249,6 +260,7 @@ | |||
| 249 | "internal/agent/exec", | 260 | "internal/agent/exec", |
| 250 | "internal/agent/hostinfo", | 261 | "internal/agent/hostinfo", |
| 251 | "internal/agent/hyperlog", | 262 | "internal/agent/hyperlog", |
| 263 | "internal/agent/permanent", | ||
| 252 | "internal/agent/pidfile", | 264 | "internal/agent/pidfile", |
| 253 | "internal/agent/state" | 265 | "internal/agent/state" |
| 254 | ] | 266 | ] |
| @@ -265,6 +277,7 @@ | |||
| 265 | "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", | 277 | "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", |
| 266 | "imports": [ | 278 | "imports": [ |
| 267 | "internal/guest", | 279 | "internal/guest", |
| 280 | "internal/names", | ||
| 268 | "internal/server/api/client" | 281 | "internal/server/api/client" |
| 269 | ] | 282 | ] |
| 270 | }, | 283 | }, |
| @@ -284,7 +297,9 @@ | |||
| 284 | "importPath": "internal/gateclient", | 297 | "importPath": "internal/gateclient", |
| 285 | "plane": "tooling", | 298 | "plane": "tooling", |
| 286 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", | 299 | "synopsis": "Package gateclient is a client of the eitri SSH-CA jump gate: it holds the credential cache (GateAuth) that self-signs short-lived user certs and verifies host certs against the eitri CA, and the two-hop dial (Dial) that reaches a VM by name through the gate.", |
| 287 | "imports": [] | 300 | "imports": [ |
| 301 | "internal/names" | ||
| 302 | ] | ||
| 288 | }, | 303 | }, |
| 289 | { | 304 | { |
| 290 | "importPath": "internal/guest", | 305 | "importPath": "internal/guest", |
| @@ -410,7 +425,9 @@ | |||
| 410 | "importPath": "internal/server/config", | 425 | "importPath": "internal/server/config", |
| 411 | "plane": "control", | 426 | "plane": "control", |
| 412 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", | 427 | "synopsis": "Package config defines the eitri-server on-disk JSON configuration schema, loaded by the server binary (cmd/eitri-server) at startup.", |
| 413 | "imports": [] | 428 | "imports": [ |
| 429 | "internal/names" | ||
| 430 | ] | ||
| 414 | }, | 431 | }, |
| 415 | { | 432 | { |
| 416 | "importPath": "internal/server/delegation", | 433 | "importPath": "internal/server/delegation", |
| @@ -507,7 +524,9 @@ | |||
| 507 | "importPath": "internal/server/vmssh", | 524 | "importPath": "internal/server/vmssh", |
| 508 | "plane": "control", | 525 | "plane": "control", |
| 509 | "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", | 526 | "synopsis": "Package vmssh reaches a tenant's VM from inside the control plane: it tunnels to the guest's sshd over the host's live sync connection and authenticates with the credential that tenant has delegated to eitri.", |
| 510 | "imports": [] | 527 | "imports": [ |
| 528 | "internal/names" | ||
| 529 | ] | ||
| 511 | }, | 530 | }, |
| 512 | { | 531 | { |
| 513 | "importPath": "internal/server/web", | 532 | "importPath": "internal/server/web", |
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | agentexec "github.com/a73x/eitri/internal/agent/exec" | 19 | agentexec "github.com/a73x/eitri/internal/agent/exec" |
| 20 | "github.com/a73x/eitri/internal/agent/hostinfo" | 20 | "github.com/a73x/eitri/internal/agent/hostinfo" |
| 21 | "github.com/a73x/eitri/internal/agent/hyperlog" | 21 | "github.com/a73x/eitri/internal/agent/hyperlog" |
| 22 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 22 | "github.com/a73x/eitri/internal/agent/pidfile" | 23 | "github.com/a73x/eitri/internal/agent/pidfile" |
| 23 | "github.com/a73x/eitri/internal/agent/state" | 24 | "github.com/a73x/eitri/internal/agent/state" |
| 24 | ) | 25 | ) |
| @@ -159,19 +160,6 @@ func (p *Provisioner) buildArgs(spec state.VMSpec) []string { | |||
| 159 | // and small enough that DiskGB<<30 can never overflow int64 (2^50 max). | 160 | // and small enough that DiskGB<<30 can never overflow int64 (2^50 max). |
| 160 | const maxDiskGB = 1 << 20 | 161 | const maxDiskGB = 1 << 20 |
| 161 | 162 | ||
| 162 | // permanentError marks provisioning failures no retry can fix (the disk | ||
| 163 | // guards); reconcile checks the Permanent() marker and terminal-fails | ||
| 164 | // immediately instead of burning its retry budget. | ||
| 165 | type permanentError struct{ err error } | ||
| 166 | |||
| 167 | func (e permanentError) Error() string { return e.err.Error() } | ||
| 168 | func (e permanentError) Unwrap() error { return e.err } | ||
| 169 | func (e permanentError) Permanent() bool { return true } | ||
| 170 | |||
| 171 | func permanentf(format string, args ...any) error { | ||
| 172 | return permanentError{err: fmt.Errorf(format, args...)} | ||
| 173 | } | ||
| 174 | |||
| 175 | // Preflight passes unconditionally: this backend exists only on a host that | 163 | // Preflight passes unconditionally: this backend exists only on a host that |
| 176 | // runs guests, and the one thing it needs beyond itself — the | 164 | // runs guests, and the one thing it needs beyond itself — the |
| 177 | // cloud-hypervisor binary — is installed by the agent's bootstrap step at | 165 | // cloud-hypervisor binary — is installed by the agent's bootstrap step at |
| @@ -196,10 +184,10 @@ func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, ba | |||
| 196 | return fmt.Errorf("stat base image %s: %w", basePath, err) | 184 | return fmt.Errorf("stat base image %s: %w", basePath, err) |
| 197 | } | 185 | } |
| 198 | if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { | 186 | if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { |
| 199 | return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) | 187 | return permanent.Errorf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) |
| 200 | } | 188 | } |
| 201 | if targetBytes := spec.DiskGB << 30; targetBytes < base.Size() { | 189 | if targetBytes := spec.DiskGB << 30; targetBytes < base.Size() { |
| 202 | return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", | 190 | return permanent.Errorf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", |
| 203 | spec.DiskGB, targetBytes, basePath, base.Size()) | 191 | spec.DiskGB, targetBytes, basePath, base.Size()) |
| 204 | } | 192 | } |
| 205 | diskPath := p.st.DiskPath(spec.VMID) | 193 | diskPath := p.st.DiskPath(spec.VMID) |
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | "testing" | 14 | "testing" |
| 15 | "time" | 15 | "time" |
| 16 | 16 | ||
| 17 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 17 | "github.com/a73x/eitri/internal/agent/pidfile" | 18 | "github.com/a73x/eitri/internal/agent/pidfile" |
| 18 | "github.com/a73x/eitri/internal/agent/state" | 19 | "github.com/a73x/eitri/internal/agent/state" |
| 19 | "github.com/stretchr/testify/assert" | 20 | "github.com/stretchr/testify/assert" |
| @@ -577,7 +578,7 @@ func TestBootPreservesThePermanenceMarkerFromTheNetwork(t *testing.T) { | |||
| 577 | require.NoError(t, err) | 578 | require.NoError(t, err) |
| 578 | 579 | ||
| 579 | fn := newFakeNet() | 580 | fn := newFakeNet() |
| 580 | fn.tapErr = permanentError{err: errors.New("name collision")} | 581 | fn.tapErr = permanent.Errorf("name collision") |
| 581 | p := New(st, "/nonexistent/cloud-hypervisor", "fw", nil, fn) | 582 | p := New(st, "/nonexistent/cloud-hypervisor", "fw", nil, fn) |
| 582 | 583 | ||
| 583 | err = p.Boot(context.Background(), "vm-perm", state.VMSpec{VMID: "vm-perm", VCPUs: 1, MemMB: 128}) | 584 | err = p.Boot(context.Background(), "vm-perm", state.VMSpec{VMID: "vm-perm", VCPUs: 1, MemMB: 128}) |
internal/agent/imagecache/format.go
| Old | New | ||
|---|---|---|---|
| @@ -43,20 +43,6 @@ func sniff(head []byte) imageFormat { | |||
| 43 | } | 43 | } |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | // permanentError marks a failure no retry can fix. reconcile matches the | ||
| 47 | // Permanent() method structurally (errors.As against an anonymous interface), | ||
| 48 | // so this mirrors the backends' markers instead of sharing one: what a VMM | ||
| 49 | // driver and an image cache call permanent have nothing else in common. | ||
| 50 | type permanentError struct{ err error } | ||
| 51 | |||
| 52 | func (e permanentError) Error() string { return e.err.Error() } | ||
| 53 | func (e permanentError) Unwrap() error { return e.err } | ||
| 54 | func (e permanentError) Permanent() bool { return true } | ||
| 55 | |||
| 56 | func permanentf(format string, args ...any) error { | ||
| 57 | return permanentError{err: fmt.Errorf(format, args...)} | ||
| 58 | } | ||
| 59 | |||
| 60 | // sniffFile classifies the image file at path. A file shorter than magicLen is | 46 | // sniffFile classifies the image file at path. A file shorter than magicLen is |
| 61 | // read in full and classified on what it has, so a truncated or empty image is | 47 | // read in full and classified on what it has, so a truncated or empty image is |
| 62 | // raw rather than an error — the checksum has already vouched for the bytes by | 48 | // raw rather than an error — the checksum has already vouched for the bytes by |
internal/agent/imagecache/imagecache.go
| Old | New | ||
|---|---|---|---|
| @@ -27,10 +27,11 @@ import ( | |||
| 27 | "net/http" | 27 | "net/http" |
| 28 | "os" | 28 | "os" |
| 29 | "path/filepath" | 29 | "path/filepath" |
| 30 | "regexp" | ||
| 31 | "sort" | 30 | "sort" |
| 32 | "time" | 31 | "time" |
| 33 | 32 | ||
| 33 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 34 | "github.com/a73x/eitri/internal/names" | ||
| 34 | "github.com/lima-vm/go-qcow2reader" | 35 | "github.com/lima-vm/go-qcow2reader" |
| 35 | "github.com/lima-vm/go-qcow2reader/convert" | 36 | "github.com/lima-vm/go-qcow2reader/convert" |
| 36 | "github.com/lima-vm/go-qcow2reader/image" | 37 | "github.com/lima-vm/go-qcow2reader/image" |
| @@ -45,10 +46,6 @@ type httpDoer interface { | |||
| 45 | Do(*http.Request) (*http.Response, error) | 46 | Do(*http.Request) (*http.Response, error) |
| 46 | } | 47 | } |
| 47 | 48 | ||
| 48 | // sha256Re matches a valid lowercase hex SHA-256 digest (exactly 64 chars). | ||
| 49 | // Checked before building any filesystem path to prevent path traversal. | ||
| 50 | var sha256Re = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 51 | |||
| 52 | type Cache struct { | 49 | type Cache struct { |
| 53 | dir string | 50 | dir string |
| 54 | http httpDoer | 51 | http httpDoer |
| @@ -178,7 +175,7 @@ func (c *Cache) fetch(ctx context.Context, url, sha string, progress func(done, | |||
| 178 | func (c *Cache) Ensure(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) { | 175 | func (c *Cache) Ensure(ctx context.Context, url, sha string, progress func(done, total int64)) (string, error) { |
| 179 | // Guard path traversal: sha becomes part of the cache file path. Checked | 176 | // Guard path traversal: sha becomes part of the cache file path. Checked |
| 180 | // before the singleflight so a bad digest can never key an entry. | 177 | // before the singleflight so a bad digest can never key an entry. |
| 181 | if !sha256Re.MatchString(sha) { | 178 | if !names.IsSHA256Hex(sha) { |
| 182 | return "", fmt.Errorf("invalid sha256: %q", sha) | 179 | return "", fmt.Errorf("invalid sha256: %q", sha) |
| 183 | } | 180 | } |
| 184 | 181 | ||
| @@ -288,7 +285,7 @@ func decompress(url, src, dst string) error { | |||
| 288 | // The download's sha already matched, so a broken gzip header is a | 285 | // The download's sha already matched, so a broken gzip header is a |
| 289 | // property of the published artifact: re-fetching returns the same bytes | 286 | // property of the published artifact: re-fetching returns the same bytes |
| 290 | // and fails the same way. | 287 | // and fails the same way. |
| 291 | return permanentf("imagecache: gzip header: %v", err) | 288 | return permanent.Errorf("imagecache: gzip header: %v", err) |
| 292 | } | 289 | } |
| 293 | defer zr.Close() | 290 | defer zr.Close() |
| 294 | 291 | ||
| @@ -308,7 +305,7 @@ func decompress(url, src, dst string) error { | |||
| 308 | if sniff(head) == formatQcow2 { | 305 | if sniff(head) == formatQcow2 { |
| 309 | // Permanent for the same reason a broken header is: the sha matched, so | 306 | // Permanent for the same reason a broken header is: the sha matched, so |
| 310 | // these bytes are what the publisher published. | 307 | // these bytes are what the publisher published. |
| 311 | return permanentf("imagecache: %s decompresses to a qcow2 image; gzip must wrap a RAW image — publish the qcow2 ungzipped (eitri decodes it) or gzip the raw", url) | 308 | return permanent.Errorf("imagecache: %s decompresses to a qcow2 image; gzip must wrap a RAW image — publish the qcow2 ungzipped (eitri decodes it) or gzip the raw", url) |
| 312 | } | 309 | } |
| 313 | 310 | ||
| 314 | out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) | 311 | out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) |
| @@ -348,12 +345,12 @@ func openImage(url, path string) (*imageFile, error) { | |||
| 348 | img, err := qcow2reader.Open(f) | 345 | img, err := qcow2reader.Open(f) |
| 349 | if err != nil { | 346 | if err != nil { |
| 350 | f.Close() | 347 | f.Close() |
| 351 | return nil, permanentf("imagecache: %s: %v", url, err) | 348 | return nil, permanent.Errorf("imagecache: %s: %v", url, err) |
| 352 | } | 349 | } |
| 353 | if err := img.Readable(); err != nil { | 350 | if err := img.Readable(); err != nil { |
| 354 | img.Close() | 351 | img.Close() |
| 355 | f.Close() | 352 | f.Close() |
| 356 | return nil, permanentf("imagecache: %s is a %s image eitri cannot read: %v", url, img.Type(), err) | 353 | return nil, permanent.Errorf("imagecache: %s is a %s image eitri cannot read: %v", url, img.Type(), err) |
| 357 | } | 354 | } |
| 358 | return &imageFile{Image: img, f: f}, nil | 355 | return &imageFile{Image: img, f: f}, nil |
| 359 | } | 356 | } |
internal/agent/netenv/netenv.go
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | "github.com/a73x/eitri/internal/agent/dhcp" | 18 | "github.com/a73x/eitri/internal/agent/dhcp" |
| 19 | "github.com/a73x/eitri/internal/agent/exec" | 19 | "github.com/a73x/eitri/internal/agent/exec" |
| 20 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 20 | "github.com/a73x/eitri/internal/agent/state" | 21 | "github.com/a73x/eitri/internal/agent/state" |
| 21 | ) | 22 | ) |
| 22 | 23 | ||
| @@ -79,15 +80,6 @@ func sysfsIsTapAt(root, name string) bool { | |||
| 79 | return flags&iffTap != 0 | 80 | return flags&iffTap != 0 |
| 80 | } | 81 | } |
| 81 | 82 | ||
| 82 | // tapConflictError marks the name-collision failure permanent: a foreign | ||
| 83 | // device squatting on the tap name never resolves by retrying (reconcile's | ||
| 84 | // Permanent() marker — burns the budget at once when that lands). | ||
| 85 | type tapConflictError struct{ err error } | ||
| 86 | |||
| 87 | func (e tapConflictError) Error() string { return e.err.Error() } | ||
| 88 | func (e tapConflictError) Unwrap() error { return e.err } | ||
| 89 | func (e tapConflictError) Permanent() bool { return true } | ||
| 90 | |||
| 91 | // Gateway returns the host-side IP (.1) on the bridge, as a bare address string. | 83 | // Gateway returns the host-side IP (.1) on the bridge, as a bare address string. |
| 92 | func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() } | 84 | func (n *Net) Gateway() string { return n.cidr.Masked().Addr().Next().String() } |
| 93 | 85 | ||
| @@ -274,7 +266,7 @@ func (n *Net) CreateTap(ctx context.Context, vmID, ip string) error { | |||
| 274 | // Name collision or stale device: silently enslaving a non-tap would | 266 | // Name collision or stale device: silently enslaving a non-tap would |
| 275 | // surface later as an illegible cloud-hypervisor failure. Fail loudly | 267 | // surface later as an illegible cloud-hypervisor failure. Fail loudly |
| 276 | // here instead (pinned by TestCreateTapRejectsNonTapDevice). | 268 | // here instead (pinned by TestCreateTapRejectsNonTapDevice). |
| 277 | return tapConflictError{err: fmt.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap)} | 269 | return permanent.Errorf("link %s exists but is not a TAP device — name collision or stale interface; remove it or rename the VM", tap) |
| 278 | } | 270 | } |
| 279 | if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil { | 271 | if _, err := n.best(ctx, "ip", "link", "set", tap, "master", Bridge); err != nil { |
| 280 | return err | 272 | return err |
internal/agent/netenv/netenv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,6 @@ package netenv | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | ||
| 7 | "net" | 6 | "net" |
| 8 | "os" | 7 | "os" |
| 9 | "strings" | 8 | "strings" |
| @@ -334,15 +333,9 @@ func TestSysfsIsTap(t *testing.T) { | |||
| 334 | assert.False(t, isTap("absent"), "missing link is not a tap") | 333 | assert.False(t, isTap("absent"), "missing link is not a tap") |
| 335 | } | 334 | } |
| 336 | 335 | ||
| 337 | // TestTapConflictErrorIsPermanent pins the marker + unwrap on the conflict error. | 336 | // The tap name-collision failure is marked permanent (reconcile terminal-fails |
| 338 | func TestTapConflictErrorIsPermanent(t *testing.T) { | 337 | // on it in one attempt); TestCreateTapRejectsNonTapDevice pins that on the |
| 339 | inner := fmt.Errorf("boom") | 338 | // production path, and internal/agent/permanent owns the marker's unit test. |
| 340 | e := tapConflictError{err: inner} | ||
| 341 | assert.Equal(t, "boom", e.Error()) | ||
| 342 | assert.Equal(t, inner, e.Unwrap()) | ||
| 343 | var p interface{ Permanent() bool } | ||
| 344 | assert.True(t, errors.As(error(e), &p) && p.Permanent()) | ||
| 345 | } | ||
| 346 | 339 | ||
| 347 | func TestCreateTapAddsReservationAndTapCommands(t *testing.T) { | 340 | func TestCreateTapAddsReservationAndTapCommands(t *testing.T) { |
| 348 | errs := map[string]error{ | 341 | errs := map[string]error{ |
internal/agent/permanent/permanent.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,30 @@ | |||
| 1 | // Package permanent mints agent errors that no retry can fix. A provisioner or | ||
| 2 | // the image cache returns one when a create can never succeed as asked — a disk | ||
| 3 | // that would have to shrink, an image digest that will never match, a vfkit | ||
| 4 | // binary the host cannot legally run — so the reconcile loop spends its whole | ||
| 5 | // retry budget at once instead of grinding on a failure that is deterministic. | ||
| 6 | // | ||
| 7 | // Only the producer lives here. The consumer-side check does not: reconcile | ||
| 8 | // matches the Permanent() marker structurally (errors.As against an anonymous | ||
| 9 | // interface), so nothing imports this package to READ a permanent error, only | ||
| 10 | // to MINT one. That split is deliberate — what a VMM driver and an image cache | ||
| 11 | // call permanent have nothing else in common, and the decision to terminal-fail | ||
| 12 | // stays where the retry budget is spent. | ||
| 13 | package permanent | ||
| 14 | |||
| 15 | import "fmt" | ||
| 16 | |||
| 17 | // permanentError carries the marker. It is unexported: callers mint one through | ||
| 18 | // Errorf and read it back through the structural Permanent() check, never by | ||
| 19 | // name. | ||
| 20 | type permanentError struct{ err error } | ||
| 21 | |||
| 22 | func (e permanentError) Error() string { return e.err.Error() } | ||
| 23 | func (e permanentError) Unwrap() error { return e.err } | ||
| 24 | func (e permanentError) Permanent() bool { return true } | ||
| 25 | |||
| 26 | // Errorf returns a permanent error with a formatted message. Wrap a cause with | ||
| 27 | // %w to keep it in the chain (errors.Is/As still traverse it). | ||
| 28 | func Errorf(format string, args ...any) error { | ||
| 29 | return permanentError{err: fmt.Errorf(format, args...)} | ||
| 30 | } | ||
internal/agent/permanent/permanent_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,22 @@ | |||
| 1 | package permanent | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func TestErrorfCarriesTheMarkerAndKeepsTheCause(t *testing.T) { | ||
| 9 | inner := errors.New("boom") | ||
| 10 | e := Errorf("wrapped: %w", inner) | ||
| 11 | |||
| 12 | if got := e.Error(); got != "wrapped: boom" { | ||
| 13 | t.Fatalf("Error() = %q, want %q", got, "wrapped: boom") | ||
| 14 | } | ||
| 15 | if !errors.Is(e, inner) { | ||
| 16 | t.Fatal("Errorf must keep the wrapped cause in the chain") | ||
| 17 | } | ||
| 18 | var p interface{ Permanent() bool } | ||
| 19 | if !errors.As(e, &p) || !p.Permanent() { | ||
| 20 | t.Fatal("Errorf must carry the Permanent() marker reconcile terminal-fails on") | ||
| 21 | } | ||
| 22 | } | ||
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -854,7 +854,8 @@ func (e *Engine) create(ctx context.Context, d *pb.VMDesired, rec state.Record, | |||
| 854 | // permanent reports whether err (anywhere in its chain) carries the | 854 | // permanent reports whether err (anywhere in its chain) carries the |
| 855 | // consumer-owned permanence marker — the provisioner's way of saying no | 855 | // consumer-owned permanence marker — the provisioner's way of saying no |
| 856 | // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side | 856 | // retry can ever succeed (e.g. the disk-shrink guard). Consumer-side |
| 857 | // interface per the R5 convention: reconcile declares it, cloudhv implements. | 857 | // interface per the R5 convention: reconcile declares it; the producers |
| 858 | // (cloudhv, vfkit, imagecache, netenv) mint one via internal/agent/permanent. | ||
| 858 | func permanent(err error) bool { | 859 | func permanent(err error) bool { |
| 859 | var p interface{ Permanent() bool } | 860 | var p interface{ Permanent() bool } |
| 860 | return errors.As(err, &p) && p.Permanent() | 861 | return errors.As(err, &p) && p.Permanent() |
internal/agent/vfkit/vfkit.go
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,7 @@ import ( | |||
| 33 | agentexec "github.com/a73x/eitri/internal/agent/exec" | 33 | agentexec "github.com/a73x/eitri/internal/agent/exec" |
| 34 | "github.com/a73x/eitri/internal/agent/hostinfo" | 34 | "github.com/a73x/eitri/internal/agent/hostinfo" |
| 35 | "github.com/a73x/eitri/internal/agent/hyperlog" | 35 | "github.com/a73x/eitri/internal/agent/hyperlog" |
| 36 | "github.com/a73x/eitri/internal/agent/permanent" | ||
| 36 | "github.com/a73x/eitri/internal/agent/pidfile" | 37 | "github.com/a73x/eitri/internal/agent/pidfile" |
| 37 | "github.com/a73x/eitri/internal/agent/state" | 38 | "github.com/a73x/eitri/internal/agent/state" |
| 38 | ) | 39 | ) |
| @@ -110,20 +111,6 @@ func New(st *state.Store, bin string, run agentexec.Runner) *Provisioner { | |||
| 110 | } | 111 | } |
| 111 | } | 112 | } |
| 112 | 113 | ||
| 113 | // permanentError marks a failure no retry can fix. reconcile matches the | ||
| 114 | // Permanent() method structurally (errors.As against an anonymous interface), | ||
| 115 | // so this mirrors cloudhv's marker rather than sharing one — a shared type | ||
| 116 | // would be the only thing the two VMM drivers have in common. | ||
| 117 | type permanentError struct{ err error } | ||
| 118 | |||
| 119 | func (e permanentError) Error() string { return e.err.Error() } | ||
| 120 | func (e permanentError) Unwrap() error { return e.err } | ||
| 121 | func (e permanentError) Permanent() bool { return true } | ||
| 122 | |||
| 123 | func permanentf(format string, args ...any) error { | ||
| 124 | return permanentError{err: fmt.Errorf(format, args...)} | ||
| 125 | } | ||
| 126 | |||
| 127 | // Preflight refuses a host that has no vfkit. The one thing this backend needs | 114 | // Preflight refuses a host that has no vfkit. The one thing this backend needs |
| 128 | // beyond itself cannot be installed by the agent the way cloud-hypervisor can: | 115 | // beyond itself cannot be installed by the agent the way cloud-hypervisor can: |
| 129 | // vfkit must carry Apple's virtualization entitlement, and an entitlement | 116 | // vfkit must carry Apple's virtualization entitlement, and an entitlement |
| @@ -141,11 +128,11 @@ func permanentf(format string, args ...any) error { | |||
| 141 | // vfkit, after the image download, in a sentence about URIs. | 128 | // vfkit, after the image download, in a sentence about URIs. |
| 142 | func (p *Provisioner) Preflight(_ context.Context) error { | 129 | func (p *Provisioner) Preflight(_ context.Context) error { |
| 143 | if _, err := p.lookPath(p.bin); err != nil { | 130 | if _, err := p.lookPath(p.bin); err != nil { |
| 144 | return permanentf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+ | 131 | return permanent.Errorf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+ |
| 145 | "the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin) | 132 | "the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin) |
| 146 | } | 133 | } |
| 147 | if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath { | 134 | if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath { |
| 148 | return permanentf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+ | 135 | return permanent.Errorf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+ |
| 149 | "run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath) | 136 | "run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath) |
| 150 | } | 137 | } |
| 151 | return nil | 138 | return nil |
| @@ -262,11 +249,11 @@ func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, ba | |||
| 262 | return fmt.Errorf("stat base image %s: %w", basePath, err) | 249 | return fmt.Errorf("stat base image %s: %w", basePath, err) |
| 263 | } | 250 | } |
| 264 | if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { | 251 | if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB { |
| 265 | return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) | 252 | return permanent.Errorf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB)) |
| 266 | } | 253 | } |
| 267 | targetBytes := spec.DiskGB << 30 | 254 | targetBytes := spec.DiskGB << 30 |
| 268 | if targetBytes < base.Size() { | 255 | if targetBytes < base.Size() { |
| 269 | return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", | 256 | return permanent.Errorf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest", |
| 270 | spec.DiskGB, targetBytes, basePath, base.Size()) | 257 | spec.DiskGB, targetBytes, basePath, base.Size()) |
| 271 | } | 258 | } |
| 272 | if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { | 259 | if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil { |
internal/cli/sshcmd.go
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | "syscall" | 11 | "syscall" |
| 12 | 12 | ||
| 13 | "github.com/a73x/eitri/internal/guest" | 13 | "github.com/a73x/eitri/internal/guest" |
| 14 | "github.com/a73x/eitri/internal/names" | ||
| 14 | "github.com/a73x/eitri/internal/server/api/client" | 15 | "github.com/a73x/eitri/internal/server/api/client" |
| 15 | ) | 16 | ) |
| 16 | 17 | ||
| @@ -44,7 +45,7 @@ func SSHArgv(e Env, vm string, extra []string) []string { | |||
| 44 | "-o", "StrictHostKeyChecking=yes", | 45 | "-o", "StrictHostKeyChecking=yes", |
| 45 | "-o", "UserKnownHostsFile=" + e.KnownHosts, | 46 | "-o", "UserKnownHostsFile=" + e.KnownHosts, |
| 46 | "-i", e.Key, | 47 | "-i", e.Key, |
| 47 | guest.LoginUser + "@" + e.Tenant + "." + vm, | 48 | guest.LoginUser + "@" + names.ConnectName(e.Tenant, vm), |
| 48 | } | 49 | } |
| 49 | return append(argv, extra...) | 50 | return append(argv, extra...) |
| 50 | } | 51 | } |
internal/gateclient/auth.go
| Old | New | ||
|---|---|---|---|
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | "sync" | 16 | "sync" |
| 17 | "time" | 17 | "time" |
| 18 | 18 | ||
| 19 | "github.com/a73x/eitri/internal/names" | ||
| 19 | "golang.org/x/crypto/ssh" | 20 | "golang.org/x/crypto/ssh" |
| 20 | ) | 21 | ) |
| 21 | 22 | ||
| @@ -154,7 +155,7 @@ func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, erro | |||
| 154 | } | 155 | } |
| 155 | g.mu.Lock() | 156 | g.mu.Lock() |
| 156 | defer g.mu.Unlock() | 157 | defer g.mu.Unlock() |
| 157 | return g.tenant + "." + vmName, nil | 158 | return names.ConnectName(g.tenant, vmName), nil |
| 158 | } | 159 | } |
| 159 | 160 | ||
| 160 | // needsMintLocked reports whether the cached cert is absent or expires | 161 | // needsMintLocked reports whether the cached cert is absent or expires |
internal/joinblob/joinblob.go
| Old | New | ||
|---|---|---|---|
| @@ -21,7 +21,9 @@ const Prefix = "eitri_join_" | |||
| 21 | // version is the current blob format version. | 21 | // version is the current blob format version. |
| 22 | const version = 1 | 22 | const version = 1 |
| 23 | 23 | ||
| 24 | // sha256Hex matches exactly 64 lowercase hex characters. | 24 | // sha256Hex matches exactly 64 lowercase hex characters. names.IsSHA256Hex is |
| 25 | // the same check, but joinblob is a wire-plane leaf (arch R9) and may import no | ||
| 26 | // other internal package — so this one stays its own copy on purpose. | ||
| 25 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) | 27 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) |
| 26 | 28 | ||
| 27 | // Fields is the decoded content of a join blob. | 29 | // Fields is the decoded content of a join blob. |
internal/mcpserver/server.go
| Old | New | ||
|---|---|---|---|
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | // Options is what a server needs beyond the tools themselves. | 15 | // Options is what a server needs beyond the tools themselves. |
| 16 | type Options struct { | 16 | type Options struct { |
| 17 | Delegator Delegator // non-nil ⇒ expose the delegate tools | 17 | Delegator Delegator // required (non-nil): every server exposes the delegate tools |
| 18 | SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection | 18 | SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection |
| 19 | } | 19 | } |
| 20 | 20 | ||
| @@ -79,24 +79,22 @@ func NewServer(t *Tools, opts Options) *mcp.Server { | |||
| 79 | "currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+ | 79 | "currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+ |
| 80 | "when an SSH operation fails, instead of guessing which step was skipped.", | 80 | "when an SSH operation fails, instead of guessing which step was skipped.", |
| 81 | t.TenantInfo) | 81 | t.TenantInfo) |
| 82 | if opts.Delegator != nil { | 82 | dg := opts.Delegator |
| 83 | dg := opts.Delegator | 83 | register(s, "delegate_begin", |
| 84 | register(s, "delegate_begin", | 84 | "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+ |
| 85 | "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+ | 85 | "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+ |
| 86 | "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+ | 86 | "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+ |
| 87 | "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+ | 87 | "until the control plane restarts — a delegation is held in memory, so after a restart call this "+ |
| 88 | "until the control plane restarts — a delegation is held in memory, so after a restart call this "+ | 88 | "tool again for the new key rather than reusing an old certificate.", |
| 89 | "tool again for the new key rather than reusing an old certificate.", | 89 | func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) }) |
| 90 | func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) }) | 90 | register(s, "delegate_complete", |
| 91 | register(s, "delegate_complete", | 91 | "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+ |
| 92 | "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+ | 92 | "argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+ |
| 93 | "argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+ | 93 | "Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+ |
| 94 | "Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+ | 94 | "tenant already registered, VMs created before this call accept it too.", |
| 95 | "tenant already registered, VMs created before this call accept it too.", | 95 | func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) { |
| 96 | func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) { | 96 | return dg.Complete(ctx, in.Certificate) |
| 97 | return dg.Complete(ctx, in.Certificate) | 97 | }) |
| 98 | }) | ||
| 99 | } | ||
| 100 | return s | 98 | return s |
| 101 | } | 99 | } |
| 102 | 100 | ||
internal/mcpserver/server_test.go
| Old | New | ||
|---|---|---|---|
| @@ -86,19 +86,13 @@ func describe(t *testing.T, cs *mcp.ClientSession, name string) string { | |||
| 86 | return "" | 86 | return "" |
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | // TestNewServerExposesTheCommonTools: without a Delegator the server offers the | 89 | // TestNewServerExposesEveryTool: the Delegator is required, so there is only |
| 90 | // VM tools and ca_upload, and nothing else — there is nothing to delegate | 90 | // one server shape and it offers the VM tools, ca_upload, tenant_info, and both |
| 91 | // through. | 91 | // delegate tools. A change here is a change to the published contract. |
| 92 | func TestNewServerExposesTheCommonTools(t *testing.T) { | 92 | func TestNewServerExposesEveryTool(t *testing.T) { |
| 93 | names := toolNames(t, NewServer(&Tools{}, Options{})) | ||
| 94 | assert.ElementsMatch(t, commonTools, names) | ||
| 95 | } | ||
| 96 | |||
| 97 | // TestNewServerAddsTheDelegateToolsForARemoteTransport: the two extra tools | ||
| 98 | // appear only when a transport can carry the exchange. | ||
| 99 | func TestNewServerAddsTheDelegateToolsForARemoteTransport(t *testing.T) { | ||
| 100 | names := toolNames(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}})) | 93 | names := toolNames(t, NewServer(&Tools{}, Options{Delegator: &fakeDelegator{}})) |
| 101 | assert.ElementsMatch(t, append(append([]string{}, commonTools...), "delegate_begin", "delegate_complete"), names) | 94 | want := append(append([]string{}, commonTools...), "delegate_begin", "delegate_complete") |
| 95 | assert.ElementsMatch(t, want, names) | ||
| 102 | } | 96 | } |
| 103 | 97 | ||
| 104 | // TestDelegateBeginDescriptionStopsAModelSigningItItself: the description is | 98 | // TestDelegateBeginDescriptionStopsAModelSigningItItself: the description is |
internal/names/names.go
| Old | New | ||
|---|---|---|---|
| @@ -12,3 +12,19 @@ var rfc1123Label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) | |||
| 12 | 12 | ||
| 13 | // IsRFC1123Label reports whether s is a valid RFC-1123 DNS label. | 13 | // IsRFC1123Label reports whether s is a valid RFC-1123 DNS label. |
| 14 | func IsRFC1123Label(s string) bool { return rfc1123Label.MatchString(s) } | 14 | func IsRFC1123Label(s string) bool { return rfc1123Label.MatchString(s) } |
| 15 | |||
| 16 | // sha256Hex matches a lowercase hex SHA-256 digest: exactly 64 hex characters. | ||
| 17 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 18 | |||
| 19 | // IsSHA256Hex reports whether s is a lowercase hex SHA-256 digest (exactly 64 | ||
| 20 | // hex characters) — the shape of an image content digest and of the cert | ||
| 21 | // fingerprint on a join blob. Both planes check it before building a filesystem | ||
| 22 | // path or trusting the value, so it lives in this dependency-free leaf. | ||
| 23 | func IsSHA256Hex(s string) bool { return sha256Hex.MatchString(s) } | ||
| 24 | |||
| 25 | // ConnectName returns a VM's connect name, "<tenant>.<name>" — the form the SSH | ||
| 26 | // gate resolves and the VM's host certificate is issued for. It splits back | ||
| 27 | // unambiguously on the first dot only because tenant ids and VM names are both | ||
| 28 | // dot-free (IsRFC1123Label rejects dots), so the leaf that enforces that shape | ||
| 29 | // is also the one place that composes the name from it. | ||
| 30 | func ConnectName(tenant, name string) string { return tenant + "." + name } | ||
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | "fmt" | 11 | "fmt" |
| 12 | "log/slog" | 12 | "log/slog" |
| 13 | "net/http" | 13 | "net/http" |
| 14 | "regexp" | ||
| 15 | "strings" | 14 | "strings" |
| 16 | "time" | 15 | "time" |
| 17 | 16 | ||
| @@ -28,10 +27,8 @@ import ( | |||
| 28 | "github.com/a73x/eitri/internal/server/store" | 27 | "github.com/a73x/eitri/internal/server/store" |
| 29 | ) | 28 | ) |
| 30 | 29 | ||
| 31 | // VM names are validated as RFC-1123 DNS labels via internal/names. | 30 | // VM names are validated as RFC-1123 DNS labels, and image digests as hex |
| 32 | 31 | // SHA-256, via internal/names. | |
| 33 | // sha256Hex matches exactly 64 lowercase hex characters. | ||
| 34 | var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 35 | 32 | ||
| 36 | // DefaultImage is the image applied to one-click VM creates. | 33 | // DefaultImage is the image applied to one-click VM creates. |
| 37 | type DefaultImage struct { | 34 | type DefaultImage struct { |
| @@ -734,7 +731,7 @@ func validateCreateVM(req *types.CreateVMRequest) (string, int) { | |||
| 734 | } | 731 | } |
| 735 | // Exactly 64 lowercase hex digits — catches a misconfigured server default | 732 | // Exactly 64 lowercase hex digits — catches a misconfigured server default |
| 736 | // (e.g. "pinned") at create time with a clear error instead of a later mismatch. | 733 | // (e.g. "pinned") at create time with a clear error instead of a later mismatch. |
| 737 | if !sha256Hex.MatchString(req.ImageSHA256) { | 734 | if !names.IsSHA256Hex(req.ImageSHA256) { |
| 738 | return "invalid image_sha256", http.StatusBadRequest | 735 | return "invalid image_sha256", http.StatusBadRequest |
| 739 | } | 736 | } |
| 740 | // Resource floors (post-defaults, so a zero has already become the default). | 737 | // Resource floors (post-defaults, so a zero has already become the default). |
| @@ -934,7 +931,7 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 934 | 931 | ||
| 935 | // Frozen from the set the refusal above just checked, so the row records | 932 | // Frozen from the set the refusal above just checked, so the row records |
| 936 | // exactly what permitted it to exist. | 933 | // exactly what permitted it to exist. |
| 937 | TrustedCAs: freezeTrustedCAs(tenantCAs), | 934 | TrustedCAs: store.FreezeCAs(tenantCAs), |
| 938 | 935 | ||
| 939 | VCPUs: req.VCPUs, | 936 | VCPUs: req.VCPUs, |
| 940 | MemMB: req.MemMB, | 937 | MemMB: req.MemMB, |
internal/server/api/trustedcas.go
| Old | New | ||
|---|---|---|---|
| @@ -3,39 +3,11 @@ package api | |||
| 3 | import ( | 3 | import ( |
| 4 | "github.com/a73x/eitri/internal/server/api/types" | 4 | "github.com/a73x/eitri/internal/server/api/types" |
| 5 | "github.com/a73x/eitri/internal/server/store" | 5 | "github.com/a73x/eitri/internal/server/store" |
| 6 | "golang.org/x/crypto/ssh" | ||
| 7 | ) | 6 | ) |
| 8 | 7 | ||
| 9 | // freezeTrustedCAs turns the tenant's registered CA set into the record that | 8 | // The create path freezes a tenant's CA set onto the VM row through |
| 10 | // goes onto a VM row at create — the set that guest will trust for the rest of | 9 | // store.FreezeCAs — the same helper the snapshot path uses, so both write an |
| 11 | // its life, whatever the tenant registers or removes afterwards. | 10 | // identical record from one source. |
| 12 | // | ||
| 13 | // It carries the authorized_keys line as well as the fingerprint because the | ||
| 14 | // row has to SERVE this trust to the agent that bakes it, and a fingerprint is | ||
| 15 | // one-way. The fingerprint is computed once, here, for the same reason the | ||
| 16 | // injected key's is (see describeKey): the console re-reads these rows on a | ||
| 17 | // tick and parsing a key per read would be work repeated forever. | ||
| 18 | // | ||
| 19 | // A CA whose stored line will not parse still contributes its line, with an | ||
| 20 | // empty fingerprint. The line is what reaches the guest, so dropping the entry | ||
| 21 | // would quietly narrow the trust the tenant asked for; an empty fingerprint | ||
| 22 | // says "we could not name this one", which is a display problem, not a reason | ||
| 23 | // to change what the guest trusts. | ||
| 24 | func freezeTrustedCAs(cas []store.TenantUserCA) []store.TrustedCA { | ||
| 25 | out := make([]store.TrustedCA, 0, len(cas)) | ||
| 26 | for _, c := range cas { | ||
| 27 | fp := "" | ||
| 28 | if pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(c.Pubkey)); err == nil { | ||
| 29 | fp = ssh.FingerprintSHA256(pub) | ||
| 30 | } | ||
| 31 | out = append(out, store.TrustedCA{ | ||
| 32 | Label: c.Label, | ||
| 33 | Fingerprint: fp, | ||
| 34 | AuthorizedKey: c.Pubkey, | ||
| 35 | }) | ||
| 36 | } | ||
| 37 | return out | ||
| 38 | } | ||
| 39 | 11 | ||
| 40 | // trustedCAs renders a VM row's frozen CA set for the wire, or nil when the row | 12 | // trustedCAs renders a VM row's frozen CA set for the wire, or nil when the row |
| 41 | // carries no record — a VM created before the set was written down. nil is the | 13 | // carries no record — a VM created before the set was written down. nil is the |
internal/server/config/load.go
| Old | New | ||
|---|---|---|---|
| @@ -12,16 +12,12 @@ import ( | |||
| 12 | "maps" | 12 | "maps" |
| 13 | "net/url" | 13 | "net/url" |
| 14 | "os" | 14 | "os" |
| 15 | "regexp" | ||
| 16 | "slices" | 15 | "slices" |
| 17 | "strings" | 16 | "strings" |
| 18 | "time" | 17 | "time" |
| 19 | ) | ||
| 20 | 18 | ||
| 21 | // defaultImageSHARe matches a valid lowercase hex SHA-256 digest. Kept local | 19 | "github.com/a73x/eitri/internal/names" |
| 22 | // to the control plane rather than shared with the agent's imagecache: R1 | 20 | ) |
| 23 | // forbids the control plane importing the data plane, even transitively. | ||
| 24 | var defaultImageSHARe = regexp.MustCompile(`^[a-f0-9]{64}$`) | ||
| 25 | 21 | ||
| 26 | // Load reads path, decodes the JSON, and enforces every startup invariant the | 22 | // Load reads path, decodes the JSON, and enforces every startup invariant the |
| 27 | // server refuses to boot without. It warns (but does not fail) on the retired | 23 | // server refuses to boot without. It warns (but does not fail) on the retired |
| @@ -104,7 +100,7 @@ func validate(cfg Config) error { | |||
| 104 | return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`) | 100 | return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`) |
| 105 | case img.URL == "": | 101 | case img.URL == "": |
| 106 | return fmt.Errorf("default_images[%q].url is required", arch) | 102 | return fmt.Errorf("default_images[%q].url is required", arch) |
| 107 | case !defaultImageSHARe.MatchString(img.SHA256): | 103 | case !names.IsSHA256Hex(img.SHA256): |
| 108 | return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256) | 104 | return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256) |
| 109 | } | 105 | } |
| 110 | } | 106 | } |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -641,11 +641,17 @@ func (s *Store) RedeemEnrollmentToken(tok string, f EnrollFacts) (Host, error) { | |||
| 641 | }, nil | 641 | }, nil |
| 642 | } | 642 | } |
| 643 | 643 | ||
| 644 | // hostColumns is the positional column list every host SELECT uses, so the | ||
| 645 | // order stays locked to the positional Scans in GetHost and listHosts below. | ||
| 646 | // Adding a column is one edit here plus the two Scans — no query can drift out | ||
| 647 | // of lockstep. Mirrors vmColumns/exposureColumns/tenantColumns. | ||
| 648 | const hostColumns = `id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr` | ||
| 649 | |||
| 644 | func (s *Store) GetHost(id string) (Host, error) { | 650 | func (s *Store) GetHost(id string) (Host, error) { |
| 645 | var h Host | 651 | var h Host |
| 646 | var enrolledAt string | 652 | var enrolledAt string |
| 647 | err := s.db.QueryRow( | 653 | err := s.db.QueryRow( |
| 648 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts WHERE id=?`, id, | 654 | `SELECT `+hostColumns+` FROM hosts WHERE id=?`, id, |
| 649 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr) | 655 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant, &h.OSID, &h.OSPretty, &h.OSVersion, &h.Kernel, &h.CPUModel, &h.Virt, &h.UplinkAddr) |
| 650 | if err != nil { | 656 | if err != nil { |
| 651 | return Host{}, err | 657 | return Host{}, err |
| @@ -775,7 +781,7 @@ type querier interface { | |||
| 775 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } | 781 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } |
| 776 | 782 | ||
| 777 | func listHosts(q querier) ([]Host, error) { | 783 | func listHosts(q querier) ([]Host, error) { |
| 778 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant, os_id, os_pretty, os_version, kernel, cpu_model, virt, uplink_addr FROM hosts`) | 784 | rows, err := q.Query(`SELECT ` + hostColumns + ` FROM hosts`) |
| 779 | if err != nil { | 785 | if err != nil { |
| 780 | return nil, err | 786 | return nil, err |
| 781 | } | 787 | } |
internal/server/store/trustedcas.go
| Old | New | ||
|---|---|---|---|
| @@ -52,7 +52,7 @@ func (s *Store) backfillTrustedCAs() error { | |||
| 52 | if err != nil { | 52 | if err != nil { |
| 53 | return fmt.Errorf("list user cas for %s: %w", r.tenant, err) | 53 | return fmt.Errorf("list user cas for %s: %w", r.tenant, err) |
| 54 | } | 54 | } |
| 55 | b, err := json.Marshal(freezeCAs(cas)) | 55 | b, err := json.Marshal(FreezeCAs(cas)) |
| 56 | if err != nil { | 56 | if err != nil { |
| 57 | return fmt.Errorf("marshal frozen cas for %s: %w", r.tenant, err) | 57 | return fmt.Errorf("marshal frozen cas for %s: %w", r.tenant, err) |
| 58 | } | 58 | } |
| @@ -96,19 +96,25 @@ func (s *Store) unrecordedVMs() ([]unrecordedVM, error) { | |||
| 96 | return out, nil | 96 | return out, nil |
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | // freezeCAs turns a tenant's registered CAs into the record that goes onto a VM | 99 | // FreezeCAs turns a tenant's registered CA set into the record that goes onto a |
| 100 | // row: the label, the SHA256 fingerprint OpenSSH would print, and the | 100 | // VM row at create — the set that guest will trust for the rest of its life, |
| 101 | // authorized_keys line itself — the line because the row has to SERVE this trust | 101 | // whatever the tenant registers or removes afterwards. |
| 102 | // to an agent and a fingerprint is one-way. A key line that will not parse still | ||
| 103 | // contributes its line with an empty fingerprint, since the line is what reaches | ||
| 104 | // the guest and dropping it would quietly narrow the tenant's trust. | ||
| 105 | // | 102 | // |
| 106 | // This is the same record api.freezeTrustedCAs writes at create, deliberately | 103 | // It carries the authorized_keys line as well as the SHA256 fingerprint |
| 107 | // producing an identical shape — the JSON is the tag set on TrustedCA, which | 104 | // OpenSSH would print, because the row has to SERVE this trust to the agent |
| 108 | // both share. It is a second copy because the API package imports this one and | 105 | // that bakes it and a fingerprint is one-way. The fingerprint is computed once, |
| 109 | // not the other way around; collapsing the two means moving the create-path | 106 | // here, so a console re-reading these rows on a tick does not parse a key per |
| 110 | // helper down here, which is a change to the API layer. | 107 | // read forever. |
| 111 | func freezeCAs(cas []TenantUserCA) []TrustedCA { | 108 | // |
| 109 | // A CA whose stored line will not parse still contributes its line, with an | ||
| 110 | // empty fingerprint. The line is what reaches the guest, so dropping the entry | ||
| 111 | // would quietly narrow the trust the tenant asked for; an empty fingerprint | ||
| 112 | // says "we could not name this one", a display problem, not a reason to change | ||
| 113 | // what the guest trusts. | ||
| 114 | // | ||
| 115 | // Exported so the create path (internal/server/api) freezes exactly what the | ||
| 116 | // snapshot path does, from one source — the API package already imports store. | ||
| 117 | func FreezeCAs(cas []TenantUserCA) []TrustedCA { | ||
| 112 | out := make([]TrustedCA, 0, len(cas)) | 118 | out := make([]TrustedCA, 0, len(cas)) |
| 113 | for _, c := range cas { | 119 | for _, c := range cas { |
| 114 | fp := "" | 120 | fp := "" |
internal/server/vmssh/vmssh.go
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | "strings" | 24 | "strings" |
| 25 | "time" | 25 | "time" |
| 26 | 26 | ||
| 27 | "github.com/a73x/eitri/internal/names" | ||
| 27 | "golang.org/x/crypto/ssh" | 28 | "golang.org/x/crypto/ssh" |
| 28 | ) | 29 | ) |
| 29 | 30 | ||
| @@ -84,7 +85,7 @@ type Dialer struct { | |||
| 84 | // ConnectName returns the VM's <tenant>.<name> connect name — the form the gate | 85 | // ConnectName returns the VM's <tenant>.<name> connect name — the form the gate |
| 85 | // resolves and the VM's host certificate names. | 86 | // resolves and the VM's host certificate names. |
| 86 | func (d *Dialer) ConnectName(_ context.Context, vmName string) (string, error) { | 87 | func (d *Dialer) ConnectName(_ context.Context, vmName string) (string, error) { |
| 87 | return d.Tenant + "." + vmName, nil | 88 | return names.ConnectName(d.Tenant, vmName), nil |
| 88 | } | 89 | } |
| 89 | 90 | ||
| 90 | // Dial opens an authenticated SSH connection to the VM named vmName. The caller | 91 | // Dial opens an authenticated SSH connection to the VM named vmName. The caller |