18d96cfa
feat(seed): guests own their full disk from first boot
a73x 2026-07-29 19:23
Commit message
internal/agent/seed/seed.go
| Old | New | ||
|---|---|---|---|
| @@ -91,21 +91,43 @@ func sshdDropIn(p Params) string { | |||
| 91 | return b.String() | 91 | return b.String() |
| 92 | } | 92 | } |
| 93 | 93 | ||
| 94 | // vendorDataDoc renders the vendor-data #cloud-config for a seed: the eitri | 94 | // growRuncmd grows the root partition to fill disk_gb and resizes the |
| 95 | // user-CA trust drop-in when the CA key is set, and the per-VM host key + cert | 95 | // filesystem, online, on the mounted root — eitri's side of the contract |
| 96 | // when those are set. Returns "" when neither is set, meaning no vendor-data | 96 | // (eitri delivers disk_gb to the guest; the guest owns everything above it). |
| 97 | // file is written (unchanged TOFU behaviour). | 97 | // It lives in vendor-data, not the default user-data, so it applies even when a |
| 98 | // tenant brings their own cloud-init. | ||
| 99 | // | ||
| 100 | // The image ships a GPT sized for its built-in ~3.5G disk; the agent extends the | ||
| 101 | // raw file to disk_gb, which strands the GPT's backup header mid-device. The | ||
| 102 | // image's own cloud-init growpart is therefore turned OFF (see vendorDataDoc): | ||
| 103 | // its sfdisk resizer treats sfdisk's non-zero exit on the "GPT backup not at the | ||
| 104 | // end / PMBR size mismatch" auto-correction as a failure and REVERTS the grow, | ||
| 105 | // so the root never grows and cloud-init reports degraded. This runcmd does the | ||
| 106 | // same sfdisk write but ignores that exit — the write persists — then completes | ||
| 107 | // the grow online: partx -u makes the kernel pick up the new partition size | ||
| 108 | // without unmounting, and resize2fs grows the mounted root filesystem. | ||
| 109 | // | ||
| 110 | // `printf ',+\n' | sfdisk -N 1` keeps partition 1's start and grows it to fill | ||
| 111 | // the disk; --no-reread/--no-tell-kernel avoid the BLKRRPART that fails on a | ||
| 112 | // mounted root (partx -u supplies the online update instead). | ||
| 113 | const growRuncmd = ` - ["sh", "-c", "printf ',+\\n' | sfdisk --no-reread --no-tell-kernel -N 1 /dev/vda; partx -u /dev/vda; resize2fs /dev/vda1"]` + "\n" | ||
| 114 | |||
| 115 | // vendorDataDoc renders the vendor-data #cloud-config for a seed. It always | ||
| 116 | // carries the disk-grow (growpart disabled + the online grow runcmd, see | ||
| 117 | // growRuncmd); it additionally carries the eitri user-CA trust drop-in when the | ||
| 118 | // CA key is set and the per-VM host key + cert when those are set. It is never | ||
| 119 | // empty, so a vendor-data file is always written. | ||
| 98 | // | 120 | // |
| 99 | // The keys, cert, and drop-in conf are embedded as YAML block scalars (`|`), so | 121 | // The keys, cert, and drop-in conf are embedded as YAML block scalars (`|`), so |
| 100 | // their bytes appear verbatim in the guest files. Block scalars are | 122 | // their bytes appear verbatim in the guest files. Block scalars are |
| 101 | // injection-safe: every indented line is literal content. The runcmd gates the | 123 | // injection-safe: every indented line is literal content. The sshd reload is |
| 102 | // reload behind `sshd -t` so a malformed config can never lock anyone out. | 124 | // gated behind `sshd -t` so a malformed config can never lock anyone out. |
| 103 | func vendorDataDoc(p Params) string { | 125 | func vendorDataDoc(p Params) string { |
| 104 | if p.SSHUserCAAuthorizedKey == "" && p.SSHHostKeyPEM == "" { | ||
| 105 | return "" | ||
| 106 | } | ||
| 107 | var b strings.Builder | 126 | var b strings.Builder |
| 108 | b.WriteString("#cloud-config\n") | 127 | b.WriteString("#cloud-config\n") |
| 128 | // Disable the image's growpart: its sfdisk resizer reverts on this disk (see | ||
| 129 | // growRuncmd). Quoted so YAML reads the string "off", not the boolean false. | ||
| 130 | b.WriteString("growpart:\n mode: \"off\"\n") | ||
| 109 | if p.SSHHostKeyPEM != "" { | 131 | if p.SSHHostKeyPEM != "" { |
| 110 | // Hand the ed25519 host key + cert to cloud-init's native ssh_keys map. | 132 | // Hand the ed25519 host key + cert to cloud-init's native ssh_keys map. |
| 111 | // cc_ssh installs ed25519_private/ed25519_certificate at the default paths | 133 | // cc_ssh installs ed25519_private/ed25519_certificate at the default paths |
| @@ -119,14 +141,22 @@ func vendorDataDoc(p Params) string { | |||
| 119 | writeBlockScalar(&b, " ", "ed25519_private", p.SSHHostKeyPEM) | 141 | writeBlockScalar(&b, " ", "ed25519_private", p.SSHHostKeyPEM) |
| 120 | writeBlockScalar(&b, " ", "ed25519_certificate", p.SSHHostCert) | 142 | writeBlockScalar(&b, " ", "ed25519_certificate", p.SSHHostCert) |
| 121 | } | 143 | } |
| 122 | b.WriteString("write_files:\n") | 144 | dropIn := sshdDropIn(p) |
| 123 | if p.SSHUserCAAuthorizedKey != "" { | 145 | if p.SSHUserCAAuthorizedKey != "" || dropIn != "" { |
| 124 | writeFileBlock(&b, userCAPath, p.SSHUserCAAuthorizedKey) | 146 | b.WriteString("write_files:\n") |
| 147 | if p.SSHUserCAAuthorizedKey != "" { | ||
| 148 | writeFileBlock(&b, userCAPath, p.SSHUserCAAuthorizedKey) | ||
| 149 | } | ||
| 150 | if dropIn != "" { | ||
| 151 | writeFileBlock(&b, dropInPath, dropIn) | ||
| 152 | } | ||
| 125 | } | 153 | } |
| 126 | writeFileBlock(&b, dropInPath, sshdDropIn(p)) | ||
| 127 | b.WriteString("runcmd:\n") | 154 | b.WriteString("runcmd:\n") |
| 128 | // -t gates the reload: a bad config won't reload, so no lockout. | 155 | b.WriteString(growRuncmd) |
| 129 | b.WriteString(" - [\"sh\", \"-c\", \"sshd -t && systemctl reload sshd\"]\n") | 156 | if dropIn != "" { |
| 157 | // -t gates the reload: a bad config won't reload, so no lockout. | ||
| 158 | b.WriteString(" - [\"sh\", \"-c\", \"sshd -t && systemctl reload sshd\"]\n") | ||
| 159 | } | ||
| 130 | return b.String() | 160 | return b.String() |
| 131 | } | 161 | } |
| 132 | 162 | ||
| @@ -156,7 +186,13 @@ func writeFileBlock(b *strings.Builder, path, content string) { | |||
| 156 | 186 | ||
| 157 | // userData returns the cloud-config string. If p.UserData is non-empty it is | 187 | // userData returns the cloud-config string. If p.UserData is non-empty it is |
| 158 | // returned verbatim (advanced users own their user-data). Otherwise a sensible | 188 | // returned verbatim (advanced users own their user-data). Otherwise a sensible |
| 159 | // default is generated with an SSH key, growpart, and a default ubuntu user. | 189 | // default is generated with an SSH key and a default ubuntu user. |
| 190 | // | ||
| 191 | // The default does NOT configure growpart or disk_setup: growing the root to | ||
| 192 | // disk_gb is eitri's job and lives in vendor-data (see vendorDataDoc/growRuncmd), | ||
| 193 | // which applies to BYO user-data too. Setting growpart here would also override | ||
| 194 | // vendor-data's `growpart: off` (user-data wins the cloud-init merge), re-arming | ||
| 195 | // the image growpart that reverts on this disk. | ||
| 160 | func userData(p Params) string { | 196 | func userData(p Params) string { |
| 161 | if p.UserData != "" { | 197 | if p.UserData != "" { |
| 162 | return p.UserData | 198 | return p.UserData |
| @@ -171,14 +207,6 @@ func userData(p Params) string { | |||
| 171 | } | 207 | } |
| 172 | return fmt.Sprintf(`#cloud-config | 208 | return fmt.Sprintf(`#cloud-config |
| 173 | hostname: %s | 209 | hostname: %s |
| 174 | disk_setup: | ||
| 175 | /dev/vda: | ||
| 176 | table_type: gpt | ||
| 177 | layout: true | ||
| 178 | overwrite: false | ||
| 179 | growpart: | ||
| 180 | mode: auto | ||
| 181 | devices: ["/"] | ||
| 182 | users: | 210 | users: |
| 183 | - name: ubuntu | 211 | - name: ubuntu |
| 184 | sudo: ALL=(ALL) NOPASSWD:ALL | 212 | sudo: ALL=(ALL) NOPASSWD:ALL |
| @@ -282,7 +310,8 @@ func Build(outPath string, p Params) error { | |||
| 282 | "/meta-data": metaData(p), | 310 | "/meta-data": metaData(p), |
| 283 | "/network-config": networkConfig(), | 311 | "/network-config": networkConfig(), |
| 284 | } | 312 | } |
| 285 | // Vendor-data carries the eitri user-CA sshd drop-in (empty doc => no file). | 313 | // Vendor-data carries eitri's own config (disk-grow, and the SSH user-CA |
| 314 | // drop-in + host cert when set); it is always non-empty, so always written. | ||
| 286 | if vd := vendorDataDoc(p); vd != "" { | 315 | if vd := vendorDataDoc(p); vd != "" { |
| 287 | files["/vendor-data"] = vd | 316 | files["/vendor-data"] = vd |
| 288 | } | 317 | } |
internal/agent/seed/seed_test.go
| Old | New | ||
|---|---|---|---|
| @@ -46,11 +46,16 @@ func TestBuildProducesISOWithNoCloudFiles(t *testing.T) { | |||
| 46 | assert.True(t, names["network-config"], "network-config must be present in ISO") | 46 | assert.True(t, names["network-config"], "network-config must be present in ISO") |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | func TestUserDataDefaultInjectsKeyAndGrowpart(t *testing.T) { | 49 | func TestUserDataDefaultInjectsKeyNotGrowpart(t *testing.T) { |
| 50 | ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"}) | 50 | ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"}) |
| 51 | assert.True(t, strings.HasPrefix(ud, "#cloud-config\n")) | 51 | assert.True(t, strings.HasPrefix(ud, "#cloud-config\n")) |
| 52 | assert.Contains(t, ud, "ssh-ed25519 KEY") | 52 | assert.Contains(t, ud, "ssh-ed25519 KEY") |
| 53 | assert.Contains(t, ud, "growpart") // disk_gb resize completes in-guest (spec) | 53 | assert.Contains(t, ud, "name: ubuntu") |
| 54 | // Growing the root to disk_gb is eitri's job and lives in vendor-data, not | ||
| 55 | // here: a growpart in user-data would override vendor-data's `growpart: off` | ||
| 56 | // and re-arm the image growpart that reverts on this disk. | ||
| 57 | assert.NotContains(t, ud, "growpart", "growpart belongs in vendor-data, not user-data") | ||
| 58 | assert.NotContains(t, ud, "disk_setup", "the no-op disk_setup block is gone") | ||
| 54 | } | 59 | } |
| 55 | 60 | ||
| 56 | func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) { | 61 | func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) { |
| @@ -62,7 +67,6 @@ func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) { | |||
| 62 | assert.NotContains(t, ud, "- \n", "no null list item may be rendered") | 67 | assert.NotContains(t, ud, "- \n", "no null list item may be rendered") |
| 63 | // The rest of the default user-data is intact. | 68 | // The rest of the default user-data is intact. |
| 64 | assert.Contains(t, ud, "hostname: h") | 69 | assert.Contains(t, ud, "hostname: h") |
| 65 | assert.Contains(t, ud, "growpart") | ||
| 66 | assert.Contains(t, ud, "name: ubuntu") | 70 | assert.Contains(t, ud, "name: ubuntu") |
| 67 | } | 71 | } |
| 68 | 72 | ||
| @@ -145,7 +149,9 @@ func readISOFile(t *testing.T, isoPath, name string) []byte { | |||
| 145 | return b | 149 | return b |
| 146 | } | 150 | } |
| 147 | 151 | ||
| 148 | func TestBuildWithoutCAKeepsThreeFileLayout(t *testing.T) { | 152 | func TestBuildAlwaysCarriesVendorDataForDiskGrow(t *testing.T) { |
| 153 | // Even with no CA/host key, the seed carries vendor-data — it holds the | ||
| 154 | // disk-grow that delivers disk_gb to the guest. | ||
| 149 | out := t.TempDir() + "/seed.iso" | 155 | out := t.TempDir() + "/seed.iso" |
| 150 | require.NoError(t, Build(out, Params{ | 156 | require.NoError(t, Build(out, Params{ |
| 151 | Hostname: "plain", | 157 | Hostname: "plain", |
| @@ -162,7 +168,11 @@ func TestBuildWithoutCAKeepsThreeFileLayout(t *testing.T) { | |||
| 162 | for _, e := range entries { | 168 | for _, e := range entries { |
| 163 | names[strings.ToLower(e.Name())] = true | 169 | names[strings.ToLower(e.Name())] = true |
| 164 | } | 170 | } |
| 165 | assert.False(t, names["vendor-data"], "seeds with no CA key must not carry vendor-data") | 171 | assert.True(t, names["vendor-data"], "every seed carries vendor-data for the disk-grow") |
| 172 | vd := string(readISOFile(t, out, "/vendor-data")) | ||
| 173 | assert.Contains(t, vd, `mode: "off"`, "image growpart disabled") | ||
| 174 | assert.Contains(t, vd, "resize2fs /dev/vda1", "online grow runcmd present") | ||
| 175 | assert.NotContains(t, vd, "TrustedUserCAKeys", "no CA ⇒ no CA trust line") | ||
| 166 | st, err := os.Stat(out) | 176 | st, err := os.Stat(out) |
| 167 | require.NoError(t, err) | 177 | require.NoError(t, err) |
| 168 | assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)") | 178 | assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)") |
| @@ -272,30 +282,55 @@ func TestVendorDataHostCertWithoutCAKey(t *testing.T) { | |||
| 272 | assert.NotContains(t, vd, "TrustedUserCAKeys") | 282 | assert.NotContains(t, vd, "TrustedUserCAKeys") |
| 273 | } | 283 | } |
| 274 | 284 | ||
| 275 | func TestNoVendorDataWhenHostAndCAEmpty(t *testing.T) { | 285 | func TestVendorDataDiskGrowAlwaysPresent(t *testing.T) { |
| 276 | // Neither CA key nor host key ⇒ no vendor-data at all. | 286 | // Even with neither CA key nor host key, vendor-data carries the disk-grow: |
| 277 | assert.Equal(t, "", vendorDataDoc(Params{})) | 287 | // growpart disabled + the proven online grow runcmd. No SSH material leaks in. |
| 288 | vd := vendorDataDoc(Params{}) | ||
| 289 | assert.True(t, strings.HasPrefix(vd, "#cloud-config\n")) | ||
| 290 | assert.Contains(t, vd, "growpart:\n mode: \"off\"\n", | ||
| 291 | "the image growpart is disabled (it reverts on this disk)") | ||
| 292 | // The exact proven runcmd: sfdisk grows partition 1, partx -u picks it up | ||
| 293 | // online on the mounted root, resize2fs grows the fs. `\\n` is two literal | ||
| 294 | // bytes here (Go), so the guest shell's printf emits one newline to sfdisk. | ||
| 295 | assert.Contains(t, vd, | ||
| 296 | ` - ["sh", "-c", "printf ',+\\n' | sfdisk --no-reread --no-tell-kernel -N 1 /dev/vda; partx -u /dev/vda; resize2fs /dev/vda1"]`+"\n") | ||
| 297 | // Nothing SSH: no host key, no CA trust, no write_files, no sshd reload. | ||
| 298 | assert.NotContains(t, vd, "ssh_keys:") | ||
| 299 | assert.NotContains(t, vd, "write_files:") | ||
| 300 | assert.NotContains(t, vd, "TrustedUserCAKeys") | ||
| 301 | assert.NotContains(t, vd, "reload sshd") | ||
| 302 | } | ||
| 303 | |||
| 304 | func TestVendorDataDiskGrowCoexistsWithSSHMaterial(t *testing.T) { | ||
| 305 | // The disk-grow sits alongside the CA trust + host cert, and its runcmd | ||
| 306 | // entry is a sibling of the sshd-reload — both run. | ||
| 307 | vd := vendorDataDoc(Params{ | ||
| 308 | SSHUserCAAuthorizedKey: testUserCAKey, | ||
| 309 | SSHHostKeyPEM: testHostKeyPEM, | ||
| 310 | SSHHostCert: testHostCert, | ||
| 311 | }) | ||
| 312 | assert.Contains(t, vd, `mode: "off"`) | ||
| 313 | assert.Contains(t, vd, "resize2fs /dev/vda1") | ||
| 314 | assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub") | ||
| 315 | assert.Contains(t, vd, "ed25519_certificate: |") | ||
| 316 | assert.Contains(t, vd, "sshd -t && systemctl reload sshd") | ||
| 278 | } | 317 | } |
| 279 | 318 | ||
| 280 | func TestNoVendorDataWhenCAKeyEmpty(t *testing.T) { | 319 | func TestUserSuppliedCloudInitKeepsVerbatimWhileVendorGrows(t *testing.T) { |
| 281 | // Empty CA key: no drop-in, no vendor-data at all. | 320 | // A BYO user-data is passed through byte-for-byte (eitri never edits it), yet |
| 321 | // the seed's vendor-data still delivers the disk-grow — the contract survives | ||
| 322 | // custom cloud-init. | ||
| 282 | out := t.TempDir() + "/seed.iso" | 323 | out := t.TempDir() + "/seed.iso" |
| 324 | custom := "#cloud-config\npackages: [htop]\n" | ||
| 283 | require.NoError(t, Build(out, Params{ | 325 | require.NoError(t, Build(out, Params{ |
| 284 | Hostname: "plain", | 326 | Hostname: "byo", |
| 285 | SSHAuthorizedKey: "ssh-ed25519 AAAA user@host", | 327 | UserData: custom, |
| 328 | SSHUserCAAuthorizedKey: testUserCAKey, | ||
| 286 | })) | 329 | })) |
| 287 | d, err := diskfs.Open(out, diskfs.WithSectorSize(2048)) | ||
| 288 | require.NoError(t, err) | ||
| 289 | defer d.Close() | ||
| 290 | fsi, err := d.GetFilesystem(0) | ||
| 291 | require.NoError(t, err) | ||
| 292 | entries, err := fsi.ReadDir(".") | ||
| 293 | require.NoError(t, err) | ||
| 294 | for _, e := range entries { | ||
| 295 | assert.NotEqual(t, "vendor-data", strings.ToLower(e.Name()), | ||
| 296 | "no CA key must not produce vendor-data") | ||
| 297 | } | ||
| 298 | // user-data untouched (default form). | ||
| 299 | ud := string(readISOFile(t, out, "/user-data")) | 330 | ud := string(readISOFile(t, out, "/user-data")) |
| 300 | assert.Contains(t, ud, "hostname: plain") | 331 | assert.Equal(t, custom, ud, "user-data is passed through verbatim") |
| 332 | assert.NotContains(t, ud, "resize2fs", "eitri does not inject the grow into user-data") | ||
| 333 | vd := string(readISOFile(t, out, "/vendor-data")) | ||
| 334 | assert.Contains(t, vd, `mode: "off"`) | ||
| 335 | assert.Contains(t, vd, "resize2fs /dev/vda1") | ||
| 301 | } | 336 | } |