internal/smoke/cloudinit.go
Ref: Size: 5.1 KiB History
package smoke
import (
"fmt"
"strconv"
"strings"
"github.com/a73x/eitri/internal/server/api/client"
)
// Guest paths the tenant document below creates, one per cloud-init key. They
// are read back by seedProbeCmd, and they live under /etc rather than /run
// because the gate proof runs again after the power cycle and cloud-init's
// write_files and runcmd only ever run once per instance.
const (
byoWriteFilesMarker = "/etc/eitri-smoke-byo-write-files"
byoRuncmdMarker = "/etc/eitri-smoke-byo-runcmd"
)
// byoCloudInit is the tenant cloud-init the scenario's VM is created with. It
// exists to make the smoke's one VM a guest of the shape that used to lose
// eitri's own seed config: cloud-init folds documents together with mergers
// that discard one side's lists, so a tenant document carrying `write_files:`
// or `runcmd:` cost the guest its CA trust file or its online root grow. The
// seed answers that with a merge directive on its own cloud-config part
// (internal/agent/seed, mergeHow), and this document is what makes the boot
// gate exercise it rather than take it on faith.
//
// It carries both keys and nothing else, so the guest still gets its default
// user from the image (and the create request's SSH key, which the control
// plane merges into whatever document it is given). Each key drops one marker
// file, so the proof can see the tenant's own entries ran too — a merge that
// silently kept only eitri's side would be the same bug pointing the other way.
const byoCloudInit = `#cloud-config
write_files:
- path: ` + byoWriteFilesMarker + `
permissions: '0644'
content: |
the tenant's own write_files entry ran
runcmd:
- ["sh", "-c", "echo 'the tenant runcmd entry ran' > ` + byoRuncmdMarker + `"]
`
// seedProbeCmd reads the three facts the merge proof needs out of a booted
// guest, one labelled line each, so proveSeedSurvivedBYO can stay pure. The
// redirections keep a missing file from failing the whole command: an absent
// marker must arrive as an empty value to be reported, not as a shell error
// that reads like an unreachable guest.
const seedProbeCmd = `printf 'root_bytes=%s\n' "$(findmnt -no SIZE -b / 2>/dev/null)"; ` +
`printf 'byo_write_files=%s\n' "$(cat ` + byoWriteFilesMarker + ` 2>/dev/null)"; ` +
`printf 'byo_runcmd=%s\n' "$(cat ` + byoRuncmdMarker + ` 2>/dev/null)"`
// minGrownRootBytes is the floor a grown root must clear: half the disk the
// scenario's VM is created with, which is the control plane's default because
// the create request names no size. The default image ships a root of about
// 3.5G, so half of a default disk sits above what the image brings and below
// what the grow produces — a root over the floor can only be one the seed's
// grow runcmd resized, and a root under it can only be one that never ran.
// Nothing lands between the two, which is why one number is enough.
//
// It is derived rather than written down so that moving the default disk moves
// the floor with it. A literal here would be this package's private copy of a
// number it does not own, and the first shrink of the default would turn this
// leg's pass into a coincidence.
const minGrownRootBytes int64 = client.DefaultDiskGB * (1 << 30) / 2
// proveSeedSurvivedBYO reads seedProbeCmd's output and reports whether both
// sides of the cloud-init merge reached the guest: eitri's grow runcmd (the
// root is bigger than the image's) and the tenant's own write_files and runcmd
// (their marker files exist). Unreadable output is a failure — a probe that
// cannot be parsed proves nothing, and must never read as a pass.
func proveSeedSurvivedBYO(out string) error {
fields := parseProbe(out)
raw, ok := fields["root_bytes"]
if !ok || raw == "" {
return fmt.Errorf("FAIL: the guest reported no root filesystem size, so the seed's disk grow cannot be judged (probe output: %q)", out)
}
size, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("FAIL: the guest reported root filesystem size %q, which is not a number of bytes", raw)
}
if size < minGrownRootBytes {
return fmt.Errorf("FAIL: the guest's root filesystem is %d bytes, under the %d it must exceed once grown — "+
"the seed's grow runcmd did not run, which is what a tenant runcmd displacing eitri's looks like", size, minGrownRootBytes)
}
if fields["byo_write_files"] == "" {
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)
}
if fields["byo_runcmd"] == "" {
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)
}
return nil
}
// parseProbe splits seedProbeCmd's `key=value` lines into a map. Lines without
// a `=` are ignored: a guest is free to print a login banner or a warning ahead
// of the command's own output, and none of it is an answer either way.
func parseProbe(out string) map[string]string {
fields := make(map[string]string)
for line := range strings.SplitSeq(out, "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
fields[key] = strings.TrimSpace(value)
}
return fields
}