fix(template): clean snapshot chain on deploy --force; seal cloud-init; harden first-boot IP

deploy-linux --force reused the VM directory but left any prior snapshot
chain in place, so vmware-vdiskmanager refused the Step 4 resize with
'disk is part of a snapshot chain'. Add _clean_force_artifacts to remove
snapshot deltas (.vmdk), .vmsn, .vmsd and stale .lck before resize.

prepare-linux Step 7d seals the image: drop the stale cloud-init netplan
(leaving only the MAC-agnostic 99-ci-dhcp-allnics), regenerate networkd
config, and disable cloud-init so it never re-runs on linked clones.

deploy user-data sets package_update/upgrade: false so cloud-init does
not hold the dpkg lock at first boot and delay open-vm-tools past the
IP-detection poll; the step-7 IP timeout is raised 300s -> 600s as a
safety net for slow first boots.

Adds unit tests for _clean_force_artifacts (snapshot chain, lock dir,
clean-dir no-op) and updates prepare-linux stubs for the seal step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:55:34 +02:00
parent 2b0e98774f
commit e91e55f20e
2 changed files with 140 additions and 2 deletions
+83 -2
View File
@@ -794,6 +794,37 @@ def _find_vdisk_manager(explicit: str | None) -> str:
)
def _clean_force_artifacts(vm_dir: Path, disk_stem: str) -> list[str]:
"""Remove snapshot-chain and lock artifacts left in a reused VM directory.
``deploy-linux --force`` reuses the existing VM directory. If a previous
run (or a prepare-linux snapshot) left a snapshot chain behind, the disk
in the .vmx is "part of a snapshot chain" and ``vmware-vdiskmanager``
refuses to resize it at Step 4. We delete the snapshot delta disks, the
snapshot metadata (.vmsn), the snapshot database (.vmsd) and stale lock
files (.lck) so Step 4 sees a plain, chain-free disk.
The base disk itself is left untouched — Step 4 overwrites it from the
cached cloud image. Returns the sorted list of removed file names (for
logging and tests).
"""
removed: list[str] = []
patterns = (
f"{disk_stem}-[0-9]*.vmdk", # snapshot delta/redo disks
"*.vmsn", # snapshot memory/state
"*.vmsd", # snapshot database
"*.lck", # stale lock files/dirs
)
for pattern in patterns:
for path in vm_dir.glob(pattern):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
removed.append(path.name)
return sorted(removed)
def _download_vmdk(url: str, dest: Path) -> None:
click.echo(f" Downloading {url} ...")
tmp = dest.with_suffix(dest.suffix + ".part")
@@ -1109,6 +1140,12 @@ def template_deploy_linux(
" Use --force to overwrite."
)
click.echo(f" [WARN] --force set -- reusing existing VM directory: {vm_dir}")
cleaned = _clean_force_artifacts(vm_dir, vmx_path.stem)
if cleaned:
click.echo(
" [OK] Cleared stale snapshot/lock artifacts (avoids resize "
f"snapshot-chain error): {', '.join(cleaned)}"
)
else:
vm_dir.mkdir(parents=True, exist_ok=True)
click.echo(f" [OK] VM directory ready: {vm_dir}")
@@ -1149,6 +1186,12 @@ def template_deploy_linux(
user_data = (
"#cloud-config\n"
f"hostname: {guest_hostname}\n"
# Suppress cloud-init's first-boot apt update/upgrade. It would hold
# the dpkg lock for minutes and delay open-vm-tools startup past the
# step-7 IP-detection poll. The real toolchain apt work runs later
# in prepare-linux where it is managed.
"package_update: false\n"
"package_upgrade: false\n"
"users:\n"
f" - name: {cloud_user}\n"
" shell: /bin/bash\n"
@@ -1316,10 +1359,10 @@ def template_deploy_linux(
# Step 7: detect guest IP via vmrun getGuestIPAddress (max 5 min)
# ──────────────────────────────────────────────────────────────────────────
_deploy_step(
"Step 7: detect guest IP via vmrun getGuestIPAddress (max 5 min)", 7, start_from_step
"Step 7: detect guest IP via vmrun getGuestIPAddress (max 10 min)", 7, start_from_step
)
if start_from_step <= 7:
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=300.0)
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=600.0)
if not guest_ip:
raise click.ClickException("[Deploy Step 7] Timed out waiting for guest IP.")
click.echo(f" [OK] Guest IP: {guest_ip}")
@@ -1757,6 +1800,44 @@ def template_prepare_linux(
raise click.ClickException(f"enabling NTP failed (exit {rc_ntp}):\n{err_ntp}")
click.echo("[prepare-linux] NTP time sync enabled.")
# ── Step 7d: Seal cloud-init (runs once at deploy, never in clones) ───
# cloud-init provisioned the image at deploy time. It must not run
# again on the template or on linked clones: the VMX carries the
# guestinfo seed (userdata/metadata), which every linked clone
# inherits, so cloud-init would re-run on each boot and rewrite
# /etc/netplan/50-cloud-init.yaml pinning the deploy-time MAC. That
# stale MAC match never matches a clone's NIC (nor a re-MAC'd
# template), which wedges systemd-networkd's DHCPv4 — the interface
# links up and gets an offer on the wire but no address is ever
# applied. We seal the image: drop the stale cloud-init netplan,
# leaving only the MAC-agnostic 99-ci-dhcp-allnics.yaml, regenerate
# networkd config, then disable cloud-init for all future boots.
click.echo("[prepare-linux] Step 7d — Sealing cloud-init (disable for clones) ...")
rc_seal, _, err_seal = _pl_run(
client,
"sudo rm -f /etc/netplan/50-cloud-init.yaml "
"/etc/netplan/50-cloud-init.yaml.bak && sudo netplan generate",
)
if rc_seal != 0:
raise click.ClickException(f"cloud-init seal (netplan) failed (exit {rc_seal}):\n{err_seal}")
# Disable cloud-init for every subsequent boot, then clean its state
# (best-effort: clean may exit non-zero if already clean).
_pl_run(client, "sudo touch /etc/cloud/cloud-init.disabled")
_pl_run(client, "sudo cloud-init clean --logs 2>/dev/null || true")
# Verify the stale netplan is gone and cloud-init is disabled.
_, seal_out, _ = _pl_run(
client,
"test -f /etc/cloud/cloud-init.disabled && echo DISABLED; "
"test ! -f /etc/netplan/50-cloud-init.yaml && echo NO50",
)
if "DISABLED" not in seal_out or "NO50" not in seal_out:
raise click.ClickException(
f"cloud-init seal verification failed (expected DISABLED+NO50):\n{seal_out}"
)
click.echo(
"[prepare-linux] cloud-init sealed — clones boot with static netplan only."
)
# ── Step 8: Graceful shutdown ─────────────────────────────────────────
if do_snapshot:
click.echo("[prepare-linux] Step 8 — Sending graceful shutdown ...")