Rework workshop first boot#910
Merged
Merged
Conversation
This is something we decided in order to minimize the maintenance overhead of supporting multiple different formats. The cost is that certain operations no longer work: - Restoring a workshop that currently has an old format - Resuming a launch or refresh that began with an old format The first is now a hard error. The second is also a hard error if resuming with `--continue`. If a change was running while the daemon was being updated, then we can still end up with weird workshops that mix two formats. We aim to fix this in the future using a pre-refresh hook. For daemon downgrades, we'll probably require that all workshops are removed first, since old daemons can't anticipate what newer workshops might look like.
c112774 to
4499153
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR reworks Workshop “first boot” behavior for LXD-backed workshops by moving more instance initialization into LXD templates and cloud-init config, adding certificate-based SSH key handling, and bumping the snapshot/format revision to reflect the new behavior.
Changes:
- Add LXD instance templates for hostname, machine-id, networkd drop-ins, and SSH host key/cert/CA material; update LXD backend launch/rebuild paths accordingly.
- Update snapshot/copy behavior and integration tests to match the new format (including format revision bump) and new “unique vs shared” config semantics.
- Add snap hook logic and system rules to keep Workshop/LXD-managed networking devices unmanaged by NetworkManager/systemd-networkd; add an integration task for sshutil coverage.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/integration/ssh/task.yaml | Adds a Spread task to run sshutil integration tests with coverage artifacts. |
| snap/local/lib/85-workshop-unmanaged.rules | Adds udev rule to mark Workshop bridge devices unmanaged. |
| snap/local/lib/85-workshop-unmanaged.conf | Adds NetworkManager unmanaged-device config for veth devices (with eth* exception). |
| snap/local/hooks/remove | Removes installed udev/NM config on snap removal and reloads services. |
| snap/local/hooks/configure | Installs udev/NM config on configure and reloads services. |
| internal/workshop/lxd/tests/integration/workshop_test.go | Updates “include when copying” logic to exclude new SSH key config namespace. |
| internal/workshop/lxd/tests/integration/snapshot-format.yaml | Updates expected snapshot-format metadata/config keys for cloud-init user-data changes. |
| internal/workshop/lxd/tests/integration/snapshot_test.go | Updates snapshot diff expectations (machine-id/SSH key preservation and new network config path). |
| internal/workshop/lxd/templates/workshop.socket.untrusted.tpl | Template plumbing for pre-creating the untrusted socket placeholder. |
| internal/workshop/lxd/templates/ssh_host_ed25519_key.tpl | Adds template for SSH host private key from instance config. |
| internal/workshop/lxd/templates/ssh_host_ed25519_key.pub.tpl | Adds template for SSH host public key from instance config. |
| internal/workshop/lxd/templates/ssh_host_ed25519_key-cert.pub.tpl | Adds template for SSH host certificate from instance config. |
| internal/workshop/lxd/templates/ssh_ca_ed25519_key.pub.tpl | Adds template for SSH user CA public key from instance config. |
| internal/workshop/lxd/templates/machine-id.tpl | Adds template to derive machine-id from volatile UUID without dashes. |
| internal/workshop/lxd/templates/hostname.tpl | Adds template for /etc/hostname based on Workshop name. |
| internal/workshop/lxd/templates/eth0.network.tpl | Adds template for networkd DHCP and domain settings. |
| internal/workshop/lxd/templates/cloud.cfg.tpl | Adds cloud-init config to force networkd renderer/activator and preserve hostname. |
| internal/workshop/lxd/lxd_backend.go | Major rework: embeds templates, installs instance metadata templates, generates/persists SSH CA + per-instance host keys/certs, updates cloud-init user-data key. |
| internal/workshop/lxd/lxd_backend_snapshots.go | Updates snapshot/copy merge semantics, treats new SSH config keys as “unique”, bumps format revision to 5. |
| internal/workshop/lxd/lxd_backend_sdk.go | Adjusts SDK install behavior to only pre-create mount target when instance is running. |
| internal/workshop/fakebackend/backend.go | Adds LaunchOrRebuild callback hook and makes SetFormatRevision restoreable for tests. |
| internal/sshutil/sshutil.go | Introduces SSH key/cert helper package (ed25519 + SSH certificates). |
| internal/sshutil/sshutil_test.go | Adds integration-tagged tests validating generated keys and signed certs via ssh-keygen. |
| internal/overlord/workshopstate/manifest.go | Blocks restore/refresh for workshops with incompatible format vs current backend. |
| internal/overlord/workshopstate/manifest_test.go | Updates tests for restoreable SetFormatRevision and adds restore-format incompatibility test. |
| internal/overlord/workshopstate/manager.go | Exposes backend format revision via WorkshopManager. |
| internal/overlord/workshopstate/handlers.go | Removes explicit creation of workshop run dir on base launch path. |
| internal/overlord/workshopstate/handlers_test.go | Updates cleanup tests to inject errors via LaunchOrRebuild callback instead of WorkshopFs callback. |
| internal/overlord/sdkstate/handlers_test.go | Updates ResumeAfterWait calls to pass format revision. |
| internal/overlord/conflict/conflict.go | Extends ResumeAfterWait to require format compatibility when continuing launch/refresh changes. |
| internal/overlord/conflict/conflict_test.go | Updates ResumeAfterWait tests and adds incompatible-format resume coverage. |
| internal/dirs/dirs.go | Adds WorkshopSSHDir under the root dir for per-user SSH material. |
| internal/daemon/snapshot-ingredients.yaml | Updates snapshot ingredients schema to include the new fake backend callback field. |
| internal/daemon/snapshot-format.yaml | Updates expected snapshot-format outputs (fs-calls and removed run dir). |
| internal/daemon/api_workshops.go | Passes current format revision into ResumeAfterWait when resuming changes. |
It's possible for <NAME>.<PID>.wp to be taken if <PID> matches a project's basename. It's also possible for *.<NAME>-<PID>.wp to be taken, but that doesn't mean <NAME>-<PID>.wp is unavailable. Might as well display it so we can rely on always having a *.wp address. We should also log when falling back to a different domain name.
Now that we remove the NoCloud seed templates, even 20.04 images support the new-style config options.
The current 6/stable LXD snap has reverted some patches to LXCFS which caused canonical/lxd#17983, so this workaround isn't needed. See canonical/lxd-pkg-snap#1236.
With VMs it's difficult to reset the machine-id when taking a snapshot. On the other hand, on first boot the machine-id is set to the SMBIOS UUID, which comes from LXD's volatile.uuid config option. We can do that (in VMs) for subsequent boots by including it in the kernel cmdline. For containers, we might as well match that behaviour. It's easier actually, because we can just use metadata templates. This removes the need to use the MAC address as a DHCP identifier. A workshop will now keep its machine-id from launch until remove.
In order to move away from reinvoking cloud-init when launching or refreshing from a snapshot, we need to move the workshop-specific config to a metadata template (which will be updated on copy, similar to the hostname). It's possible to keep using netplan, but it complains that the instantiated templates have 0644 permissions instead of 0600. The LXD API doesn't provide a way to change this, and we already use some (workshop-agnostic) settings that netplan doesn't support, so I decided to just bypass netplan completely and use systemd-networkd alone. While testing this, I noticed that the hostname systemd-networkd sends over DHCP is immaterial, LXD hard codes it using a config file in /var/snap/lxd/common/lxd/networks/workshopbr0/dnsmasq.hosts/. This actually leads to annoying journal messages because the FQDN is wrong. But luckily dnsmasq ignores it and just looks at the first label, i.e. <WORKSHOP>-<PROJECT>. We can disable this behaviour by setting dns.mode to "dynamic", but I'm holding off on that for now because some weird stuff happened to my network after I enabled it. Prime suspect is NetworkManager thinking it owns the bridge and/or veth devices.
4499153 to
f323597
Compare
f323597 to
fefcc88
Compare
dmitry-lyfar
approved these changes
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
LXD doesn't support SFTP for stopped VMs, and the implementation for containers is buggy, so the aim of this PR is to replace SFTP with other mechanisms, namely cloud-init and metadata templates.
Behaviour changes
The main behaviour change is that we no longer clean up snapshots as if preparing a new base image. It's technically possible to do this by mounting the snapshot's block device inside the still-running workshop, but we managed to find solutions that work a bit better.
To ensure uniqueness, we now set
/etc/machine-idto the LXD config optionvolatile.uuid. Whatever file ends up in the snapshot is irrelevant. A bonus is that themachine-idis now preserved onrefresh, so we can resume using it as the DHCP client identifier.Another problem with skipping cleanup is that cloud-init changes its behaviour based on whether a workshop's instance ID matches the one stored in the snapshot. Since cloud-init is intended for initial VM setup, we decided to just disable it after the first boot (i.e. when launching or refreshing from a base image).
The main thing cloud-init was responsible for on subsequent boots was generating SSH host keys. Workshop is now responsible for this, which means the keys are preserved on
refresh. Even better, we create a signed certificate for each workshop, so users can skip theknown_hostsprompt and enable passwordless login by adding this line to their SSH config:Workshop format changes
Templates are applied before containers start, and very early in the boot for VMs, so we use them to create:
/var/lib/workshop/run: needs to exist before LXD initializes theworkshop.socketproxy device./etc/cloud/cloud.cfg.d/90_workshop.cfg: needs to exist before cloud-init starts; used to stop cloud-init touching the hostname and bypass netplan in favour of systemd-networkd./etc/machine-id: needs to be set before systemd starts (or passed in as a command-line or kernel parameter); for VMs we'll use a different mechanism based on SMBIOS metadata and GRUB scripts.Since cloud-init only runs once now, but LXD templates are applied on instance copy, we also use them to create:
/etc/systemd/network/10-cloud-init-eth0.network.d/workshop.conf: configures workshop-specific networking and settings that cloud-init's network-config doesn't support./etc/ssh/ssh_*_key*: generated on launch, preserved on refresh.For late-boot and workshop-agnostic config, we use cloud-init to:
Minor changes
restoreorlaunch|refresh --continue).dev-42424242.wpinworkshop infoif evendev.42424242.wpis unavailable.user.*LXD options tocloud-init.*.udisks2workaround that used to be required to fix suspend.Self-review quick check
Docs
Procedure:
Content:
tutorial/andhow-to/sections).docs/.coverage.yamlupdated, coverage tags added (.. artefact).Or: