Our container-based integration tests in test/integration/ run against a
stubbed systemctl (test/infra/stub-systemctl.sh installed over
/usr/local/bin/systemctl in test/infra/Dockerfile). The stub accepts
every verb/unit combination and returns 0. That masks an entire class of
bug where code writes the correct unit file but activates the wrong one —
see docs/issues/04-node-install-enables-gateway-unit.md, where a node
install silently "enabled" openclaw-gateway.service in CI and only failed
on real Linode VMs.
Two problems today:
- Low fidelity. Docker containers aren't systemd. User-bus units, linger, cloud-init ordering, and real unit activation aren't exercised.
- Slow feedback on the real thing. Linode apply/destroy cycles cost 2–5 minutes each, plus API quotas and a billed account.
This plan introduces a third integration tier: a local Multipass-based test mode that runs the same manifests against real Ubuntu VMs with real systemd, over the LAN, with no stubs. Close enough to Linode for the code paths we actually care about; fast enough to run on a laptop.
In scope
- A
multipassmachine provider that implementshosting.Provider. - A parallel manifest variant (
manifests/army-local.yml) that uses the existing plain-HTTP control URL path to skip Caddy + TLS. - Optional small enhancement to
mesh/control_url.goto avoid hard-coding the gateway LAN IP in the manifest. - Integration test harness that can drive this tier with the same
claws applyflow used in CI and production.
Out of scope
- Public TLS / Let's Encrypt testing. Plain HTTP is intentional.
- Multi-host / multi-machine testing across physical boxes. All VMs live on one developer host or one CI runner.
- Replacing the Docker tier. The Docker tier stays for fast pure-logic tests (plan graph, cache, CLI). Multipass is additive.
| Property | Multipass | Docker + stub | libvirt/KVM | Linode |
|---|---|---|---|---|
| Real systemd + user bus | yes | no | yes | yes |
| Real cloud-init | yes | no | yes | yes |
| Works on macOS and Linux dev | yes | yes | Linux only | yes |
| Boot time | ~10–30s | <1s | ~10s | ~60s |
| Matches Ubuntu cloud image | yes | no | yes | yes |
| Catches issue-04-class bugs | yes | no | yes | yes |
We pick Multipass over raw libvirt because it runs identically on a Mac
laptop (via Hypervisor.framework) and a Linux CI runner (via KVM), with
one CLI. Our manifests target linode/ubuntu24.04 today, so the
Ubuntu-only limitation isn't a real constraint.
internal/hosting/hosting.go defines:
type Provider interface {
Kind() string
CreateInstance(ctx context.Context, opts CreateInstanceOpts) (*Instance, error)
DeleteInstance(ctx context.Context, resourceID string) error
WaitRunning(ctx context.Context, resourceID string) (*Instance, error)
ListByTag(ctx context.Context, tag string) ([]Instance, error)
}A multipass provider is a peer of the existing Linode provider. The
manifest-level type is already open-ended:
type MachineType string
const (
MachineTypeLinode MachineType = "linode"
MachineTypeSSH MachineType = "ssh"We add MachineTypeMultipass MachineType = "multipass" and register a
hosting.Provider implementation against it.
func (*InstallCaddyStep) Applicable(ctx context.Context, t scaffold.Target) (bool, error) {
mt, ok := t.Payload.(*MeshTarget)
if !ok || !mt.IsGatewayHost {
return false, nil
}
if v, ok := scaffold.PlanCacheGet(ctx, CacheKeyControlURL); ok {
controlURL, _ := v.(string)
return !IsHTTPControlURL(controlURL), nil
}
return !ExpectedControlURLIsHTTP(mt.Gateway), nil
}And resolveControlURL already honors a custom strategy with an explicit
http:// host:
case "custom":
if gw.Networking.PublicHostname == nil || strings.TrimSpace(gw.Networking.PublicHostname.Host) == "" {
return "", fmt.Errorf("networking.public_hostname.host is required when strategy is custom")
}
h := strings.TrimSpace(gw.Networking.PublicHostname.Host)
if strings.HasPrefix(h, "https://") || strings.HasPrefix(h, "http://") {
return h, nil
}
return "https://" + h, nilThis means LAN mode is a manifest shape, not a code fork. The entire apply plan below the networking layer runs identically.
internal/claws/plans/apply/provisioning/resolve.go currently hard-codes
Linode:
func ResolveLinodeInstances(ctx context.Context, provider hosting.Provider, prefix string, targets []scaffold.Target) error {
...
if mt.Spec.Type != manifestdata.MachineTypeLinode {
continue
}
...
instances, err = provider.ListByTag(ctx, prefixTag)We'll either rename/generalize this to ResolveHostedInstances over any
non-SSH provider type, or add a sibling resolver for Multipass. Either way
the contract is: for each non-SSH machine, seed mt.Instance.PublicIPv4
(which for Multipass is the bridge LAN IP) and record it with
scaffold.RecordPlanMachineHost.
A single package shelling out to the multipass CLI. Methods map cleanly:
hosting.Provider |
multipass call |
|---|---|
Kind() |
returns "multipass" |
CreateInstance |
multipass launch --name <label> --cpus N --memory X --disk Y --cloud-init <seed> |
WaitRunning |
multipass info <label> --format json (poll) |
DeleteInstance |
multipass delete <label> --purge |
ListByTag |
multipass list --format json + sidecar tag file |
Notes:
- Tags. Multipass has no tag primitive. Store a tag set per instance in
a sidecar file under
~/.cache/openclaw/multipass/tags/<label>.json.ListByTagenumerates instances, reads sidecars, filters. Cheap, local, no network. - IP.
multipass info --format jsonreturnsipv4. First entry is the bridge IP. That becomesInstance.PublicIPv4. - Cloud-init. Generate a NoCloud seed from
CreateInstanceOpts(PublicKeys,Label,RootPass) that mirrors the Linode equivalent — specifically, SSH-authorized keys for the agent user andsudowithout password. Store the rendered seed per-instance under~/.cache/openclaw/multipass/seeds/<label>.yamlfor postmortem. - Uniqueness. Label =
<prefix>-<machine.name>, matching the Linode convention inmachineLabel().
Extend internal/manifests/data/types.go Machine with optional fields
that only apply when type: multipass:
CPUs int `yaml:"cpus,omitempty"`
Memory string `yaml:"memory,omitempty"` // e.g. "2G"
Disk string `yaml:"disk,omitempty"` // e.g. "10G"And MachineTypeMultipass MachineType = "multipass".
Validate in internal/manifests/service/ that these are only set when
type: multipass (or simply ignored otherwise — keep lenient).
Rename/refactor ResolveLinodeInstances to work over any non-SSH
hosting.Provider, driven by machine.Type. Call sites in
internal/cli/commands/automations/automations.go and
internal/claws/plans/destroy/destroy.go (per the existing grep) need to
pass the right provider.
Mirrors manifests/army.yml with three classes of change:
type: linode→type: multipass; dropsku/region; addcpus/memory/disk.linode_token_env:removed.public_hostname: switch from{ strategy: sslip }to explicit HTTP:
public_hostname:
strategy: custom
host: "http://army-local-gateway-host.local:8080"Everything below (channels, agents, nodes, automations) is byte-identical. See "Gateway host value options" below for alternatives to the mDNS hostname.
To avoid checking a hostname or IP into the manifest, teach
resolveControlURL in mesh/control_url.go to accept http://:PORT (or a
sentinel like http://@lan:PORT) and substitute the gateway machine's
resolved PublicIPv4. Seven-line change, fully backward compatible.
If we ship D5, the manifest becomes truly static:
public_hostname:
strategy: custom
host: "http://:8080"- New build tag:
//go:build integration_multipass(parallel to the existing//go:build integration). - Test lifecycle:
- Preflight: check
multipassis on PATH;multipass version; fail fast with a clear "install Multipass" message if missing. - Launch VMs from
manifests/army-local.ymlviaclaws apply. - Assert on real systemd:
systemctl --user is-enabled openclaw-node,systemctl is-enabled openclaw-gateway, headscale reachable athttp://<gateway-ip>:8080/health, tailscaleStatusreports online. - Teardown:
claws destroy -f manifests/army-local.yml --all --yes, thenmultipass delete --all --purgeas a belt-and-braces cleanup.
- Preflight: check
- No stubs. Do not install
stub-systemctl.shon these VMs. The whole point is real systemd.
GitHub Actions on ubuntu-latest supports KVM now. Add a new job:
integration-multipass:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Multipass
run: sudo snap install multipass
- name: Run Multipass integration tests
run: go test -tags=integration_multipass ./test/integration/...
timeout-minutes: 20Runs in parallel with the existing Docker-based integration job. Failure
in either fails the PR.
The public_hostname.host in army-local.yml has three possible shapes.
Pick one; they're not mutually exclusive — the test harness can choose at
runtime.
A. mDNS hostname (simplest, static manifest).
host: "http://army-local-gateway-host.local:8080". Works on macOS
(Bonjour) and Linux (systemd-resolved with MulticastDNS=yes). May be
blocked in restrictive CI sandboxes.
B. Pre-rendered LAN IP. Harness runs multipass info on the gateway
after provisioning, then envsubst or a tiny templater rewrites the
manifest before claws apply. Works today, no code changes, but adds a
render step and the manifest isn't self-contained.
C. Sentinel + D5 resolver change. host: "http://:8080" resolves at
plan time using the gateway's PublicIPv4. Cleanest long-term; requires
the small change in D5.
Recommendation: ship A first (zero code), add C in a follow-up if mDNS proves flaky in CI.
- provisioning phase — Multipass provider
CreateInstances three VMs:army-local-gateway-host,army-local-dev-host-1,army-local-qa-host-1. Each gets ~30s first boot, <10s subsequent. Seeds SSH keys via cloud-init. Records LAN IPs into the plan cache. - common phase —
install_openclaw.go, agent user setup, etc. Runs identically to Linode. - mesh phase —
install-caddy.Applicablederives the scheme from the manifest (ExpectedControlURLIsHTTP) and returns false → step skipped forhttp://…:8080.install-headscalecalls the on-demandgetOrResolveControlURLhelper (memoised on the plan cache) and binds:8080directly.install-tailscalepulls the control URL + preauth key via the same on-demand helpers and dials--login-server=http://...:8080 --authkey=<preauth>. - channels phase — Telegram bots, no networking changes.
- gateway phase —
openclaw-gateway.servicewritten and enabled via realsystemctl. - node phase —
bootstrap-noderunsopenclaw node installon node VMs. Realsystemctl --user enable openclaw-node.serviceon real systemd. If issue #04 regressed, this fails here, loudly. - automations phase — optional gvm/rust/foundry installers run the same way.
| Aspect | Linode | Multipass LAN | Matters for our bugs? |
|---|---|---|---|
| Hypervisor (KVM / virtio) | real KVM | KVM on Linux, Hypervisor.framework on macOS | no |
| Ubuntu cloud image | Linode tweak of Canonical | Canonical stock | no |
| cloud-init (NoCloud) | yes | yes | yes — ordering bugs |
| systemd (system + user bus) | yes | yes | yes — issue-04 class |
loginctl enable-linger |
needed | needed | yes |
| SSH bootstrap over IP | public IP | LAN IP | no — same code path |
| Headscale wire (after join) | tailnet | tailnet | no — overlay identical |
| TLS on gateway | yes (ACME) | no (HTTP) | no — skipped by design |
| Public internet ingress | yes | no | not tested here |
What we explicitly do not cover: real ACME, real DNS, real cross-region latency, real NAT traversal (DERP). Those stay on the Linode-backed end-to-end suite if we keep one.
- Multipass networking flakiness on macOS. Mitigation: retry
WaitRunningwith bounded backoff; surfacemultipass infooutput on failure for postmortem. - mDNS blocked in CI. Mitigation: option B (pre-render IP) is a one-flag fallback; or implement D5 early.
- Snap dependency on Linux CI.
sudo snap install multipassrequires snapd on the runner.ubuntu-latestships with snapd; if we move to container-based runners later, switch to libvirt directly. - KVM availability in CI. GitHub-hosted
ubuntu-latestenabled nested virt / KVM in 2023. If a runner regresses, Multipass falls back to software emulation (very slow). Mitigation: fail fast in preflight if/dev/kvmis absent on Linux; print an explicit message. - Cleanup on aborted runs. Orphaned VMs waste disk. Mitigation: test
harness labels everything with a run-scoped prefix and always runs
multipass delete --all --purgeon teardown in at.Cleanup.
- M1 — Provider skeleton.
internal/hosting/multipass/withKind(),CreateInstance,DeleteInstance,WaitRunning,ListByTag. Unit tests using a fakeexecwrapper. - M2 — Schema + resolver.
MachineTypeMultipass, optional machine fields, generalizedResolveHostedInstances. Existing Linode tests stay green. - M3 — Manifest + smoke test.
manifests/army-local.ymlplus a minimalintegration_multipasstest that provisions one gateway and one node, assertssystemctlstate, tears down. - M4 — Full apply coverage. Exercise the full
army-local.yml(gateway + two nodes + agents). Assert mesh join, channels, node install. This is where issue-04-class bugs get caught. - M5 — Optional: sentinel control URL (D5). Only if mDNS is flaky.
- M6 — CI wiring. New job in
.github/workflows/release.yml, runs in parallel with the existing Docker integration job.
- Keep the Docker tier. It's fine for pure-logic tests. Not deleting
it; not expanding it. The stub
systemctlis now scoped to "things we know it can't test." - Do not run Linode tests in CI by default. They stay as an opt-in
nightly job (or manual
workflow_dispatch). The Multipass tier catches everything except ACME/DNS/public-internet scenarios. - No multi-distro support in v1. Ubuntu 24.04 only, matching the manifest. Adding Debian/Alma later is a libvirt-tier conversation, not a Multipass one.
internal/hosting/hosting.go— provider interface.internal/hosting/linode/— reference implementation.internal/manifests/data/types.go— machine schema.internal/claws/plans/apply/mesh/control_url.go— control URL derivation, already HTTP-aware.internal/claws/plans/apply/mesh/install_caddy.go— skips itself on HTTP control URLs.internal/claws/plans/apply/provisioning/resolve.go— post-provision IP resolver; needs generalization.docs/issues/04-node-install-enables-gateway-unit.md— the bug class this tier is explicitly designed to catch.test/infra/stub-systemctl.sh,test/infra/Dockerfile— what we're not using for this tier.