ef7319c9
seed: a tenant's own cloud-init keeps eitri's trust file and disk grow
a73x 2026-09-02 09:42
Commit message
deploy/server/README.md
| Old | New | ||
|---|---|---|---|
| @@ -455,7 +455,7 @@ the other's guests. | |||
| 455 | All four of these must pass before mewtwo is allowed to move: | 455 | All four of these must pass before mewtwo is allowed to move: |
| 456 | 456 | ||
| 457 | 1. `devhost.sh create` from nothing, then `make deploy` → boot gate **PASS** | 457 | 1. `devhost.sh create` from nothing, then `make deploy` → boot gate **PASS** |
| 458 | including `gate SSH: ok`, `exposed port: ok`, `remote MCP: ok`. A first pass | 458 | including `gate SSH: ok`, `BYO cloud-init merge: ok`, `exposed port: ok`, `remote MCP: ok`. A first pass |
| 459 | on a cold host also proves the agent bootstrap — cloud-hypervisor and | 459 | on a cold host also proves the agent bootstrap — cloud-hypervisor and |
| 460 | `CLOUDHV.fd` downloaded and sha-verified — works nested. | 460 | `CLOUDHV.fd` downloaded and sha-verified — works nested. |
| 461 | 2. A second `make deploy` on the warm host → PASS. Proves the agent-swap path | 461 | 2. A second `make deploy` on the warm host → PASS. Proves the agent-swap path |
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -702,3 +702,32 @@ sweeps the whole import graph for `protojson`, and `depguard` denies it in the | |||
| 702 | editor. Falsified the moment that import lands anywhere, because the names it | 702 | editor. Falsified the moment that import lands anywhere, because the names it |
| 703 | writes to a log or a row become a contract retroactively — and the rename after | 703 | writes to a log or a row become a contract retroactively — and the rename after |
| 704 | that one breaks it silently. | 704 | that one breaks it silently. |
| 705 | |||
| 706 | ### cloud-init honours `merge_how` from the part being merged in | ||
| 707 | |||
| 708 | eitri's own cloud-config — the CA trust file and sshd drop-in, the clock | ||
| 709 | drop-in, the online root grow, the sshd reload — reaches a guest as the LAST | ||
| 710 | part of a multipart user-data, after the tenant's own document. Within one data | ||
| 711 | source, cloud-init's cloud-config handler folds each part into what the earlier | ||
| 712 | parts built, using the mergers the incoming part names, and a part that names | ||
| 713 | none falls back to `dict(replace)+list()+str()`. So eitri's part carries | ||
| 714 | `merge_how: "list(prepend)+dict(recurse_array,no_replace)+str()"`, and that one | ||
| 715 | line is why a tenant's `write_files` and eitri's both land, why eitri's grow | ||
| 716 | runs before the tenant's commands, and why every scalar the tenant set still | ||
| 717 | wins. Underpins the whole seed contract for BYO cloud-init. | ||
| 718 | **Traced** 2026-09-02 in cloud-init 26.1's own source (`handlers/cloud_config.py` | ||
| 719 | and `mergers/__init__.py`) on the Ubuntu resolute image, where the merger string | ||
| 720 | itself was also run against the guest's copy. Held to the boot gate from here: | ||
| 721 | the smoke's VM is created with a tenant document carrying both keys, and the | ||
| 722 | gate leg reads back a CA-signed login, a grown root, and the tenant's own | ||
| 723 | markers, so a run that goes green has proven this and a run that does not fails. | ||
| 724 | Falsified by a guest whose BYO `write_files` leaves `/etc/ssh/eitri_user_ca.pub` | ||
| 725 | absent — it boots, runs sshd, and refuses only CA-signed certificates, which is | ||
| 726 | why the boot gate has to ask rather than assume. | ||
| 727 | |||
| 728 | The same claim was first made for vendor-data and was **falsified by the boot | ||
| 729 | gate on 2026-09-02**: `mergers.dict_extract_mergers` pops `merge_how` out of a | ||
| 730 | document the first time the part handler reads it and the stripped result is | ||
| 731 | what gets written, so the later user-data-versus-vendor-data merge never sees | ||
| 732 | one and its first-wins default stands. Vendor-data was dropped from the seed | ||
| 733 | entirely rather than left as a second mechanism. | ||
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,7 @@ self-hosted eitri is a self-hosted MCP server, with nothing extra to install. | |||
| 15 | 15 | ||
| 16 | | Tool | Purpose | | 16 | | Tool | Purpose | |
| 17 | |---|---| | 17 | |---|---| |
| 18 | | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. Takes an optional `network`, the named host network to give the guest a second NIC on; a host serves only the names it advertises, and naming another refuses the create. Returns `network` and `network_ip`—the address that network's own DHCP granted, empty at ready when it has not answered yet. Refused while the tenant has no registered SSH user CA—call `ca_upload` first, or the guest would trust nothing. | | 18 | | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. Takes an optional `network`, the named host network to give the guest a second NIC on; a host serves only the names it advertises, and naming another refuses the create. Returns `network` and `network_ip`—the address that network's own DHCP granted, empty at ready when it has not answered yet. Refused while the tenant has no registered SSH user CA—call `ca_upload` first, or the guest would trust nothing. An optional `cloud_init` is the guest's own user-data, taken verbatim: eitri's own `write_files` and `runcmd` (the CA trust file, the sshd drop-in, the clock drop-in, the root grow) are **prepended** to yours rather than replaced by them, and every scalar you set—`growpart` included—still wins. | |
| 19 | | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size, `network` and `network_ip`). | | 19 | | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size, `network` and `network_ip`). | |
| 20 | | `vm_info` | Show one VM's state, both addresses, plus a ready-to-use `ssh` command. Where to read `network_ip` back when it was still empty at create. | | 20 | | `vm_info` | Show one VM's state, both addresses, plus a ready-to-use `ssh` command. Where to read `network_ip` back when it was still empty at create. | |
| 21 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | | 21 | | `vm_exec` | Run a shell command in a VM over SSH; returns stdout, stderr, exit code. | |
| @@ -101,7 +101,8 @@ three-step recipe rather than a generic authentication failure. | |||
| 101 | 101 | ||
| 102 | VMs are addressed by their namespaced connect name, not IP, so recycled IPs and | 102 | VMs are addressed by their namespaced connect name, not IP, so recycled IPs and |
| 103 | host-key churn are not a concern. The guest trusts the tenant's registered user | 103 | host-key churn are not a concern. The guest trusts the tenant's registered user |
| 104 | CAs via `TrustedUserCAKeys` (provisioned through vendor-data), so no per-VM | 104 | CAs via `TrustedUserCAKeys` (provisioned by eitri's own cloud-config, merged |
| 105 | with yours), so no per-VM | ||
| 105 | `authorized_key` injection is needed. The PAT authenticates API calls only; SSH | 106 | `authorized_key` injection is needed. The PAT authenticates API calls only; SSH |
| 106 | traffic uses the certificate, and the token itself is never surfaced in a tool | 107 | traffic uses the certificate, and the token itself is never surfaced in a tool |
| 107 | result or error. | 108 | result or error. |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -269,6 +269,7 @@ | |||
| 269 | "plane": "data", | 269 | "plane": "data", |
| 270 | "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", | 270 | "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", |
| 271 | "imports": [ | 271 | "imports": [ |
| 272 | "internal/cloudinit", | ||
| 272 | "internal/guest" | 273 | "internal/guest" |
| 273 | ] | 274 | ] |
| 274 | }, | 275 | }, |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -218,6 +218,7 @@ | |||
| 218 | "plane": "data", | 218 | "plane": "data", |
| 219 | "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", | 219 | "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", |
| 220 | "imports": [ | 220 | "imports": [ |
| 221 | "internal/cloudinit", | ||
| 221 | "internal/guest" | 222 | "internal/guest" |
| 222 | ] | 223 | ] |
| 223 | }, | 224 | }, |
internal/agent/seed/seed.go
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | "os" | 6 | "os" |
| 7 | "strings" | 7 | "strings" |
| 8 | 8 | ||
| 9 | "github.com/a73x/eitri/internal/cloudinit" | ||
| 9 | "github.com/a73x/eitri/internal/guest" | 10 | "github.com/a73x/eitri/internal/guest" |
| 10 | diskfs "github.com/diskfs/go-diskfs" | 11 | diskfs "github.com/diskfs/go-diskfs" |
| 11 | "github.com/diskfs/go-diskfs/disk" | 12 | "github.com/diskfs/go-diskfs/disk" |
| @@ -112,12 +113,14 @@ func sshdDropIn(p Params) string { | |||
| 112 | // growRuncmd grows the root partition to fill disk_gb and resizes the | 113 | // growRuncmd grows the root partition to fill disk_gb and resizes the |
| 113 | // filesystem, online, on the mounted root — eitri's side of the contract | 114 | // filesystem, online, on the mounted root — eitri's side of the contract |
| 114 | // (eitri delivers disk_gb to the guest; the guest owns everything above it). | 115 | // (eitri delivers disk_gb to the guest; the guest owns everything above it). |
| 115 | // It lives in vendor-data, not the default user-data, so it applies even when a | 116 | // It lives in eitri's own cloud-config part, not the default user-data, so it |
| 116 | // tenant brings their own cloud-init. | 117 | // applies even when a tenant brings their own cloud-init — including one with a |
| 118 | // `runcmd` of its own, which without the merge directive (see mergeHow) would | ||
| 119 | // replace this. | ||
| 117 | // | 120 | // |
| 118 | // The image ships a GPT sized for its built-in ~3.5G disk; the agent extends the | 121 | // The image ships a GPT sized for its built-in ~3.5G disk; the agent extends the |
| 119 | // raw file to disk_gb, which strands the GPT's backup header mid-device. The | 122 | // raw file to disk_gb, which strands the GPT's backup header mid-device. The |
| 120 | // image's own cloud-init growpart is therefore turned OFF (see vendorDataDoc): | 123 | // image's own cloud-init growpart is therefore turned OFF (see eitriConfigDoc): |
| 121 | // its sfdisk resizer treats sfdisk's non-zero exit on the "GPT backup not at the | 124 | // its sfdisk resizer treats sfdisk's non-zero exit on the "GPT backup not at the |
| 122 | // end / PMBR size mismatch" auto-correction as a failure and REVERTS the grow, | 125 | // end / PMBR size mismatch" auto-correction as a failure and REVERTS the grow, |
| 123 | // so the root never grows and cloud-init reports degraded. This runcmd does the | 126 | // so the root never grows and cloud-init reports degraded. This runcmd does the |
| @@ -173,20 +176,56 @@ makestep 1 -1 | |||
| 173 | // failing the runcmd and reporting cloud-init degraded. | 176 | // failing the runcmd and reporting cloud-init degraded. |
| 174 | const chronyRuncmd = ` - ["sh", "-c", "systemctl try-restart chrony.service 2>/dev/null || :"]` + "\n" | 177 | const chronyRuncmd = ` - ["sh", "-c", "systemctl try-restart chrony.service 2>/dev/null || :"]` + "\n" |
| 175 | 178 | ||
| 176 | // vendorDataDoc renders the vendor-data #cloud-config for a seed. It always | 179 | // mergeHow is the merge directive eitri's cloud-config carries, and the only |
| 177 | // carries the disk-grow (growpart disabled + the online grow runcmd, see | 180 | // thing that makes the rest of it survive a tenant's own cloud-init. |
| 178 | // growRuncmd) and the clock drop-in (see chronyDropIn); it additionally carries | 181 | // |
| 179 | // the eitri user-CA trust drop-in when the CA key is set and the per-VM host key | 182 | // cloud-init's cloud-config part handler folds the parts of one data source |
| 180 | // + cert when those are set. It is never empty, so a vendor-data file is always | 183 | // into a single buffer, one at a time, merging each part into what the earlier |
| 181 | // written. | 184 | // parts built. The mergers it uses are the ones named by the part being merged |
| 185 | // IN — a `merge_how` key in that part's document, or a `Merge-Type` MIME header | ||
| 186 | // — and a part that names neither falls back to `dict(replace)+list()+str()`: | ||
| 187 | // dict keys REPLACED, lists NOT appended. That fallback is the defect. A tenant | ||
| 188 | // document carrying `write_files:` or `runcmd:` and eitri's document carrying | ||
| 189 | // its own would leave the guest with whichever was folded in last and nothing | ||
| 190 | // of the other — no /etc/ssh/eitri_user_ca.pub and no CA-signed certificate | ||
| 191 | // accepted, or the image's ~3.5G root still sitting on a 10G disk. | ||
| 192 | // | ||
| 193 | // So eitri's document names its own mergers and is the LAST part appended (see | ||
| 194 | // composeUserData): `list(prepend)` puts eitri's list items in front of the | ||
| 195 | // tenant's instead of discarding them; `dict(recurse_array,no_replace)` merges | ||
| 196 | // mappings key by key, recursing into a list a key holds rather than treating | ||
| 197 | // it as one opaque value, and never overwriting a key the tenant set; `str()` | ||
| 198 | // leaves a scalar the tenant set alone. eitri's entries are ADDED to the | ||
| 199 | // tenant's, and every scalar the tenant chose still wins — `growpart: mode` | ||
| 200 | // included, which is what the userData comment below promises them. | ||
| 201 | // | ||
| 202 | // prepend rather than append because the order is load-bearing: the grow | ||
| 203 | // runcmd has to run before whatever the tenant put in runcmd, so their commands | ||
| 204 | // see the disk they asked for and not the image's. | ||
| 205 | // | ||
| 206 | // This is the ONE layer that reads merge_how. The handler pops the key out of | ||
| 207 | // each part as it folds it in and writes the stripped result, so the merge that | ||
| 208 | // happens afterwards — user-data against vendor-data — never sees a merge_how | ||
| 209 | // from either. That is why eitri's config is a part of user-data and there is | ||
| 210 | // no vendor-data in the seed at all. | ||
| 211 | const mergeHow = `merge_how: "list(prepend)+dict(recurse_array,no_replace)+str()"` + "\n" | ||
| 212 | |||
| 213 | // eitriConfigDoc renders eitri's own #cloud-config for a seed — the part | ||
| 214 | // appended after the tenant's document (see composeUserData). It always carries | ||
| 215 | // the disk-grow (growpart disabled + the online grow runcmd, see growRuncmd) and | ||
| 216 | // the clock drop-in (see chronyDropIn); it additionally carries the eitri | ||
| 217 | // user-CA trust drop-in when the CA key is set and the per-VM host key + cert | ||
| 218 | // when those are set. Its first line is the merge directive (see mergeHow) that | ||
| 219 | // keeps all of it from being discarded by a tenant's own write_files or runcmd. | ||
| 182 | // | 220 | // |
| 183 | // The keys, cert, and drop-in conf are embedded as YAML block scalars (`|`), so | 221 | // The keys, cert, and drop-in conf are embedded as YAML block scalars (`|`), so |
| 184 | // their bytes appear verbatim in the guest files. Block scalars are | 222 | // their bytes appear verbatim in the guest files. Block scalars are |
| 185 | // injection-safe: every indented line is literal content. The sshd reload is | 223 | // injection-safe: every indented line is literal content. The sshd reload is |
| 186 | // gated behind `sshd -t` so a malformed config can never lock anyone out. | 224 | // gated behind `sshd -t` so a malformed config can never lock anyone out. |
| 187 | func vendorDataDoc(p Params) string { | 225 | func eitriConfigDoc(p Params) string { |
| 188 | var b strings.Builder | 226 | var b strings.Builder |
| 189 | b.WriteString("#cloud-config\n") | 227 | b.WriteString("#cloud-config\n") |
| 228 | b.WriteString(mergeHow) | ||
| 190 | // Disable the image's growpart: its sfdisk resizer reverts on this disk (see | 229 | // Disable the image's growpart: its sfdisk resizer reverts on this disk (see |
| 191 | // growRuncmd). Quoted so YAML reads the string "off", not the boolean false. | 230 | // growRuncmd). Quoted so YAML reads the string "off", not the boolean false. |
| 192 | b.WriteString("growpart:\n mode: \"off\"\n") | 231 | b.WriteString("growpart:\n mode: \"off\"\n") |
| @@ -253,10 +292,11 @@ func writeFileBlock(b *strings.Builder, path, content string) { | |||
| 253 | // default is generated with an SSH key and a default ubuntu user. | 292 | // default is generated with an SSH key and a default ubuntu user. |
| 254 | // | 293 | // |
| 255 | // The default does NOT configure growpart or disk_setup: growing the root to | 294 | // The default does NOT configure growpart or disk_setup: growing the root to |
| 256 | // disk_gb is eitri's job and lives in vendor-data (see vendorDataDoc/growRuncmd), | 295 | // disk_gb is eitri's job and lives in eitri's own cloud-config part (see |
| 257 | // which applies to BYO user-data too. Setting growpart here would also override | 296 | // eitriConfigDoc/growRuncmd), which applies to BYO user-data too. Setting |
| 258 | // vendor-data's `growpart: off` (user-data wins the cloud-init merge), re-arming | 297 | // growpart here would also win over that part's `growpart: off` — a scalar the |
| 259 | // the image growpart that reverts on this disk. | 298 | // tenant's document set is never overwritten (see mergeHow) — re-arming the |
| 299 | // image growpart that reverts on this disk. | ||
| 260 | func userData(p Params) string { | 300 | func userData(p Params) string { |
| 261 | if p.UserData != "" { | 301 | if p.UserData != "" { |
| 262 | return p.UserData | 302 | return p.UserData |
| @@ -278,6 +318,30 @@ users: | |||
| 278 | `, p.Hostname, guest.LoginUser, sshKeys) | 318 | `, p.Hostname, guest.LoginUser, sshKeys) |
| 279 | } | 319 | } |
| 280 | 320 | ||
| 321 | // composeUserData builds the guest's whole user-data: the tenant's document | ||
| 322 | // (or eitri's default when they brought none) first, then eitri's own | ||
| 323 | // cloud-config appended as the last part of a MIME multipart archive. Both | ||
| 324 | // halves reach the guest, and the tenant's bytes are never edited. | ||
| 325 | // | ||
| 326 | // One mechanism, not two. eitri's config used to ride in a separate vendor-data | ||
| 327 | // file, which cloud-init merges against user-data with mergers that no document | ||
| 328 | // can influence — first-wins per key, so a tenant list quietly replaced eitri's | ||
| 329 | // (see mergeHow). Inside ONE data source the parts are folded together under the | ||
| 330 | // mergers the incoming part names, so this is the only arrangement in which | ||
| 331 | // eitri's merge directive is read at all. | ||
| 332 | // | ||
| 333 | // It can fail: a jinja-templated or gzipped tenant document cannot be given an | ||
| 334 | // honest MIME part header, so there is nowhere to put eitri's config. Refusing | ||
| 335 | // to build the seed is the point — the guest that would boot instead is one | ||
| 336 | // with no CA trust file, reachable by nobody and silent about why. | ||
| 337 | func composeUserData(p Params) (string, error) { | ||
| 338 | out, err := cloudinit.AppendCloudConfig(userData(p), eitriConfigDoc(p)) | ||
| 339 | if err != nil { | ||
| 340 | return "", fmt.Errorf("seed: %w", err) | ||
| 341 | } | ||
| 342 | return out, nil | ||
| 343 | } | ||
| 344 | |||
| 281 | // metaData returns the cloud-init meta-data content. | 345 | // metaData returns the cloud-init meta-data content. |
| 282 | // InstanceID is used when set; falls back to Hostname for backward compatibility. | 346 | // InstanceID is used when set; falls back to Hostname for backward compatibility. |
| 283 | func metaData(p Params) string { | 347 | func metaData(p Params) string { |
| @@ -347,11 +411,19 @@ func networkConfig(p Params) string { | |||
| 347 | 411 | ||
| 348 | // Build creates a cloud-init NoCloud seed ISO at outPath. | 412 | // Build creates a cloud-init NoCloud seed ISO at outPath. |
| 349 | // The ISO uses volume label "cidata" as required by cloud-init's NoCloud source. | 413 | // The ISO uses volume label "cidata" as required by cloud-init's NoCloud source. |
| 350 | // Files written: /user-data, /meta-data, /network-config. | 414 | // Files written: /user-data, /meta-data, /network-config. eitri's own config is |
| 415 | // the last part of /user-data rather than a /vendor-data file (see | ||
| 416 | // composeUserData), so a seed carries three files and no fourth. | ||
| 351 | func Build(outPath string, p Params) error { | 417 | func Build(outPath string, p Params) error { |
| 352 | if err := validateParams(p); err != nil { | 418 | if err := validateParams(p); err != nil { |
| 353 | return err | 419 | return err |
| 354 | } | 420 | } |
| 421 | // Composed before anything is created, so a tenant document eitri cannot | ||
| 422 | // compose with fails without leaving an artifact behind. | ||
| 423 | userDataDoc, err := composeUserData(p) | ||
| 424 | if err != nil { | ||
| 425 | return err | ||
| 426 | } | ||
| 355 | 427 | ||
| 356 | // 1 MiB covers the text files plus slack for ISO9660 structures. | 428 | // 1 MiB covers the text files plus slack for ISO9660 structures. |
| 357 | isoSize := int64(1 * 1024 * 1024) | 429 | isoSize := int64(1 * 1024 * 1024) |
| @@ -411,16 +483,14 @@ func Build(outPath string, p Params) error { | |||
| 411 | return fmt.Errorf("seed: unexpected filesystem type %T", fsi) | 483 | return fmt.Errorf("seed: unexpected filesystem type %T", fsi) |
| 412 | } | 484 | } |
| 413 | 485 | ||
| 486 | // No vendor-data: eitri's own config rides as the last part of user-data, | ||
| 487 | // which is the only place cloud-init reads a merge directive from (see | ||
| 488 | // composeUserData). | ||
| 414 | files := map[string]string{ | 489 | files := map[string]string{ |
| 415 | "/user-data": userData(p), | 490 | "/user-data": userDataDoc, |
| 416 | "/meta-data": metaData(p), | 491 | "/meta-data": metaData(p), |
| 417 | "/network-config": networkConfig(p), | 492 | "/network-config": networkConfig(p), |
| 418 | } | 493 | } |
| 419 | // Vendor-data carries eitri's own config (disk-grow, and the SSH user-CA | ||
| 420 | // drop-in + host cert when set); it is always non-empty, so always written. | ||
| 421 | if vd := vendorDataDoc(p); vd != "" { | ||
| 422 | files["/vendor-data"] = vd | ||
| 423 | } | ||
| 424 | for name, content := range files { | 494 | for name, content := range files { |
| 425 | f, err := fs.OpenFile(name, os.O_CREATE|os.O_RDWR) | 495 | f, err := fs.OpenFile(name, os.O_CREATE|os.O_RDWR) |
| 426 | if err != nil { | 496 | if err != nil { |
internal/agent/seed/seed_test.go
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,9 @@ package seed | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "io" | 4 | "io" |
| 5 | "mime" | ||
| 6 | "mime/multipart" | ||
| 7 | "net/mail" | ||
| 5 | "os" | 8 | "os" |
| 6 | "strings" | 9 | "strings" |
| 7 | "testing" | 10 | "testing" |
| @@ -52,10 +55,11 @@ func TestUserDataDefaultInjectsKeyNotGrowpart(t *testing.T) { | |||
| 52 | assert.True(t, strings.HasPrefix(ud, "#cloud-config\n")) | 55 | assert.True(t, strings.HasPrefix(ud, "#cloud-config\n")) |
| 53 | assert.Contains(t, ud, "ssh-ed25519 KEY") | 56 | assert.Contains(t, ud, "ssh-ed25519 KEY") |
| 54 | assert.Contains(t, ud, "name: "+guest.LoginUser) | 57 | assert.Contains(t, ud, "name: "+guest.LoginUser) |
| 55 | // Growing the root to disk_gb is eitri's job and lives in vendor-data, not | 58 | // Growing the root to disk_gb is eitri's job and lives in eitri's own |
| 56 | // here: a growpart in user-data would override vendor-data's `growpart: off` | 59 | // cloud-config part, not here: a growpart in the tenant's document wins over |
| 57 | // and re-arm the image growpart that reverts on this disk. | 60 | // that part's `growpart: off` and re-arms the image growpart, which reverts |
| 58 | assert.NotContains(t, ud, "growpart", "growpart belongs in vendor-data, not user-data") | 61 | // on this disk. |
| 62 | assert.NotContains(t, ud, "growpart", "growpart belongs in eitri's own cloud-config part, not this document") | ||
| 59 | assert.NotContains(t, ud, "disk_setup", "the no-op disk_setup block is gone") | 63 | assert.NotContains(t, ud, "disk_setup", "the no-op disk_setup block is gone") |
| 60 | } | 64 | } |
| 61 | 65 | ||
| @@ -220,9 +224,38 @@ func readISOFile(t *testing.T, isoPath, name string) []byte { | |||
| 220 | return b | 224 | return b |
| 221 | } | 225 | } |
| 222 | 226 | ||
| 223 | func TestBuildAlwaysCarriesVendorDataForDiskGrow(t *testing.T) { | 227 | // seedParts reads a finished seed's /user-data and returns the two documents it |
| 224 | // Even with no CA/host key, the seed carries vendor-data — it holds the | 228 | // composes: the tenant's part first, eitri's own last. Splitting here keeps |
| 225 | // disk-grow that delivers disk_gb to the guest. | 229 | // every assertion below about content rather than about MIME framing, and the |
| 230 | // length check is itself the contract — a seed is those two parts and no others. | ||
| 231 | func seedParts(t *testing.T, isoPath string) (tenant, eitri string) { | ||
| 232 | t.Helper() | ||
| 233 | raw := string(readISOFile(t, isoPath, "/user-data")) | ||
| 234 | msg, err := mail.ReadMessage(strings.NewReader(raw)) | ||
| 235 | require.NoError(t, err, "user-data must be a MIME archive") | ||
| 236 | mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) | ||
| 237 | require.NoError(t, err) | ||
| 238 | require.Equal(t, "multipart/mixed", mediaType) | ||
| 239 | mr := multipart.NewReader(msg.Body, params["boundary"]) | ||
| 240 | var bodies []string | ||
| 241 | for { | ||
| 242 | part, err := mr.NextPart() | ||
| 243 | if err != nil { | ||
| 244 | break | ||
| 245 | } | ||
| 246 | b, err := io.ReadAll(part) | ||
| 247 | require.NoError(t, err) | ||
| 248 | bodies = append(bodies, string(b)) | ||
| 249 | } | ||
| 250 | require.Len(t, bodies, 2, "a seed composes the tenant's document and eitri's, in that order") | ||
| 251 | return bodies[0], bodies[1] | ||
| 252 | } | ||
| 253 | |||
| 254 | func TestEverySeedCarriesTheDiskGrowAndWritesNoVendorData(t *testing.T) { | ||
| 255 | // Even with no CA/host key, eitri's part holds the disk-grow that delivers | ||
| 256 | // disk_gb to the guest — and it is a part of user-data, not a vendor-data | ||
| 257 | // file. There is one mechanism, and a stray vendor-data would be a second | ||
| 258 | // one whose mergers no document can influence. | ||
| 226 | out := t.TempDir() + "/seed.iso" | 259 | out := t.TempDir() + "/seed.iso" |
| 227 | require.NoError(t, Build(out, Params{ | 260 | require.NoError(t, Build(out, Params{ |
| 228 | Hostname: "plain", | 261 | Hostname: "plain", |
| @@ -239,11 +272,12 @@ func TestBuildAlwaysCarriesVendorDataForDiskGrow(t *testing.T) { | |||
| 239 | for _, e := range entries { | 272 | for _, e := range entries { |
| 240 | names[strings.ToLower(e.Name())] = true | 273 | names[strings.ToLower(e.Name())] = true |
| 241 | } | 274 | } |
| 242 | assert.True(t, names["vendor-data"], "every seed carries vendor-data for the disk-grow") | 275 | assert.False(t, names["vendor-data"], "eitri's config rides in user-data; a vendor-data file is the mechanism that lost it") |
| 243 | vd := string(readISOFile(t, out, "/vendor-data")) | 276 | |
| 244 | assert.Contains(t, vd, `mode: "off"`, "image growpart disabled") | 277 | _, eitri := seedParts(t, out) |
| 245 | assert.Contains(t, vd, "resize2fs /dev/vda1", "online grow runcmd present") | 278 | assert.Contains(t, eitri, `mode: "off"`, "image growpart disabled") |
| 246 | assert.NotContains(t, vd, "TrustedUserCAKeys", "no CA ⇒ no CA trust line") | 279 | assert.Contains(t, eitri, "resize2fs /dev/vda1", "online grow runcmd present") |
| 280 | assert.NotContains(t, eitri, "TrustedUserCAKeys", "no CA ⇒ no CA trust line") | ||
| 247 | st, err := os.Stat(out) | 281 | st, err := os.Stat(out) |
| 248 | require.NoError(t, err) | 282 | require.NoError(t, err) |
| 249 | assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)") | 283 | assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)") |
| @@ -253,8 +287,8 @@ func TestBuildAlwaysCarriesVendorDataForDiskGrow(t *testing.T) { | |||
| 253 | 287 | ||
| 254 | const testUserCAKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTCAKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eitri-user-ca\n" | 288 | const testUserCAKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTCAKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eitri-user-ca\n" |
| 255 | 289 | ||
| 256 | func TestVendorDataInjectsUserCATrust(t *testing.T) { | 290 | func TestEitriPartInjectsUserCATrust(t *testing.T) { |
| 257 | // A VM with a CA key gets a vendor-data carrying the sshd drop-in. | 291 | // A VM with a CA key gets an eitri part carrying the sshd drop-in. |
| 258 | out := t.TempDir() + "/seed.iso" | 292 | out := t.TempDir() + "/seed.iso" |
| 259 | require.NoError(t, Build(out, Params{ | 293 | require.NoError(t, Build(out, Params{ |
| 260 | Hostname: "plain", | 294 | Hostname: "plain", |
| @@ -262,7 +296,7 @@ func TestVendorDataInjectsUserCATrust(t *testing.T) { | |||
| 262 | SSHUserCAAuthorizedKey: testUserCAKey, | 296 | SSHUserCAAuthorizedKey: testUserCAKey, |
| 263 | })) | 297 | })) |
| 264 | 298 | ||
| 265 | vd := string(readISOFile(t, out, "/vendor-data")) | 299 | tenant, vd := seedParts(t, out) |
| 266 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) | 300 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) |
| 267 | assert.Contains(t, vd, "/etc/ssh/eitri_user_ca.pub") | 301 | assert.Contains(t, vd, "/etc/ssh/eitri_user_ca.pub") |
| 268 | assert.Contains(t, vd, "/etc/ssh/sshd_config.d/eitri-ca.conf") | 302 | assert.Contains(t, vd, "/etc/ssh/sshd_config.d/eitri-ca.conf") |
| @@ -272,10 +306,9 @@ func TestVendorDataInjectsUserCATrust(t *testing.T) { | |||
| 272 | // A bad config must NOT lock anyone out: -t gates the reload. | 306 | // A bad config must NOT lock anyone out: -t gates the reload. |
| 273 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | 307 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") |
| 274 | 308 | ||
| 275 | // user-data is never touched by CA injection. | 309 | // The tenant's part is never touched by CA injection. |
| 276 | ud := string(readISOFile(t, out, "/user-data")) | 310 | assert.Contains(t, tenant, "hostname: plain") |
| 277 | assert.Contains(t, ud, "hostname: plain") | 311 | assert.NotContains(t, tenant, "eitri-ca.conf") |
| 278 | assert.NotContains(t, ud, "eitri-ca.conf") | ||
| 279 | } | 312 | } |
| 280 | 313 | ||
| 281 | const ( | 314 | const ( |
| @@ -283,7 +316,7 @@ const ( | |||
| 283 | testHostCert = "ssh-ed25519-cert-v01@openssh.com AAAATESTHOSTCERTdata with-hostcert\n" | 316 | testHostCert = "ssh-ed25519-cert-v01@openssh.com AAAATESTHOSTCERTdata with-hostcert\n" |
| 284 | ) | 317 | ) |
| 285 | 318 | ||
| 286 | func TestVendorDataInjectsHostKeyAndCert(t *testing.T) { | 319 | func TestEitriPartInjectsHostKeyAndCert(t *testing.T) { |
| 287 | // A VM with a host key + cert gets them installed and sshd pointed at them. | 320 | // A VM with a host key + cert gets them installed and sshd pointed at them. |
| 288 | out := t.TempDir() + "/seed.iso" | 321 | out := t.TempDir() + "/seed.iso" |
| 289 | require.NoError(t, Build(out, Params{ | 322 | require.NoError(t, Build(out, Params{ |
| @@ -294,7 +327,7 @@ func TestVendorDataInjectsHostKeyAndCert(t *testing.T) { | |||
| 294 | SSHHostCert: testHostCert, | 327 | SSHHostCert: testHostCert, |
| 295 | })) | 328 | })) |
| 296 | 329 | ||
| 297 | vd := string(readISOFile(t, out, "/vendor-data")) | 330 | _, vd := seedParts(t, out) |
| 298 | // The host key + cert are handed to cloud-init via its native ssh_keys map, | 331 | // The host key + cert are handed to cloud-init via its native ssh_keys map, |
| 299 | // and sshd's HostCertificate directive is asserted via the drop-in. | 332 | // and sshd's HostCertificate directive is asserted via the drop-in. |
| 300 | assert.Contains(t, vd, "ssh_keys:") | 333 | assert.Contains(t, vd, "ssh_keys:") |
| @@ -310,12 +343,12 @@ func TestVendorDataInjectsHostKeyAndCert(t *testing.T) { | |||
| 310 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | 343 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") |
| 311 | } | 344 | } |
| 312 | 345 | ||
| 313 | func TestVendorDataInjectsHostKeyViaSSHKeys(t *testing.T) { | 346 | func TestEitriPartInjectsHostKeyViaSSHKeys(t *testing.T) { |
| 314 | // The per-VM ed25519 host key + cert are handed to cloud-init via its native | 347 | // The per-VM ed25519 host key + cert are handed to cloud-init via its native |
| 315 | // ssh_keys map — NOT write_files — and we do NOT set ssh_deletekeys: false. | 348 | // ssh_keys map — NOT write_files — and we do NOT set ssh_deletekeys: false. |
| 316 | // So cloud-init (default ssh_deletekeys: true) deletes the image's baked host | 349 | // So cloud-init (default ssh_deletekeys: true) deletes the image's baked host |
| 317 | // keys, installs OUR ed25519 key+cert, and regenerates rsa/ecdsa fresh per VM. | 350 | // keys, installs OUR ed25519 key+cert, and regenerates rsa/ecdsa fresh per VM. |
| 318 | vd := vendorDataDoc(Params{ | 351 | vd := eitriConfigDoc(Params{ |
| 319 | SSHHostKeyPEM: testHostKeyPEM, | 352 | SSHHostKeyPEM: testHostKeyPEM, |
| 320 | SSHHostCert: testHostCert, | 353 | SSHHostCert: testHostCert, |
| 321 | }) | 354 | }) |
| @@ -332,19 +365,19 @@ func TestVendorDataInjectsHostKeyViaSSHKeys(t *testing.T) { | |||
| 332 | "host key must not be injected via write_files") | 365 | "host key must not be injected via write_files") |
| 333 | } | 366 | } |
| 334 | 367 | ||
| 335 | func TestVendorDataOmitsSSHKeysWithoutHostCert(t *testing.T) { | 368 | func TestEitriPartOmitsSSHKeysWithoutHostCert(t *testing.T) { |
| 336 | // No host-cert injection ⇒ no ssh_keys map (and never the ssh_deletekeys hack). | 369 | // No host-cert injection ⇒ no ssh_keys map (and never the ssh_deletekeys hack). |
| 337 | vd := vendorDataDoc(Params{SSHUserCAAuthorizedKey: testUserCAKey}) | 370 | vd := eitriConfigDoc(Params{SSHUserCAAuthorizedKey: testUserCAKey}) |
| 338 | assert.NotContains(t, vd, "ssh_keys:", | 371 | assert.NotContains(t, vd, "ssh_keys:", |
| 339 | "ssh_keys must only appear when injecting a host key") | 372 | "ssh_keys must only appear when injecting a host key") |
| 340 | assert.NotContains(t, vd, "ssh_deletekeys") | 373 | assert.NotContains(t, vd, "ssh_deletekeys") |
| 341 | } | 374 | } |
| 342 | 375 | ||
| 343 | func TestVendorDataHostCertWithoutCAKey(t *testing.T) { | 376 | func TestEitriPartHostCertWithoutCAKey(t *testing.T) { |
| 344 | // Host cert injection is independent of user-CA trust: a seed with only a | 377 | // Host cert injection is independent of user-CA trust: a seed with only a |
| 345 | // host key still produces vendor-data with the host directives (and no | 378 | // host key still produces an eitri part with the host directives (and no |
| 346 | // TrustedUserCAKeys line). | 379 | // TrustedUserCAKeys line). |
| 347 | vd := vendorDataDoc(Params{ | 380 | vd := eitriConfigDoc(Params{ |
| 348 | SSHHostKeyPEM: testHostKeyPEM, | 381 | SSHHostKeyPEM: testHostKeyPEM, |
| 349 | SSHHostCert: testHostCert, | 382 | SSHHostCert: testHostCert, |
| 350 | }) | 383 | }) |
| @@ -353,10 +386,10 @@ func TestVendorDataHostCertWithoutCAKey(t *testing.T) { | |||
| 353 | assert.NotContains(t, vd, "TrustedUserCAKeys") | 386 | assert.NotContains(t, vd, "TrustedUserCAKeys") |
| 354 | } | 387 | } |
| 355 | 388 | ||
| 356 | func TestVendorDataDiskGrowAlwaysPresent(t *testing.T) { | 389 | func TestEitriPartDiskGrowAlwaysPresent(t *testing.T) { |
| 357 | // Even with neither CA key nor host key, vendor-data carries the disk-grow: | 390 | // Even with neither CA key nor host key, eitri's part carries the disk-grow: |
| 358 | // growpart disabled + the proven online grow runcmd. No SSH material leaks in. | 391 | // growpart disabled + the proven online grow runcmd. No SSH material leaks in. |
| 359 | vd := vendorDataDoc(Params{}) | 392 | vd := eitriConfigDoc(Params{}) |
| 360 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) | 393 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) |
| 361 | assert.Contains(t, vd, "growpart:\n mode: \"off\"\n", | 394 | assert.Contains(t, vd, "growpart:\n mode: \"off\"\n", |
| 362 | "the image growpart is disabled (it reverts on this disk)") | 395 | "the image growpart is disabled (it reverts on this disk)") |
| @@ -375,10 +408,10 @@ func TestVendorDataDiskGrowAlwaysPresent(t *testing.T) { | |||
| 375 | assert.NotContains(t, vd, "reload sshd") | 408 | assert.NotContains(t, vd, "reload sshd") |
| 376 | } | 409 | } |
| 377 | 410 | ||
| 378 | func TestVendorDataClockAlwaysPresent(t *testing.T) { | 411 | func TestEitriPartClockAlwaysPresent(t *testing.T) { |
| 379 | // The clock drop-in is fleet infrastructure like the disk-grow, not SSH | 412 | // The clock drop-in is fleet infrastructure like the disk-grow, not SSH |
| 380 | // material: it lands with no CA key, no host key, and no user-data. | 413 | // material: it lands with no CA key, no host key, and no user-data. |
| 381 | vd := vendorDataDoc(Params{}) | 414 | vd := eitriConfigDoc(Params{}) |
| 382 | assert.Contains(t, vd, " - path: /etc/chrony/conf.d/eitri-clock.conf\n", | 415 | assert.Contains(t, vd, " - path: /etc/chrony/conf.d/eitri-clock.conf\n", |
| 383 | "the drop-in path is the guest's, not eitri's: chronyd reads /etc/chrony/conf.d, and a path asserted against the "+ | 416 | "the drop-in path is the guest's, not eitri's: chronyd reads /etc/chrony/conf.d, and a path asserted against the "+ |
| 384 | "constant that wrote it lands anywhere at all while the guest's clock quietly keeps drifting") | 417 | "constant that wrote it lands anywhere at all while the guest's clock quietly keeps drifting") |
| @@ -387,18 +420,18 @@ func TestVendorDataClockAlwaysPresent(t *testing.T) { | |||
| 387 | assert.Contains(t, vd, chronyRuncmd, "the drop-in reaches the running chronyd, not just the next boot") | 420 | assert.Contains(t, vd, chronyRuncmd, "the drop-in reaches the running chronyd, not just the next boot") |
| 388 | } | 421 | } |
| 389 | 422 | ||
| 390 | func TestVendorDataClockSurvivesBYOUserData(t *testing.T) { | 423 | func TestEitriPartClockSurvivesBYOUserData(t *testing.T) { |
| 391 | // vendor-data is the reason the clock config is not optional: a tenant who | 424 | // eitri's own part is the reason the clock config is not optional: a tenant |
| 392 | // brings their own user-data still gets it, exactly as they get the grow. | 425 | // who brings their own user-data still gets it, exactly as they get the grow. |
| 393 | vd := vendorDataDoc(Params{UserData: "#cloud-config\npackages: [nginx]\n"}) | 426 | vd := eitriConfigDoc(Params{UserData: "#cloud-config\npackages: [nginx]\n"}) |
| 394 | assert.Contains(t, vd, chronyDropInPath) | 427 | assert.Contains(t, vd, chronyDropInPath) |
| 395 | assert.Contains(t, vd, " makestep 1 -1\n") | 428 | assert.Contains(t, vd, " makestep 1 -1\n") |
| 396 | } | 429 | } |
| 397 | 430 | ||
| 398 | func TestVendorDataDiskGrowCoexistsWithSSHMaterial(t *testing.T) { | 431 | func TestEitriPartDiskGrowCoexistsWithSSHMaterial(t *testing.T) { |
| 399 | // The disk-grow sits alongside the CA trust + host cert, and its runcmd | 432 | // The disk-grow sits alongside the CA trust + host cert, and its runcmd |
| 400 | // entry is a sibling of the sshd-reload — both run. | 433 | // entry is a sibling of the sshd-reload — both run. |
| 401 | vd := vendorDataDoc(Params{ | 434 | vd := eitriConfigDoc(Params{ |
| 402 | SSHUserCAAuthorizedKey: testUserCAKey, | 435 | SSHUserCAAuthorizedKey: testUserCAKey, |
| 403 | SSHHostKeyPEM: testHostKeyPEM, | 436 | SSHHostKeyPEM: testHostKeyPEM, |
| 404 | SSHHostCert: testHostCert, | 437 | SSHHostCert: testHostCert, |
| @@ -410,21 +443,57 @@ func TestVendorDataDiskGrowCoexistsWithSSHMaterial(t *testing.T) { | |||
| 410 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | 443 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") |
| 411 | } | 444 | } |
| 412 | 445 | ||
| 413 | func TestUserSuppliedCloudInitKeepsVerbatimWhileVendorGrows(t *testing.T) { | 446 | func TestBYOCloudInitCannotDropTheCATrustOrTheDiskGrow(t *testing.T) { |
| 414 | // A BYO user-data is passed through byte-for-byte (eitri never edits it), yet | 447 | // A BYO user-data rides byte-for-byte as its own part (eitri never edits it), |
| 415 | // the seed's vendor-data still delivers the disk-grow — the contract survives | 448 | // yet the seed's CA trust and disk-grow still reach the guest — even when the |
| 416 | // custom cloud-init. | 449 | // tenant's document carries the two keys that used to replace them wholesale. |
| 450 | // Without a merge directive cloud-init folds a later part in with | ||
| 451 | // dict(replace)+list(), so eitri's `write_files` would take the tenant's with | ||
| 452 | // it or the tenant's `runcmd` would take the grow. | ||
| 417 | out := t.TempDir() + "/seed.iso" | 453 | out := t.TempDir() + "/seed.iso" |
| 418 | custom := "#cloud-config\npackages: [htop]\n" | 454 | custom := "#cloud-config\npackages: [htop]\n" + |
| 455 | "write_files:\n - path: /etc/tenant\n content: mine\n" + | ||
| 456 | "runcmd:\n - [\"sh\", \"-c\", \"echo tenant\"]\n" | ||
| 419 | require.NoError(t, Build(out, Params{ | 457 | require.NoError(t, Build(out, Params{ |
| 420 | Hostname: "byo", | 458 | Hostname: "byo", |
| 421 | UserData: custom, | 459 | UserData: custom, |
| 422 | SSHUserCAAuthorizedKey: testUserCAKey, | 460 | SSHUserCAAuthorizedKey: testUserCAKey, |
| 423 | })) | 461 | })) |
| 424 | ud := string(readISOFile(t, out, "/user-data")) | 462 | tenant, eitri := seedParts(t, out) |
| 425 | assert.Equal(t, custom, ud, "user-data is passed through verbatim") | 463 | assert.Equal(t, custom, tenant, "the tenant's document is passed through verbatim") |
| 426 | assert.NotContains(t, ud, "resize2fs", "eitri does not inject the grow into user-data") | 464 | assert.NotContains(t, tenant, "resize2fs", "eitri does not inject the grow into the tenant's document") |
| 427 | vd := string(readISOFile(t, out, "/vendor-data")) | 465 | assert.NotContains(t, tenant, "merge_how", "the merge directive rides in eitri's part, never in the tenant's") |
| 428 | assert.Contains(t, vd, `mode: "off"`) | 466 | |
| 429 | assert.Contains(t, vd, "resize2fs /dev/vda1") | 467 | assert.Contains(t, eitri, `merge_how: "list(prepend)+dict(recurse_array,no_replace)+str()"`, |
| 468 | "without this, the handler's dict(replace)+list() fallback drops whichever list was folded in first") | ||
| 469 | assert.Contains(t, eitri, userCAPath, "the CA trust file must survive a tenant write_files") | ||
| 470 | assert.Contains(t, eitri, "resize2fs /dev/vda1", "the online grow must survive a tenant runcmd") | ||
| 471 | assert.Contains(t, eitri, `mode: "off"`) | ||
| 472 | } | ||
| 473 | |||
| 474 | func TestEitrisPartIsMergedInLastOrItsDirectiveGovernsNothing(t *testing.T) { | ||
| 475 | // Part order is the whole mechanism: cloud-init folds each part into what the | ||
| 476 | // earlier parts built, under the mergers the incoming part names. eitri's | ||
| 477 | // document carries merge_how, so it has to be the one arriving last. | ||
| 478 | out := t.TempDir() + "/seed.iso" | ||
| 479 | custom := "#cloud-config\npackages: [htop]\n" | ||
| 480 | require.NoError(t, Build(out, Params{Hostname: "byo", UserData: custom})) | ||
| 481 | |||
| 482 | tenant, eitri := seedParts(t, out) | ||
| 483 | assert.Equal(t, custom, tenant, "the tenant's document is the part folded in first") | ||
| 484 | assert.Contains(t, eitri, "merge_how:", "the part folded in last is the one that names the mergers") | ||
| 485 | } | ||
| 486 | |||
| 487 | func TestBuildRefusesUserDataItCannotComposeWith(t *testing.T) { | ||
| 488 | // A jinja template renders to a type nobody knows yet and a gzip blob is | ||
| 489 | // opaque, so neither can carry an honest MIME part header and eitri's config | ||
| 490 | // has nowhere to go. Refusing beats booting a guest whose CA trust file never | ||
| 491 | // lands: that guest comes up, runs sshd, and is reachable by nobody. | ||
| 492 | for _, ud := range []string{ | ||
| 493 | "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n", | ||
| 494 | "\x1f\x8b\x08 gzipped", | ||
| 495 | } { | ||
| 496 | err := Build(t.TempDir()+"/seed.iso", Params{Hostname: "h", UserData: ud}) | ||
| 497 | assert.Error(t, err, "Build must refuse user-data it cannot compose eitri's config with") | ||
| 498 | } | ||
| 430 | } | 499 | } |
internal/cloudinit/append_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,108 @@ | |||
| 1 | package cloudinit | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "mime" | ||
| 6 | "mime/multipart" | ||
| 7 | "net/mail" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // eitriDoc stands in for the cloud-config the agent's seed appends. Its | ||
| 16 | // merge_how line is what the ordering below exists to serve. | ||
| 17 | const eitriDoc = "#cloud-config\nmerge_how: \"list(prepend)+dict(recurse_array,no_replace)+str()\"\nruncmd:\n - [\"sh\", \"-c\", \"resize2fs /dev/vda1\"]\n" | ||
| 18 | |||
| 19 | // orderedParts parses an archive into (contentType, body) pairs IN ORDER. | ||
| 20 | // mimeParts keys by content type, which cannot see the difference between two | ||
| 21 | // cloud-config parts — and their order is the whole contract here. | ||
| 22 | func orderedParts(t *testing.T, s string) []struct{ CT, Body string } { | ||
| 23 | t.Helper() | ||
| 24 | msg, err := mail.ReadMessage(strings.NewReader(s)) | ||
| 25 | require.NoError(t, err) | ||
| 26 | mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) | ||
| 27 | require.NoError(t, err) | ||
| 28 | require.Equal(t, "multipart/mixed", mediaType) | ||
| 29 | mr := multipart.NewReader(msg.Body, params["boundary"]) | ||
| 30 | var out []struct{ CT, Body string } | ||
| 31 | for { | ||
| 32 | p, err := mr.NextPart() | ||
| 33 | if err != nil { | ||
| 34 | break | ||
| 35 | } | ||
| 36 | body, err := io.ReadAll(p) | ||
| 37 | require.NoError(t, err) | ||
| 38 | mt, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) | ||
| 39 | out = append(out, struct{ CT, Body string }{mt, string(body)}) | ||
| 40 | } | ||
| 41 | return out | ||
| 42 | } | ||
| 43 | |||
| 44 | func TestAppendCloudConfigPutsTheTenantsDocumentFirstAndEitrisLast(t *testing.T) { | ||
| 45 | // Order is the contract: cloud-init's cloud-config handler merges each part | ||
| 46 | // into the buffer the earlier parts built, using THAT part's mergers. eitri's | ||
| 47 | // part must therefore be merged in last, or its merge_how governs nothing. | ||
| 48 | tenant := "#cloud-config\npackages: [htop]\nruncmd:\n - [\"sh\", \"-c\", \"echo tenant\"]\n" | ||
| 49 | out, err := AppendCloudConfig(tenant, eitriDoc) | ||
| 50 | require.NoError(t, err) | ||
| 51 | |||
| 52 | parts := orderedParts(t, out) | ||
| 53 | require.Len(t, parts, 2) | ||
| 54 | assert.Equal(t, "text/cloud-config", parts[0].CT) | ||
| 55 | assert.Equal(t, tenant, parts[0].Body, "the tenant's document rides byte-for-byte") | ||
| 56 | assert.Equal(t, "text/cloud-config", parts[1].CT) | ||
| 57 | assert.Equal(t, eitriDoc, parts[1].Body, "eitri's document is the last part merged") | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestAppendCloudConfigLeavesANonCloudConfigPayloadIntact(t *testing.T) { | ||
| 61 | // A tenant script is not something eitri can merge into, so it rides as its | ||
| 62 | // own part under the type cloud-init dispatches it with, untouched. | ||
| 63 | script := "#!/bin/bash\necho hello > /tmp/marker\n" | ||
| 64 | out, err := AppendCloudConfig(script, eitriDoc) | ||
| 65 | require.NoError(t, err) | ||
| 66 | |||
| 67 | parts := orderedParts(t, out) | ||
| 68 | require.Len(t, parts, 2) | ||
| 69 | assert.Equal(t, "text/x-shellscript", parts[0].CT) | ||
| 70 | assert.Equal(t, script, parts[0].Body) | ||
| 71 | assert.Equal(t, "text/cloud-config", parts[1].CT) | ||
| 72 | assert.Equal(t, eitriDoc, parts[1].Body) | ||
| 73 | } | ||
| 74 | |||
| 75 | func TestAppendCloudConfigAppendsToAnExistingArchiveRatherThanNesting(t *testing.T) { | ||
| 76 | existing, err := wrapMultipart([]part{ | ||
| 77 | typedPart("text/cloud-config", "#cloud-config\npackages:\n - git\n"), | ||
| 78 | typedPart("text/x-shellscript", "#!/bin/bash\necho hi\n"), | ||
| 79 | }) | ||
| 80 | require.NoError(t, err) | ||
| 81 | |||
| 82 | out, err := AppendCloudConfig(existing, eitriDoc) | ||
| 83 | require.NoError(t, err) | ||
| 84 | |||
| 85 | parts := orderedParts(t, out) | ||
| 86 | require.Len(t, parts, 3) | ||
| 87 | for _, p := range parts { | ||
| 88 | assert.NotContains(t, p.CT, "multipart", "must append, not nest") | ||
| 89 | } | ||
| 90 | assert.Equal(t, "#cloud-config\npackages:\n - git\n", parts[0].Body, "the tenant's own parts keep their order") | ||
| 91 | assert.Equal(t, "#!/bin/bash\necho hi\n", parts[1].Body) | ||
| 92 | assert.Equal(t, eitriDoc, parts[2].Body, "eitri's part is last, after every part the tenant brought") | ||
| 93 | } | ||
| 94 | |||
| 95 | func TestAppendCloudConfigRefusesAPayloadItCannotGiveAPartHeader(t *testing.T) { | ||
| 96 | // A jinja template's real type is unknown until it renders and a gzip blob is | ||
| 97 | // opaque, so neither can be labelled honestly. Refusing is the only safe | ||
| 98 | // answer: composing them wrong would hand the guest a seed whose CA trust | ||
| 99 | // never lands, which is the failure this whole mechanism exists to prevent. | ||
| 100 | for _, in := range []string{ | ||
| 101 | "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n", | ||
| 102 | "\x1f\x8b\x08 gzipped", | ||
| 103 | "random text that is not user-data", | ||
| 104 | } { | ||
| 105 | _, err := AppendCloudConfig(in, eitriDoc) | ||
| 106 | assert.Error(t, err, "must refuse %q rather than drop eitri's config", in[:min(20, len(in))]) | ||
| 107 | } | ||
| 108 | } | ||
internal/cloudinit/multipart.go
| Old | New | ||
|---|---|---|---|
| @@ -122,22 +122,87 @@ 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 | f := detectFormat(userData) |
| 126 | case formatCloudConfig: | 126 | if f == formatCloudConfig { |
| 127 | // The common case is a single clean document, so it stays one: merging | ||
| 128 | // the key in beats handing cloud-init an archive to reassemble. | ||
| 127 | return mergeSSHKey(userData, key) | 129 | return mergeSSHKey(userData, key) |
| 130 | } | ||
| 131 | out, err := appendPart(userData, f, keyPart(key)) | ||
| 132 | if err != nil { | ||
| 133 | return "", fmt.Errorf("cannot add an ssh key to %s user-data; include the key in the user-data itself", f) | ||
| 134 | } | ||
| 135 | return out, nil | ||
| 136 | } | ||
| 137 | |||
| 138 | // AppendCloudConfig returns userData with doc — a #cloud-config document eitri | ||
| 139 | // generated — appended as the LAST part of a MIME multipart archive, leaving | ||
| 140 | // whatever the tenant supplied byte-for-byte intact as the earlier part(s). | ||
| 141 | // | ||
| 142 | // Last is the contract, not a detail. cloud-init's cloud-config part handler | ||
| 143 | // merges each part into the buffer the earlier parts built, using the mergers | ||
| 144 | // named by THAT part (its own `merge_how`, or a `Merge-Type` MIME header), and | ||
| 145 | // falling back to `dict(replace)+list()+str()` for a part that names none. So a | ||
| 146 | // document that carries a merge_how governs only what is merged into it — it | ||
| 147 | // has to arrive after the document it means to merge with. This is the layer | ||
| 148 | // where merge_how is read at all: the cloud-config handler POPS the key out of | ||
| 149 | // each part as it processes it and writes the stripped result, so the later | ||
| 150 | // user-data-versus-vendor-data merge never sees one (see mergeHow in | ||
| 151 | // internal/agent/seed). | ||
| 152 | // | ||
| 153 | // A tenant document is never edited to make room. A plain cloud-config becomes | ||
| 154 | // its own part; a script, boothook, include or part-handler payload rides under | ||
| 155 | // the type cloud-init dispatches it with; an archive the tenant already built | ||
| 156 | // gets the part appended to it rather than nested inside a new one, keeping | ||
| 157 | // every original part's body AND headers. | ||
| 158 | // | ||
| 159 | // Jinja, gzip and unrecognised payloads are refused. Their real content-type is | ||
| 160 | // not knowable without rendering or decompressing them, and a part labelled | ||
| 161 | // wrongly is a part cloud-init runs wrongly — where the alternative is handing | ||
| 162 | // a guest a seed whose CA trust silently never lands, an error at build time is | ||
| 163 | // the only honest answer. | ||
| 164 | func AppendCloudConfig(userData, doc string) (string, error) { | ||
| 165 | f := detectFormat(userData) | ||
| 166 | out, err := appendPart(userData, f, typedPart("text/cloud-config", doc)) | ||
| 167 | if err != nil { | ||
| 168 | return "", fmt.Errorf("cannot compose eitri's cloud-config with %s user-data: %w", f, err) | ||
| 169 | } | ||
| 170 | return out, nil | ||
| 171 | } | ||
| 172 | |||
| 173 | // appendPart returns userData with last appended as the final part of a | ||
| 174 | // multipart archive: an archive gains a part, anything else is wrapped with | ||
| 175 | // itself first. f is userData's already-detected format, so callers that | ||
| 176 | // special-case one of them (AddSSHKey) classify only once. | ||
| 177 | func appendPart(userData string, f format, last part) (string, error) { | ||
| 178 | if f == formatMultipart { | ||
| 179 | return appendToMultipart(userData, last) | ||
| 180 | } | ||
| 181 | ct, ok := partType(f) | ||
| 182 | if !ok { | ||
| 183 | return "", fmt.Errorf("%s payloads cannot be given a MIME part header", f) | ||
| 184 | } | ||
| 185 | return wrapMultipart([]part{typedPart(ct, userData), last}) | ||
| 186 | } | ||
| 187 | |||
| 188 | // partType maps a detected format to the content-type cloud-init dispatches | ||
| 189 | // that payload with. The bool reports whether the payload can ride in an | ||
| 190 | // archive at all: jinja renders to a type nobody knows yet, gzip is opaque | ||
| 191 | // bytes, and an unrecognised payload names no handler to run it. | ||
| 192 | func partType(f format) (string, bool) { | ||
| 193 | switch f { | ||
| 194 | case formatCloudConfig: | ||
| 195 | return "text/cloud-config", true | ||
| 128 | case formatShellScript: | 196 | case formatShellScript: |
| 129 | return wrapMultipart([]part{typedPart("text/x-shellscript", userData), keyPart(key)}) | 197 | return "text/x-shellscript", true |
| 130 | case formatBoothook: | 198 | case formatBoothook: |
| 131 | return wrapMultipart([]part{typedPart("text/cloud-boothook", userData), keyPart(key)}) | 199 | return "text/cloud-boothook", true |
| 132 | case formatInclude: | 200 | case formatInclude: |
| 133 | return wrapMultipart([]part{typedPart("text/x-include-url", userData), keyPart(key)}) | 201 | return "text/x-include-url", true |
| 134 | case formatPartHandler: | 202 | case formatPartHandler: |
| 135 | return wrapMultipart([]part{typedPart("text/part-handler", userData), keyPart(key)}) | 203 | return "text/part-handler", true |
| 136 | case formatMultipart: | ||
| 137 | return appendToMultipart(userData, key) | ||
| 138 | default: | ||
| 139 | return "", fmt.Errorf("cannot add an ssh key to %s user-data; include the key in the user-data itself", f) | ||
| 140 | } | 204 | } |
| 205 | return "", false | ||
| 141 | } | 206 | } |
| 142 | 207 | ||
| 143 | // part is one MIME sub-document: its headers and its (already-decoded) body. | 208 | // part is one MIME sub-document: its headers and its (already-decoded) body. |
| @@ -199,9 +264,9 @@ func wrapMultipart(parts []part) (string, error) { | |||
| 199 | // appendToMultipart parses an existing multipart archive, keeps every original | 264 | // appendToMultipart parses an existing multipart archive, keeps every original |
| 200 | // part's body AND headers (cloud-init uses part headers like | 265 | // part's body AND headers (cloud-init uses part headers like |
| 201 | // Content-Disposition/filename to name and order scripts, and Merge-Type to | 266 | // Content-Disposition/filename to name and order scripts, and Merge-Type to |
| 202 | // control cloud-config merging), and re-emits with the key part appended — | 267 | // control cloud-config merging), and re-emits with last appended — |
| 203 | // deliberately NOT nesting the user's archive inside a new one. | 268 | // deliberately NOT nesting the user's archive inside a new one. |
| 204 | func appendToMultipart(userData, key string) (string, error) { | 269 | func appendToMultipart(userData string, last part) (string, error) { |
| 205 | msg, err := mail.ReadMessage(strings.NewReader(userData)) | 270 | msg, err := mail.ReadMessage(strings.NewReader(userData)) |
| 206 | if err != nil { | 271 | if err != nil { |
| 207 | return "", fmt.Errorf("parse multipart headers: %w", err) | 272 | return "", fmt.Errorf("parse multipart headers: %w", err) |
| @@ -246,7 +311,7 @@ func appendToMultipart(userData, key string) (string, error) { | |||
| 246 | if len(parts) == 0 { | 311 | if len(parts) == 0 { |
| 247 | return "", errors.New("multipart archive has no parts") | 312 | return "", errors.New("multipart archive has no parts") |
| 248 | } | 313 | } |
| 249 | return wrapMultipart(append(parts, keyPart(key))) | 314 | return wrapMultipart(append(parts, last)) |
| 250 | } | 315 | } |
| 251 | 316 | ||
| 252 | // firstNonBlankLine returns the first line with non-whitespace content, trimmed. | 317 | // firstNonBlankLine returns the first line with non-whitespace content, trimmed. |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -173,7 +173,7 @@ type VMCreateIn struct { | |||
| 173 | VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"` | 173 | VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"` |
| 174 | MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"` | 174 | MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"` |
| 175 | DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 10"` | 175 | DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 10"` |
| 176 | CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init"` | 176 | CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init, taken verbatim; eitri's own write_files and runcmd are prepended to yours, not replaced by them, and every scalar you set wins"` |
| 177 | Network string `json:"network,omitempty" jsonschema:"named host network to give the guest a SECOND NIC on, addressed by that network's own DHCP; omit for the private NAT underlay every guest has anyway. A host serves only the names it advertises: naming another refuses the create and names the ones it does serve"` | 177 | Network string `json:"network,omitempty" jsonschema:"named host network to give the guest a SECOND NIC on, addressed by that network's own DHCP; omit for the private NAT underlay every guest has anyway. A host serves only the names it advertises: naming another refuses the create and names the ones it does serve"` |
| 178 | Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"` | 178 | Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"` |
| 179 | } | 179 | } |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -42,6 +42,13 @@ type ( | |||
| 42 | Delegation = types.Delegation | 42 | Delegation = types.Delegation |
| 43 | ) | 43 | ) |
| 44 | 44 | ||
| 45 | // DefaultDiskGB is the wire contract's default disk size, re-exported for the | ||
| 46 | // reason the aliases above exist: the client is the only door to the contract | ||
| 47 | // package (R11), so a consumer that needs one of the API's defaults reaches it | ||
| 48 | // here rather than keeping a copy of the number. Its siblings, DefaultVCPUs and | ||
| 49 | // DefaultMemMB, join it when something asks for them. | ||
| 50 | const DefaultDiskGB = types.DefaultDiskGB | ||
| 51 | |||
| 45 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a | 52 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a |
| 46 | // Bearer header when non-empty). The zero value plus a BaseURL is a working | 53 | // Bearer header when non-empty). The zero value plus a BaseURL is a working |
| 47 | // client; a nil HTTP falls back to a 30s-timeout http.Client. UserCALabel, if | 54 | // client; a nil HTTP falls back to a 30s-timeout http.Client. UserCALabel, if |
internal/smoke/cloudinit.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,110 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strconv" | ||
| 6 | "strings" | ||
| 7 | |||
| 8 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Guest paths the tenant document below creates, one per cloud-init key. They | ||
| 12 | // are read back by seedProbeCmd, and they live under /etc rather than /run | ||
| 13 | // because the gate proof runs again after the power cycle and cloud-init's | ||
| 14 | // write_files and runcmd only ever run once per instance. | ||
| 15 | const ( | ||
| 16 | byoWriteFilesMarker = "/etc/eitri-smoke-byo-write-files" | ||
| 17 | byoRuncmdMarker = "/etc/eitri-smoke-byo-runcmd" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // byoCloudInit is the tenant cloud-init the scenario's VM is created with. It | ||
| 21 | // exists to make the smoke's one VM a guest of the shape that used to lose | ||
| 22 | // eitri's own seed config: cloud-init folds documents together with mergers | ||
| 23 | // that discard one side's lists, so a tenant document carrying `write_files:` | ||
| 24 | // or `runcmd:` cost the guest its CA trust file or its online root grow. The | ||
| 25 | // seed answers that with a merge directive on its own cloud-config part | ||
| 26 | // (internal/agent/seed, mergeHow), and this document is what makes the boot | ||
| 27 | // gate exercise it rather than take it on faith. | ||
| 28 | // | ||
| 29 | // It carries both keys and nothing else, so the guest still gets its default | ||
| 30 | // user from the image (and the create request's SSH key, which the control | ||
| 31 | // plane merges into whatever document it is given). Each key drops one marker | ||
| 32 | // file, so the proof can see the tenant's own entries ran too — a merge that | ||
| 33 | // silently kept only eitri's side would be the same bug pointing the other way. | ||
| 34 | const byoCloudInit = `#cloud-config | ||
| 35 | write_files: | ||
| 36 | - path: ` + byoWriteFilesMarker + ` | ||
| 37 | permissions: '0644' | ||
| 38 | content: | | ||
| 39 | the tenant's own write_files entry ran | ||
| 40 | runcmd: | ||
| 41 | - ["sh", "-c", "echo 'the tenant runcmd entry ran' > ` + byoRuncmdMarker + `"] | ||
| 42 | ` | ||
| 43 | |||
| 44 | // seedProbeCmd reads the three facts the merge proof needs out of a booted | ||
| 45 | // guest, one labelled line each, so proveSeedSurvivedBYO can stay pure. The | ||
| 46 | // redirections keep a missing file from failing the whole command: an absent | ||
| 47 | // marker must arrive as an empty value to be reported, not as a shell error | ||
| 48 | // that reads like an unreachable guest. | ||
| 49 | const seedProbeCmd = `printf 'root_bytes=%s\n' "$(findmnt -no SIZE -b / 2>/dev/null)"; ` + | ||
| 50 | `printf 'byo_write_files=%s\n' "$(cat ` + byoWriteFilesMarker + ` 2>/dev/null)"; ` + | ||
| 51 | `printf 'byo_runcmd=%s\n' "$(cat ` + byoRuncmdMarker + ` 2>/dev/null)"` | ||
| 52 | |||
| 53 | // minGrownRootBytes is the floor a grown root must clear: half the disk the | ||
| 54 | // scenario's VM is created with, which is the control plane's default because | ||
| 55 | // the create request names no size. The default image ships a root of about | ||
| 56 | // 3.5G, so half of a default disk sits above what the image brings and below | ||
| 57 | // what the grow produces — a root over the floor can only be one the seed's | ||
| 58 | // grow runcmd resized, and a root under it can only be one that never ran. | ||
| 59 | // Nothing lands between the two, which is why one number is enough. | ||
| 60 | // | ||
| 61 | // It is derived rather than written down so that moving the default disk moves | ||
| 62 | // the floor with it. A literal here would be this package's private copy of a | ||
| 63 | // number it does not own, and the first shrink of the default would turn this | ||
| 64 | // leg's pass into a coincidence. | ||
| 65 | const minGrownRootBytes int64 = client.DefaultDiskGB * (1 << 30) / 2 | ||
| 66 | |||
| 67 | // proveSeedSurvivedBYO reads seedProbeCmd's output and reports whether both | ||
| 68 | // sides of the cloud-init merge reached the guest: eitri's grow runcmd (the | ||
| 69 | // root is bigger than the image's) and the tenant's own write_files and runcmd | ||
| 70 | // (their marker files exist). Unreadable output is a failure — a probe that | ||
| 71 | // cannot be parsed proves nothing, and must never read as a pass. | ||
| 72 | func proveSeedSurvivedBYO(out string) error { | ||
| 73 | fields := parseProbe(out) | ||
| 74 | |||
| 75 | raw, ok := fields["root_bytes"] | ||
| 76 | if !ok || raw == "" { | ||
| 77 | return fmt.Errorf("FAIL: the guest reported no root filesystem size, so the seed's disk grow cannot be judged (probe output: %q)", out) | ||
| 78 | } | ||
| 79 | size, err := strconv.ParseInt(raw, 10, 64) | ||
| 80 | if err != nil { | ||
| 81 | return fmt.Errorf("FAIL: the guest reported root filesystem size %q, which is not a number of bytes", raw) | ||
| 82 | } | ||
| 83 | if size < minGrownRootBytes { | ||
| 84 | return fmt.Errorf("FAIL: the guest's root filesystem is %d bytes, under the %d it must exceed once grown — "+ | ||
| 85 | "the seed's grow runcmd did not run, which is what a tenant runcmd displacing eitri's looks like", size, minGrownRootBytes) | ||
| 86 | } | ||
| 87 | |||
| 88 | if fields["byo_write_files"] == "" { | ||
| 89 | return fmt.Errorf("FAIL: %s is missing, so the tenant's own write_files never ran — the merge kept eitri's list and dropped theirs", byoWriteFilesMarker) | ||
| 90 | } | ||
| 91 | if fields["byo_runcmd"] == "" { | ||
| 92 | return fmt.Errorf("FAIL: %s is missing, so the tenant's own runcmd never ran — the merge kept eitri's list and dropped theirs", byoRuncmdMarker) | ||
| 93 | } | ||
| 94 | return nil | ||
| 95 | } | ||
| 96 | |||
| 97 | // parseProbe splits seedProbeCmd's `key=value` lines into a map. Lines without | ||
| 98 | // a `=` are ignored: a guest is free to print a login banner or a warning ahead | ||
| 99 | // of the command's own output, and none of it is an answer either way. | ||
| 100 | func parseProbe(out string) map[string]string { | ||
| 101 | fields := make(map[string]string) | ||
| 102 | for line := range strings.SplitSeq(out, "\n") { | ||
| 103 | key, value, ok := strings.Cut(strings.TrimSpace(line), "=") | ||
| 104 | if !ok { | ||
| 105 | continue | ||
| 106 | } | ||
| 107 | fields[key] = strings.TrimSpace(value) | ||
| 108 | } | ||
| 109 | return fields | ||
| 110 | } | ||
internal/smoke/cloudinit_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,137 @@ | |||
| 1 | package smoke | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strconv" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/a73x/eitri/internal/cloudinit" | ||
| 9 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // grownRootBytes is what a guest reports once the seed's runcmd has grown its | ||
| 13 | // root to the disk the scenario asked for: the whole default disk, less the | ||
| 14 | // filesystem's own overhead, which is nowhere near half of it. | ||
| 15 | const grownRootBytes = client.DefaultDiskGB * (1 << 30) | ||
| 16 | |||
| 17 | // imageRootBytes is the root the default Ubuntu image ships — about 3.5G, the | ||
| 18 | // size an ungrown guest reports. Written as a literal because it is a fact | ||
| 19 | // about somebody else's image, not a number eitri gets to choose. | ||
| 20 | const imageRootBytes int64 = 3650722816 | ||
| 21 | |||
| 22 | func TestGrownRootFloorSitsBetweenTheImageAndTheDefaultDisk(t *testing.T) { | ||
| 23 | // The floor is derived from the default disk, so it moves when that moves. | ||
| 24 | // What must stay true either way is that it separates the two roots a guest | ||
| 25 | // can actually have: a floor under the image's root passes an ungrown guest, | ||
| 26 | // and one above the disk fails every guest. | ||
| 27 | if minGrownRootBytes <= imageRootBytes { | ||
| 28 | t.Errorf("floor %d does not clear the image's own root %d — an ungrown guest would pass", minGrownRootBytes, imageRootBytes) | ||
| 29 | } | ||
| 30 | if minGrownRootBytes >= grownRootBytes { | ||
| 31 | t.Errorf("floor %d is at or above the whole default disk %d — no guest could clear it", minGrownRootBytes, grownRootBytes) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | // probeOutput renders what seedProbeCmd prints in a guest, so each test states | ||
| 36 | // the three facts it is about and nothing else. | ||
| 37 | func probeOutput(rootBytes, writeFiles, runcmd string) string { | ||
| 38 | return "root_bytes=" + rootBytes + "\n" + | ||
| 39 | "byo_write_files=" + writeFiles + "\n" + | ||
| 40 | "byo_runcmd=" + runcmd + "\n" | ||
| 41 | } | ||
| 42 | |||
| 43 | func TestSeedProbeAcceptsAGuestWhereBothSidesOfTheMergeLanded(t *testing.T) { | ||
| 44 | out := probeOutput(strconv.FormatInt(grownRootBytes, 10), "tenant write_files ran", "tenant runcmd ran") | ||
| 45 | if err := proveSeedSurvivedBYO(out); err != nil { | ||
| 46 | t.Fatalf("proveSeedSurvivedBYO: %v", err) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | func TestSeedProbeRejectsARootTheGrowNeverReached(t *testing.T) { | ||
| 51 | // The image's own ~3.5G root on a 10G disk: the seed's runcmd was replaced | ||
| 52 | // by the tenant's, which is the half of the defect that costs disk. | ||
| 53 | out := probeOutput(strconv.FormatInt(imageRootBytes, 10), "tenant write_files ran", "tenant runcmd ran") | ||
| 54 | err := proveSeedSurvivedBYO(out) | ||
| 55 | if err == nil { | ||
| 56 | t.Fatal("proveSeedSurvivedBYO: want an error for an ungrown root, got nil") | ||
| 57 | } | ||
| 58 | if !strings.Contains(err.Error(), "runcmd") { | ||
| 59 | t.Errorf("error = %q, want it to name the runcmd that was dropped", err) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | func TestSeedProbeRejectsAGuestThatLostTheTenantsWriteFiles(t *testing.T) { | ||
| 64 | // The mirror image: eitri's lists won and the tenant's were discarded. A | ||
| 65 | // merge that only ever keeps eitri's side is not a merge. | ||
| 66 | out := probeOutput(strconv.FormatInt(grownRootBytes, 10), "", "tenant runcmd ran") | ||
| 67 | err := proveSeedSurvivedBYO(out) | ||
| 68 | if err == nil { | ||
| 69 | t.Fatal("proveSeedSurvivedBYO: want an error when the tenant's write_files is gone, got nil") | ||
| 70 | } | ||
| 71 | if !strings.Contains(err.Error(), "write_files") { | ||
| 72 | t.Errorf("error = %q, want it to name the tenant's write_files", err) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | func TestSeedProbeRejectsAGuestThatLostTheTenantsRuncmd(t *testing.T) { | ||
| 77 | out := probeOutput(strconv.FormatInt(grownRootBytes, 10), "tenant write_files ran", "") | ||
| 78 | err := proveSeedSurvivedBYO(out) | ||
| 79 | if err == nil { | ||
| 80 | t.Fatal("proveSeedSurvivedBYO: want an error when the tenant's runcmd is gone, got nil") | ||
| 81 | } | ||
| 82 | if !strings.Contains(err.Error(), "runcmd") { | ||
| 83 | t.Errorf("error = %q, want it to name the tenant's runcmd", err) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | func TestSeedProbeRejectsOutputItCannotRead(t *testing.T) { | ||
| 88 | // A probe whose root_bytes is missing or not a number proves nothing, and | ||
| 89 | // must never be mistaken for a pass. | ||
| 90 | for _, out := range []string{ | ||
| 91 | "", | ||
| 92 | probeOutput("", "w", "r"), | ||
| 93 | probeOutput("not-a-number", "w", "r"), | ||
| 94 | } { | ||
| 95 | if err := proveSeedSurvivedBYO(out); err == nil { | ||
| 96 | t.Errorf("proveSeedSurvivedBYO(%q): want an error, got nil", out) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | func TestBYOCloudInitCarriesTheTwoKeysThatUsedToClobberTheSeed(t *testing.T) { | ||
| 102 | // The document is the whole point of the leg: without both keys the guest | ||
| 103 | // never exercises the merge the seed's merge_how exists for. | ||
| 104 | if !strings.Contains(byoCloudInit, "\nwrite_files:") { | ||
| 105 | t.Error("byoCloudInit must carry a write_files key") | ||
| 106 | } | ||
| 107 | if !strings.Contains(byoCloudInit, "\nruncmd:") { | ||
| 108 | t.Error("byoCloudInit must carry a runcmd key") | ||
| 109 | } | ||
| 110 | if !strings.HasPrefix(byoCloudInit, "#cloud-config\n") { | ||
| 111 | t.Error("byoCloudInit must be a #cloud-config document") | ||
| 112 | } | ||
| 113 | for _, path := range []string{byoWriteFilesMarker, byoRuncmdMarker} { | ||
| 114 | if !strings.Contains(byoCloudInit, path) { | ||
| 115 | t.Errorf("byoCloudInit does not write %s, so the probe reads a file nothing creates", path) | ||
| 116 | } | ||
| 117 | if !strings.Contains(seedProbeCmd, path) { | ||
| 118 | t.Errorf("seedProbeCmd does not read %s", path) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | func TestBYOCloudInitSurvivesTheControlPlanesOwnSSHKeyMerge(t *testing.T) { | ||
| 124 | // The create request carries an SSH key alongside the document, and the | ||
| 125 | // control plane merges the one into the other before the seed ever sees it. | ||
| 126 | // A document that merge refuses is a 400 at create, so the leg would fail | ||
| 127 | // before it proved anything about cloud-init. | ||
| 128 | merged, err := cloudinit.AddSSHKey(byoCloudInit, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISMOKE smoke@gate") | ||
| 129 | if err != nil { | ||
| 130 | t.Fatalf("AddSSHKey(byoCloudInit): %v", err) | ||
| 131 | } | ||
| 132 | for _, want := range []string{"write_files", "runcmd", byoWriteFilesMarker, byoRuncmdMarker, "ssh_authorized_keys"} { | ||
| 133 | if !strings.Contains(merged, want) { | ||
| 134 | t.Errorf("merged document lost %q:\n%s", want, merged) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | } | ||
internal/smoke/gatecheck.go
| Old | New | ||
|---|---|---|---|
| @@ -23,10 +23,22 @@ func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userC | |||
| 23 | } | 23 | } |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | // gateExec proves the guest is reachable through the SSH-CA gate: it retries | 26 | // gateExec proves the guest is reachable through the SSH-CA gate, and that the |
| 27 | // dial+login over the guest's pre-sshd boot window (a dial or session error | 27 | // seed the guest booted from survived the tenant cloud-init it was created with. |
| 28 | // mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser and | 28 | // It retries dial+login over the guest's pre-sshd boot window (a dial or session |
| 29 | // confirms `id -un` echoes that same user, or the 120s deadline expires. | 29 | // error mid-boot is expected, not fatal) until it logs in as cfg.SmokeVMUser, |
| 30 | // confirms `id -un` echoes that same user, and reads back a guest where both | ||
| 31 | // sides of the cloud-init merge landed — or the 120s deadline expires. | ||
| 32 | // | ||
| 33 | // The login itself is half the merge proof: a CA-signed certificate is accepted | ||
| 34 | // only because the seed's write_files put the trust file and the sshd drop-in on | ||
| 35 | // the guest, which is exactly what a tenant `write_files` used to replace. The | ||
| 36 | // probe supplies the other half, the root the seed's runcmd grew. | ||
| 37 | // | ||
| 38 | // The probe is inside the retry rather than after it because cloud-init writes | ||
| 39 | // guest files before it runs runcmd, so a login can land in the window between | ||
| 40 | // the two: a root that is not grown YET is an unfinished boot, not a verdict. | ||
| 41 | // Only the deadline decides, and lastErr carries the reason it ran out. | ||
| 30 | func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error { | 42 | func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmName string, now func() time.Time, sleep func(time.Duration)) error { |
| 31 | var lastErr error | 43 | var lastErr error |
| 32 | err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { | 44 | err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { |
| @@ -50,11 +62,21 @@ func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmNa | |||
| 50 | if got != cfg.SmokeVMUser { | 62 | if got != cfg.SmokeVMUser { |
| 51 | return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser) | 63 | return false, fmt.Errorf("gate SSH logged into %q as %q, want %q", vmName, got, cfg.SmokeVMUser) |
| 52 | } | 64 | } |
| 65 | |||
| 66 | probe, probeErr := runGuestCommand(client, seedProbeCmd) | ||
| 67 | if probeErr != nil { | ||
| 68 | lastErr = probeErr | ||
| 69 | return false, nil | ||
| 70 | } | ||
| 71 | if seedErr := proveSeedSurvivedBYO(probe); seedErr != nil { | ||
| 72 | lastErr = seedErr | ||
| 73 | return false, nil | ||
| 74 | } | ||
| 53 | return true, nil | 75 | return true, nil |
| 54 | }) | 76 | }) |
| 55 | if err != nil { | 77 | if err != nil { |
| 56 | if errors.Is(err, errPollTimeout) { | 78 | if errors.Is(err, errPollTimeout) { |
| 57 | return fmt.Errorf("FAIL: could not reach guest %q through the gate within 120s: %w", vmName, lastErr) | 79 | return fmt.Errorf("FAIL: guest %q did not pass the gate proof within 120s: %w", vmName, lastErr) |
| 58 | } | 80 | } |
| 59 | return err | 81 | return err |
| 60 | } | 82 | } |
internal/smoke/scenario.go
| Old | New | ||
|---|---|---|---|
| @@ -144,8 +144,9 @@ func pollLoop(ctx context.Context, now func() time.Time, sleep func(time.Duratio | |||
| 144 | // source), and dialBanner (the published-port transport). vmName is the | 144 | // source), and dialBanner (the published-port transport). vmName is the |
| 145 | // pre-generated name for the throwaway VM. gate, when non-nil, registers the | 145 | // pre-generated name for the throwaway VM. gate, when non-nil, registers the |
| 146 | // smoke's user CA with the tenant before create (the guest bakes its trusted | 146 | // smoke's user CA with the tenant before create (the guest bakes its trusted |
| 147 | // CAs at boot, so registration MUST happen first) and proves gate SSH access | 147 | // CAs at boot, so registration MUST happen first) and proves gate SSH access — |
| 148 | // after the boot-proof — a hard gate, so a failure there fails the scenario. | 148 | // plus the cloud-init merge this VM's tenant document exercises — after the |
| 149 | // boot-proof; a hard gate, so a failure there fails the scenario. | ||
| 149 | // mcp, when non-nil, drives a second VM's whole life through the remote MCP | 150 | // mcp, when non-nil, drives a second VM's whole life through the remote MCP |
| 150 | // endpoint — that leg owns its own VM, created after it registers its own CA | 151 | // endpoint — that leg owns its own VM, created after it registers its own CA |
| 151 | // with the tenant, so the guest trusts the certificates eitri presents. | 152 | // with the tenant, so the guest trusts the certificates eitri presents. |
| @@ -182,7 +183,12 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 182 | sshKey := readPubKey() | 183 | sshKey := readPubKey() |
| 183 | 184 | ||
| 184 | start := now() | 185 | start := now() |
| 185 | created, err := c.CreateVM(ctx, client.CreateVMRequest{HostID: hostID, Name: vmName, SSHAuthorizedKey: sshKey}) | 186 | // The VM carries a tenant cloud-init (see byoCloudInit) so this one guest is |
| 187 | // also the guest the merge proof needs: the gate leg below reads back both | ||
| 188 | // eitri's grown root and the tenant's own marker files. No extra boot buys | ||
| 189 | // that, and a VM created with an empty document would never exercise the | ||
| 190 | // merge at all. | ||
| 191 | created, err := c.CreateVM(ctx, client.CreateVMRequest{HostID: hostID, Name: vmName, SSHAuthorizedKey: sshKey, CloudInit: byoCloudInit}) | ||
| 186 | if err != nil { | 192 | if err != nil { |
| 187 | return "", fmt.Errorf("create vm: %w", err) | 193 | return "", fmt.Errorf("create vm: %w", err) |
| 188 | } | 194 | } |
| @@ -326,7 +332,10 @@ func runScenario(ctx context.Context, vmName string, c vmAPI, dialConsole consol | |||
| 326 | 332 | ||
| 327 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reboot: ok, reaped OK", int64(coldStart.Seconds())) | 333 | msg := fmt.Sprintf("SMOKE COMPLETE — booted under UEFI, cold_start=%ds, reboot: ok, reaped OK", int64(coldStart.Seconds())) |
| 328 | if gateOK { | 334 | if gateOK { |
| 329 | msg += ", gate SSH: ok" | 335 | // One clause per thing the gate leg proved: reaching the guest with a |
| 336 | // CA-signed certificate, and the tenant cloud-init the guest booted from | ||
| 337 | // leaving eitri's own write_files and runcmd intact (see gateExec). | ||
| 338 | msg += ", gate SSH: ok, BYO cloud-init merge: ok" | ||
| 330 | } | 339 | } |
| 331 | if exposureOK { | 340 | if exposureOK { |
| 332 | msg += ", exposed port: ok" | 341 | msg += ", exposed port: ok" |
internal/smoke/scenario_test.go
| Old | New | ||
|---|---|---|---|
| @@ -474,6 +474,12 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 474 | if !strings.Contains(msg, "gate SSH: ok") { | 474 | if !strings.Contains(msg, "gate SSH: ok") { |
| 475 | t.Errorf("message = %q, want it to mention gate SSH: ok", msg) | 475 | t.Errorf("message = %q, want it to mention gate SSH: ok", msg) |
| 476 | } | 476 | } |
| 477 | // The gate leg is also where the cloud-init merge is proven, and a run that | ||
| 478 | // proved it says so — a report that only says "gate SSH: ok" leaves a reader | ||
| 479 | // to guess whether the tenant document was exercised at all. | ||
| 480 | if !strings.Contains(msg, "BYO cloud-init merge: ok") { | ||
| 481 | t.Errorf("message = %q, want it to mention BYO cloud-init merge: ok", msg) | ||
| 482 | } | ||
| 477 | 483 | ||
| 478 | // exec appears twice: once after the first boot proof, once after the | 484 | // exec appears twice: once after the first boot proof, once after the |
| 479 | // power-cycle proof — SSH through the gate must survive a reboot too. | 485 | // power-cycle proof — SSH through the gate must survive a reboot too. |
| @@ -489,6 +495,27 @@ func TestRunScenarioGateRegistersBeforeCreateAndExecsAfterBoot(t *testing.T) { | |||
| 489 | } | 495 | } |
| 490 | } | 496 | } |
| 491 | 497 | ||
| 498 | func TestRunScenarioCreatesItsVMWithATenantCloudInit(t *testing.T) { | ||
| 499 | // The scenario's own VM carries the tenant document, so the gate leg proves | ||
| 500 | // the cloud-init merge on a guest that already exists rather than on a | ||
| 501 | // second boot added for the purpose. | ||
| 502 | var calls []string | ||
| 503 | api := happyPathAPI(t, &calls) | ||
| 504 | var got client.CreateVMRequest | ||
| 505 | api.createVMFunc = func(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error) { | ||
| 506 | got = req | ||
| 507 | return client.CreateVMResponse{ID: "vm-1"}, nil | ||
| 508 | } | ||
| 509 | |||
| 510 | clock := &fakeClock{t: time.Unix(0, 0)} | ||
| 511 | if _, err := runScenario(context.Background(), "smoke-test", api, powerCycleConsole(t, api, bootedGuest, "ubuntu-vm login: ").dial, nil, nil, "", clock.now, clock.sleep, noopReadPubKey, okBanner); err != nil { | ||
| 512 | t.Fatalf("runScenario: %v", err) | ||
| 513 | } | ||
| 514 | if got.CloudInit != byoCloudInit { | ||
| 515 | t.Errorf("CreateVM CloudInit = %q, want the tenant document the merge proof reads back", got.CloudInit) | ||
| 516 | } | ||
| 517 | } | ||
| 518 | |||
| 492 | func TestRunScenarioGateRegisterErrorAbortsBeforeCreate(t *testing.T) { | 519 | func TestRunScenarioGateRegisterErrorAbortsBeforeCreate(t *testing.T) { |
| 493 | api := &testAPI{ | 520 | api := &testAPI{ |
| 494 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { | 521 | listHostsFunc: func(ctx context.Context) ([]client.Host, error) { |