cloud-init-renderer
Recipe card from the charly-internals plugin (Development — contributor internals).
cloud-init-renderer
Section titled “cloud-init-renderer”Host-side renderer producing NoCloud seed ISOs for cloud_image VMs (and bootc VMs that include the cloud-init layer). Pure transformation — given VmSpec + VmCloudInit, produces three files (user-data, meta-data, network-config) and packages them into an ISO 9660 image labeled CIDATA via xorriso.
Lives host-side, in the charly binary. The guest-side /charly-distros:cloud-init layer is complementary: it puts the cloud-init package into the bootc guest OS so that guest reads the seed ISO. The two sides cooperate across the host/guest boundary.
Source files
Section titled “Source files”| File | Contents |
|---|---|
sdk/vmshared/cloud_init_render.go |
RenderCloudInit, ResolveKeyInjectionChannels, composeUsers, composePackages, composeBootCmd, composeRunCmd |
sdk/vmshared/cloud_init_iso.go |
WriteSeedISO via xorriso; genisoimage + mkisofs fallbacks |
sdk/kit/charly_install.go |
kit.EnsureCharlyInGuest state machine (auto/scp/url/skip strategies) |
sdk/spec/cue_types_gen.go (generated) |
VmCloudInit, VmCloudInitUser, VmCloudInitFile, VmCloudInitNetwork, VmCloudInitMirrors, VmCharlyInstall |
RenderCloudInit top-level
Section titled “RenderCloudInit top-level”func RenderCloudInit(spec *VmSpec, rt CloudInitRuntimeParams) (userData, metaData, networkConfig string, err error)Returns three strings — the three files NoCloud expects on the seed ISO. The caller (BuildCloudImage or the vm deploy preflight) then calls WriteSeedISO to pack them.
Egress validation gate. Before returning, each rendered document is validated
against a CUE schema — user-data against the vendored Canonical cloud-config
schema (#CloudConfig), meta-data against #CloudInitMeta, network-config against
#NetworkConfigV2 (via ValidateEgress). A malformed render fails here, so a
cloud-init that its own schema would reject never reaches the seed ISO. The gate
is owned by /charly-internals:egress.
composeUsers (adopt-merge pattern)
Section titled “composeUsers (adopt-merge pattern)”Single most important function in this renderer. Produces the users: list in user-data.
When spec.Source.BaseUser is non-empty (cloud_image adopt pattern):
users: - default # cloud-init sentinel: preserve distro's default account - name: <base_user> ssh_authorized_keys: - <pubkey>No useradd, no sudoers write, no shell change — cloud-init interprets “name: X” on an existing account as “append ssh_authorized_keys”. The parity is exact with the container-side base_user: + user_policy: adopt pattern (/charly-image:image “user_policy”).
When BaseUser is empty and VmSSH.User is non-empty (create pattern):
users: - default - name: <ssh_user> sudo: ALL=(ALL) NOPASSWD:ALL groups: [wheel, sudo] shell: /bin/bash lock_passwd: true ssh_authorized_keys: - <pubkey>Full account provisioning. Used by bootc VMs where no base_user: applies.
When a user already appears in spec.CloudInit.Users: the renderer appends the ssh pubkey to that existing entry instead of emitting a new one. Lets authors declare a user with specific sudo/groups/shell fields and still get the pubkey injected.
ResolveKeyInjectionChannels
Section titled “ResolveKeyInjectionChannels”Applies the per-source-kind auto-defaults documented in /charly-internals:vm-spec:
func ResolveKeyInjectionChannels(spec *VmSpec) (smbios bool, cloudInit bool) { if spec.SSH != nil && spec.SSH.KeyInjection != nil { // explicit overrides return spec.SSH.KeyInjection.SMBIOS == "enabled" (with "auto" = source-kind default), spec.SSH.KeyInjection.CloudInit == "enabled" (with "auto" = source-kind default) } // per-source-kind auto-defaults switch spec.Source.Kind { case "cloud_image": return true, true // belt + suspenders case "bootc": return true, false // cloud_init channel only activates with cloud-init layer }}Both channels are additive — when both are on, systemd-ssh-generator (SMBIOS path) and cloud-init (user-data path) both inject the key. Dedup happens in the guest’s authorized_keys. There’s no correctness issue with duplicate keys; the dual-injection pattern is the safe default.
composePackages + composeBootCmd + composeRunCmd renderer defaults
Section titled “composePackages + composeBootCmd + composeRunCmd renderer defaults”The renderer prepends defaults to user-declared lists:
composePackages: prepends{openssh (oropenssh-serveron debian/ubuntu), curl, tar}(deduplicated against user’sPackages). Guarantees the guest has SSH server + download tools + tar for later layer application.composeBootCmd: prependssystemctl mask ssh.socket || true— the EARLIEST cloud-init phase (bootcmdruns beforewrite_files/packages/runcmd), so a socket-activated sshd (enabled by default on some cloud images, notably Debian/Ubuntu) can never accept a connection before cloud-init has finished configuring the guest.|| truemakes this a harmless no-op on a distro that ships nossh.socketunit at all (Arch/Fedora typically don’t).composeRunCmd: prepends THREE steps, in order: (1) a self-testing shell snippet that writes aPerSourcePenalties nosshd_config.d drop-in and validates the FULL resulting config withsshd -t, deleting the drop-in again on failure; (2)systemctl unmask ssh.socket || true(the matching unmask forcomposeBootCmd’s mask); (3)systemctl enable --now sshd(orsshon debian/ubuntu). Distro-specific userruncmd:entries can assume sshd is running and hardened.
User-supplied fields extend defaults; they don’t replace them. Prevents the common footgun where an author puts packages: [nginx] and accidentally breaks SSH because they overrode the default list.
Guest SSH hardening (D18) — the RCA’d kex-reset wedge class
Section titled “Guest SSH hardening (D18) — the RCA’d kex-reset wedge class”The bootcmd-mask + runcmd-unmask/hardening-drop-in/enable sequence above closes a
confirmed wedge: OpenSSH ≥ 9.8 defaults PerSourcePenalties ON, penalizing
repeated connection attempts from ONE source. Every VM guest is reached through
the SAME single passt gateway source IP, so kit.WaitForSSH’s own readiness
poll (/charly-internals:vm-deploy-target) can trip its own guest’s rate limit
and appear to “reset forever” against an otherwise-healthy guest.
Why a shell runcmd snippet, not a static write_files entry, for the sshd
drop-in. PerSourcePenalties does not exist before OpenSSH 9.8. Writing it
as a static cloud-config write_files entry would hand an OLDER guest’s sshd
a config with an unrecognized directive, which sshd refuses to start
against. The shell snippet writes the drop-in, then runs sshd -t (validating
the FULL resulting config) and deletes the drop-in again on failure — fail-safe
to the pre-fix behavior (the original penalty risk stands on an old guest),
never a bricked sshd.
Design trade-off — deliberate, not overlooked. Masking ssh.socket until
runcmd makes the guest deterministically unreachable via SSH for the
entire package-install phase, trading the old “sometimes-flaky-but-reachable”
window (sshd up early, racing a possible host-key rewrite) for
“safe-but-fully-blocked-if-package-install-stalls.” This is verified live
against real Arch cloud_image VM beds (check-charly-vm, and
check-sidecar-pod’s nested ephemeral VM member) — including a full
fresh-rebuild pass (destroy+recreate) for each — with zero wedge, now that the
companion redundant-package-reinstall fix (below) keeps the package-install
phase itself fast. If field evidence ever shows a package-install stall under
this ordering, the revisit path is either (a) moving the unmask earlier (e.g.
a write_files-stage cloud-init module instead of runcmd), or (b) dropping
the mask/unmask pair entirely and relying on the PerSourcePenalties drop-in
alone.
Delivery is distro-branched (D15): packages: for most distros, a runcmd:-prepended pacman -S --needed for pacman-family. On every distro EXCEPT the pacman family (arch/cachyos/manjaro/endeavouros), the composed package union rides the packages: cloud-config key as documented above. On a pacman-family distro, packages: is OMITTED entirely and the union is instead PREPENDED to runcmd: as pacman -S --needed --noconfirm <union> — AHEAD of composeRunCmd’s own three hardening/enable steps (D18, above). This is an R10 bed finding: cloud-init’s own package-install module invokes pacman -S WITHOUT --needed, so on an image that already ships the minimum set (e.g. every Arch cloud image) it unconditionally REINSTALLS them — reinstalling openssh re-triggers its post-install host-key-regen hook while the base image’s own socket-activated sshd is already listening, racing a live key-file rewrite against new SSH connections (the observed “reset during kex_exchange_identification, guest otherwise idle” signature). apt/dnf installs are naturally no-op-idempotent when the package is already present, so only the pacman-family path needs the --needed rewrite. The pacman-family check is formatForDistroID(effectiveDistro(spec)) == "pac"; effectiveDistro resolves Source.Distro when set, and additionally infers "arch" for a cloud_image source with base_user: "arch" and no explicit distro: (a narrowing of the pre-existing composePackages distro-switch fallback, never a behavior change for a caller that already got the Arch/Fedora shape) — every OTHER empty-distro image stays on the safe, unchanged packages:-key path. See sdk/vmshared/cloud_init_render.go’s effectiveDistro/composePackages doc comments for the full narrowing proof. Note (D18): the pacman-vs-non-pacman split governs ONLY the packages: key vs the pacman -S --needed runcmd prepend — every distro’s runcmd: now ALSO carries the D18 hardening/unmask/enable steps, so “byte-identical to before” no longer applies to the full runcmd list, only to the packages-key handling this paragraph describes.
WriteSeedISO
Section titled “WriteSeedISO”func WriteSeedISO(userData, metaData, networkConfig string, outputPath string) errorWrites an ISO 9660 image whose volume identifier is CIDATA (the shared
vmshared.cloudInitVolumeID). It MUST be uppercase: ISO 9660 / ECMA 119
d-characters are A-Z 0-9 _ only, so a lowercase label makes xorriso warn on
every VM boot. cloud-init still finds it — its NoCloud datasource searches both
LABEL=<fs_label>.upper() and .lower(), with fs_label defaulting to
cidata. Tool preference order:
xorriso(preferred — modern, scriptable).genisoimage(legacy but widely available).mkisofs(oldest fallback).
Clean error when none are present, with distro-appropriate install recipe (dnf install xorriso, pacman -S libisoburn, apt-get install xorriso).
The ISO is mounted by QEMU as a CD-ROM; cloud-init’s NoCloud datasource reads /dev/sr0 at first boot.
EnsureCharlyInGuest (charly_install.strategy state machine)
Section titled “EnsureCharlyInGuest (charly_install.strategy state machine)”Runs post-boot inside the vm deploy preflight (the candy/plugin-deploy-vm plugin’s OpPrepareVenue, via kit.EnsureCharlyInGuest in sdk/kit/charly_install.go) after cloud-init completes, BEFORE the plugin walks the plans. Dispatches on spec.CloudInit.CharlyInstall.Strategy:
| Strategy | Action |
|---|---|
auto / scp |
scp $(os.Executable()) guest:/usr/local/bin/charly; chmod +x |
url |
ssh-execute curl -L <url> -o /usr/local/bin/charly && sha256sum -c (verified against VmCharlyInstall.Checksum) |
skip |
ssh 'which charly' — fails if missing, returns early if present |
Idempotent. If charly is already present at the target version, the function returns without re-scp’ing. See /charly-internals:vm-deploy-target for how this plugs into the overall deploy flow.
Network config
Section titled “Network config”VmCloudInitNetwork.Ethernets passes through to cloud-init’s network-config v2 as-is. When unset, the renderer emits an empty network-config (cloud-init defaults to DHCP on every virtio-net interface). Good default; override only for static-IP deployments.
SMBIOS vs cloud_init can coexist
Section titled “SMBIOS vs cloud_init can coexist”Explicitly supported — not either/or. VmKeyInjection.SMBIOS: enabled + VmKeyInjection.CloudInit: enabled simultaneously is the default for cloud_image VMs. Rationale: belt-and-suspenders (SMBIOS via systemd-ssh-generator v250+, cloud-init via user-data — both paths exist on modern Linux). No duplication cost.
Cross-References
Section titled “Cross-References”/charly-internals:vm-spec—VmCloudInit,VmSSH.KeyInjection,VmCharlyInstalltypes/charly-internals:libvirt-renderer— SMBIOS-channel emission (domain XML side)/charly-internals:vm-deploy-target—EnsureCharlyInGuestcaller; SSH/cloud-init readiness waits/charly-vm:vm— command-family; cloud-init flow/charly-vm:vms-catalog— YAML-authoring reference/charly-vm:arch-cloud-vm—charly_install.strategy: autoworked example; adopt-user pattern/charly-distros:cloud-init— guest-side pairing: the cloud-init package installed inside bootc images reads the seed ISO this renderer produces