Test fixtures were emitting flat walls of t.Logf with every line prefixed
by the same `lb_test.go:61:` (file:line of the call site that the
t.Helper() chain bubbles up to) and forced 6-space indent. Hard to skim,
hard to find where one phase ends and the next begins.
Add three helpers in tests/e2e/harness/log.go:
Phase(t, fmt, ...) — flush-left colored banner via fmt.Println.
Bypasses t.Logf so the line lands without the
testing framework's indent/file:line prefix.
Pure visual structure; the diagnostic detail
stays on t.Logf.
Step(t, fmt, ...) — single-line sub-phase marker via t.Logf.
Keeps file:line attribution so a triager can
find the source of a progress message.
Detail(t, k, v, ...) — key=value resource IDs via t.Logf. Same
attribution rationale as Step — IDs are
diagnostic and want to land in JUnit XML
bucketed with the owning test.
Why the split: Phase is purely a banner; losing JUnit attribution costs
nothing because banners aren't diagnostic. Step/Detail carry triage
context (instance IDs, AMI, etc.) and a failing test's JUnit testcase
node should still contain them, so t.Logf attribution is kept.
ANSI colors are auto-disabled when stdout isn't a TTY and GITHUB_ACTIONS
isn't set, so piped local `go test -v` stays clean.
Apply to lb_test setupSharedFixture — five Phase banners replace the
implicit run of helper logs ("Discovering cluster capacity",
"Ensuring SSH key pair", "Creating shared VPC topology",
"Launching app instances", "Launching probe client") so the live CI log
reads as a story rather than a flat resource dump.
Pretty up the live Actions log without flooding it. Three changes:
1. .github/scripts/ci-fmt.sh — sourceable bash helpers (banner, ok, bad,
warn, note, quiet_run) using ANSI colors that the Actions UI renders.
2. e2e-go.yml + e2e-ddil.yml — tofu phases switch back to quiet redirect
into $TOFU_LOG (the previous tee'd version regressed live noise vs
the original silent redirect). Each phase now prints one colored
ok/✗ line; on failure the tail of $TOFU_LOG dumps inside a single
collapsed group, so the live log stays scannable but the failing tail
is one click away.
3. junit-report render.py — compact summary: short status icons (✅/❌)
in a single column, no separate err column, single-line failure list
(truncated to 120 chars) instead of <details> blocks that wrap badly
on narrow screens.
Per-suite test runs still wrap in ::group:: with an inline cyan banner
so pass output collapses and the suite name is obvious when expanded.
CI run 26016840819 surfaced that ScenarioC_CleanPartition silently
depends on predastore-ddil-hardening §2 (write-quorum with deferred
parity repair). Root cause from node3 daemon log:
predastore: write udp ...->192.168.0.22:9991: sendmsg: operation not permitted
predastore: putObjectViaQUIC: put failed
spx QMP: BLOCK_IO_ERROR instanceId=<witness>
spx: StopOrTerminateInstance: Terminating
spx: Instance state transition running → shutting-down
The 'sendmsg: operation not permitted' is the EPERM Linux returns when
iptables drops an outbound packet — exactly the OUTPUT DROP rules
PartitionNode installs. The witness VM's block I/O is backed by
distributed predastore, so the same partition that severs node3↔peer
NATS traffic also severs the witness disk's cross-shard QUIC writes.
QEMU power-downs the guest after BLOCK_IO_ERROR, the daemon's
reconciler terminates the instance, and the next AssertProgressed dials
a sshd that's already gone (hence the 'handshake failed: EOF' the test
reports).
Quarantine by collapsing C to the same scenarioSkip stub D/E/F already
use, citing predastore-ddil-hardening §2 as the dependency. Flip C
back to ENABLED in the same PR that lands §2 — that's when the witness
disk can survive a peer partition without QEMU halting.
TEST_COVERAGE.md row C flipped ENABLED → SKIPPED; added a quarantine
note explaining the predastore dependency.
Add a reusable composite action (.github/actions/junit-report) that parses
a glob of JUnit XML files, writes a totals table + collapsible per-failure
details into $GITHUB_STEP_SUMMARY, and emits one ::error annotation per
failed/errored test case so failures surface in the run header instead of
being buried in step logs.
Apply the action to both e2e-go.yml and e2e-ddil.yml (e2e-ddil gotestsum
now also emits --junitfile so the action has input).
Same workflows also wrap noisy phases (tofu apply/destroy, scrub-env,
tar-over-ssh, per-suite test runs, cluster-log collection) in
::group::/::endgroup:: markers so the Actions UI collapses the chatter
by default and the human can expand only the failing phase. Tofu output
still tees to $TOFU_LOG so the artifact bundle is unchanged.
Future workflows (multinode, baremetal) can adopt the same pattern via
the README in .github/actions/junit-report.
The daemon currently drops the top-level shortcut AuthorizeSecurityGroupIngress
form silently (mulga-siv-79), so the default SG starts with no inbound :22 and
the witness EIP awaitBaseline tunnel times out under DDIL CI. Mirror the
workaround already present in lb_test by issuing the structured IpPermissions
form before LaunchWitnessVM. Idempotent on InvalidPermission.Duplicate so reruns
in the same cluster are safe.
Step 1.5 of docs/development/improvements/e2e-go-harness.md. Promotes
Cluster/Node/SSH/DaemonClient/LocalStateRevision/ResetAllNodes/Witness
into the shared package so the LB, multinode, and BM scenarios in
steps 4-5 can build on the same primitives instead of duplicating SSH
+ cluster plumbing.
Moves
- ddil/harness/{cluster,ssh,daemon_client,state,reset,witness}.go
-> harness/ (plus the witness testdata embed dir).
- ddil/harness/{fault,netem,profiles,snapshot}.go -> ddil/fault/ as a
new package; fault.PartitionNode/HealNode/KillNATS/StartNATS/
RestartDaemonOnly/ApplyNetem/ClearNetem/LinkProfile*/TakeSnapshot
now take harness.Cluster / harness.Node / harness.SSH explicitly.
- ddil/harness/retry.go::Run retry-with-reset extracted as
harness.RetryWithReset in harness/wait.go; ddil/harness/quarantine.go
keeps the DDIL-only quarantine wrapper that calls into it.
- WaitForMode moved from fault.go to harness/daemon_client.go since
it polls daemon state and has no fault-injection concern.
- The pre-hoist harness/ssh.go (PeerSSH, subprocess ssh) is preserved
as harness/peer_ssh.go so the lb_test suite keeps working unchanged;
the typed x/crypto/ssh client from DDIL now owns harness/ssh.go.
- New harness/util.go exports ShellQuote (was unexported shellQuote in
netem.go) so both ddil/fault and reset.go/witness.go can use it.
Env var rename
- SPINIFEX_NODES / SPINIFEX_SSH_USER / SPINIFEX_SSH_KEY replace
DDIL_NODES / DDIL_SSH_USER / DDIL_SSH_KEY. Old names still honoured
with a stderr deprecation note (envWithLegacy) so the DDIL CI keeps
passing until .github/workflows/e2e-ddil.yml drops the old names.
DDIL_QUARANTINED stays DDIL-specific.
DDIL parity preserved — scenarios still call ddilh.Run for quarantine
semantics; the underlying retry-with-reset is the same code path,
just shared.
Closes mulga-siv-74.
Replaces the source-tree + go + gotestsum payload that the e2e-go.yml
workflow previously shipped to the tofu CI VM with self-contained
test binaries built on the runner via `go test -c`. testdata for the
LB suite is already //go:embed'd into the binaries, so the bundle on
the VM is just `<suite>.test` files (cert: 14M, lb: 17M uncompressed).
Workflow changes
- actions/setup-go on runner.
- New step builds binaries via `make e2e-build SUITES_BUILD=...`.
- Install go-junit-report on runner; convert each VM-side test log to
junit-<suite>.xml after artifact pull so "Publish test summary" still
parses JUnit XML.
- Drop "Install Go + gotestsum on VM" entirely (saves ~60s + network).
- Drop source-shipping rsync/tar (saves ~30s + a lot of bandwidth).
- VM step runs `./<suite>.test -test.v -test.timeout=30m` directly.
Makefile changes
- New `e2e-build` target compiles each `SUITES_BUILD` entry into
`BIN_DIR/<suite>.test` via `go test -c -tags=e2e`. `clean` also
removes BIN_DIR.
Closes mulga-siv-80.
run-cert-e2e.sh single mode probes awsgw.host from spinifex.toml and
adds every non-loopback global IPv4 to the node list. The Go harness
defaulted to 127.0.0.1 only, so CI on a tofu VM (awsgw bound to the
LAN IP, not loopback) saw 'connection refused' on every TLS check.
Port the same discovery: ModeSingle now walks net.InterfaceAddrs for
NodeIPs and grep-parses spinifex.toml for the awsgw bind. Both stay
overridable via SPINIFEX_NODE_IPS / SPINIFEX_SERVICE_IPS.
bootstrap-install.sh ships a minimal binary VM with no rsync; the
ship-source and collect-artifacts steps failed with
'rsync: command not found'. Stream a tarball over ssh instead — same
exclude list, no extra dependency on the VM.
Single-node tofu VM only has the spx binary + bash test bundle at
~/e2e-tests; the Go e2e workflow was running 'make -C tests/e2e e2e-cert'
from $HOME/Development/mulga/spinifex which doesn't exist, producing:
make: *** No rule to make target 'e2e-cert'. Stop.
Add a checkout of the spinifex repo on the runner and rsync it (minus
.git, build/, bin/, node_modules) to the VM as
~/Development/mulga/spinifex before installing Go + gotestsum.
Interim solution; mulga-siv-80 tracks replacing the rsync with shipping
pre-built test binaries from the runner.
gotestsum v1.12.0 pins golang.org/x/tools v0.19.0 which fails to compile
under Go 1.26 with:
invalid array length -delta * delta (constant -256 of type int64)
Switching to @latest pulls a newer x/tools that builds clean.
The tofu-provisioned VM ships with the spx binary and bash test scripts
but no go toolchain, so the prior workflow's "Install gotestsum on VM"
step failed with `bash: line 2: go: command not found`. Add a curl-based
Go 1.26.3 install (matching go.mod) ahead of the gotestsum install and
update the test step's PATH so make e2e-* runs the freshly-installed
go binary.
- Replaces per-suite probe client with a single shared VM launched in the
fixture. Saves ~60s by avoiding a cold boot between the ALB and NLB
internal suites.
- Client cloud-init exposes a JSON trigger endpoint on :9090 in addition
to the http.server on :80 (results dir). Each suite POSTs
{proto, ip, count, outfile, http_port, tcp_port} to run a probe loop
against the LB private IP and writes the result file under /tmp/httpd
for the driver to fetch back.
- configureDefaultSG now uses the structured IpPermissions form (top-level
shortcut is silently ignored by vpcd — tracked separately) and opens
port 9090 in addition to 80 + 9000.
- deleteLB captures the underlying sys.micro VM id via the LB ENI before
delete, then polls DescribeLoadBalancers until LoadBalancerNotFound so
the next suite doesn't race the capacity reclaim.
- Internal_NLB sleeps 15s up front to let the ALB sys.micro deallocate
fully before its own createLB fires. DescribeLoadBalancers can report
the ALB gone before the vCPU/memory allocation is actually released on
capacity-tight dev hosts; without this settle, NLB's createLB trips the
sys.micro reserve and lands in terminal state=failed. Still cheaper
than cold-booting a per-suite client.
Standalone workflow runs the new Go-based E2E harness on the existing
ci-single self-hosted runner — provisions a tofu VM, bootstraps spinifex,
installs gotestsum, runs each requested suite via the e2e Makefile, and
uploads per-test artifacts + JUnit XML + JSON streams as a build artifact.
Test summary parses the JUnit and emits ::error file=...:: annotations so
failures surface in the PR diff.
Gated to the feat/e2e-go-harness branch + workflow_dispatch so it doesn't
interfere with the existing e2e.yml while the Go conversion lands.
tests/e2e/Makefile gains a GOTESTSUM=1 opt-in: when set, the e2e target
runs gotestsum with --junitfile and --jsonfile rooted in ARTIFACT_DIR
under a suite-tagged filename derived from the first PKG component.
Reverts the shared probe-server VM that wouldn't come up reliably during
the InternalFacing suites — back to the proven bash pattern of launching
a fresh client VM per suite (one of ALB_IP / NLB_IP populated based on
the suite kind). Adds renderClientUserData + a conditional template so
each client probes only its target LB.
WaitForLBActive now bails immediately on terminal LB states (failed,
provisioning_failed) and emits a "still waiting" log line every 30s so
hangs are visible instead of silently burning the full 5-minute timeout.
Adds SPINIFEX_RESERVED_VCPU and SPINIFEX_RESERVED_MEM_GB env-var overrides
read at daemon startup. Invalid values are logged and ignored so a typo
doesn't silently widen the reserve. Unblocks the LB E2E on capacity-tight
dev hosts (8 vCPU box: default 2-vCPU reserve + 3x t3.nano test load
leaves 0 free for the sys.micro LB VM). Lays groundwork for the planned
operator-tunable [capacity] config.
bead: mulga-siv-78
Splits TestLoadBalancer into 4 sequential subtests (ALB/NLB ×
internet-facing/internal). Each suite creates its own TG + LB + listener,
runs its traffic test, and tears down before the next starts. Replaces
the per-suite throwaway client VM with a single long-lived probe server
launched at fixture setup; per-suite internal traffic tests GET
/probe?ip=...&proto=... against it instead of cold-booting a fresh client.
Drops peak instance count from 5 to 4 (2 app + 1 LB + 1 client), so the
internal phase passes on capacity-constrained dev nodes that previously
hit InvalidInstanceType (see mulga-siv-76, mulga-siv-77 for the underlying
spinifex bugs).
Adds diagnostics for the LB internal client launch path. Logs the exact
InstanceType, AMI, subnet, key name, and user-data size right before
RunInstances, and registers a daemon-log dump on failure so we can
correlate the 400 InvalidInstanceType with what spinifex saw.
Step 3 of docs/development/improvements/e2e-go-harness.md. Replaces 1246
lines of bash with a typed Go scenario exercising all 4 LB variants
(ALB/NLB × internet-facing/internal) against a shared VPC + app instance
pool. Internet-facing phase auto-skips when no peer node is configured;
internal phase always runs.
Adds shared harness primitives:
- harness/aws.go: AWS SDK client factory wired to Spinifex AWS gateway
- harness/lb.go: WaitForLBActive, WaitForTargetsHealthy, WaitForENICleanup,
WaitForInstanceRunning/Terminated, InstanceENI
- harness/traffic.go: HTTPRoundRobin, TCPRoundRobin, distribution asserts
- harness/ssh.go: minimal peer SSH wrapper shelling out to /usr/bin/ssh
(replaced by typed x/crypto/ssh client hoisted from DDIL in mulga-siv-74)
User-data scripts embedded via //go:embed under lb/testdata/.
Bash script stays in place for one CI cycle before deletion.
Lands step 1 of docs/development/improvements/e2e-go-harness.md:
shared harness primitives (env, wait, artifacts, cert, tls) plus the
TestCertIssuance scenario that supersedes run-cert-e2e.sh. All files
gated by //go:build e2e so default builds and `make preflight` are
unaffected. Bash script stays in place for one CI cycle before deletion.
* feat(vm): direct-boot foundations + config/input extensions + image build pipeline
vm package (mulga-siv-62):
- Add KernelImage, Initrd, KernelCmdline, FwCfg/FwCfgEntry to vm.Config
- Emit -kernel/-initrd/-append/-fw_cfg args when fields set
- Relax drive invariant: accept kernel image in place of drives
- Add devices.go: IsMMIO predicate, NetDevice/BlkDevice/RngDevice helpers
- Refactor lifecycle.go + network_helpers.go call sites to use helpers
- shutdown.go: os.Remove fw_cfg tmpfiles after QEMU exits
- Add devices_test.go, microvm_test.go; extend vm_test.go
config/input extensions (mulga-siv-63):
- Add MicrovmConfig{ELBv2Enabled, ImagePath} to DaemonConfig
- ImagePath falls back to /usr/share/spinifex/microvm when empty
- Add commented [microvm] section to spinifex.toml template
- Add DirectBoot bool + NICs []NICConfig to SystemInstanceInput
image build pipeline (mulga-siv-64):
- scripts/build-microvm-image.sh: Alpine chroot build producing vmlinuz + initramfs.cpio.gz
- build/microvm/init.sh: fw_cfg-driven PID 1 init (mounts, modprobe, NIC config, lb-agent-env, ca-cert)
- build/microvm/inittab: BusyBox init with lb-agent respawn
- make build-microvm-image target
- setup.sh: install microvm/* to /usr/share/spinifex/microvm/
- .gitignore: exclude built vmlinuz + initramfs.cpio.gz artifacts
* feat(daemon): direct-boot launch path for microvm system instances
When SystemInstanceInput.DirectBoot=true:
- Skip GetAMIConfig and GenerateVolumes
- Build vm.Config directly (no buildBaseVMConfig, no PCIe root ports)
- MachineType=microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on
- Resolve kernel/initrd from Microvm.ImagePath config field
- Serialise NICs to three fw_cfg tmpfiles (netcfg, lb-agent-env, ca-cert)
- Validate exactly one NICConfig.IsDefault=true before writing netcfg blob
- Shutdown path cleans up tmpfiles via existing Config.FwCfg iteration
PC-machine path (DirectBoot=false) unchanged.
Closes mulga-siv-65
* feat(elbv2): wire microvm.elbv2_enabled flag into ELBv2 service
When ELBv2Enabled=true:
- Drop lbVMUserData cloud-config; emit KEY=value LBAgentEnv blob instead
- Set SystemInstanceInput.DirectBoot=true; omit ImageID
- Populate NICs []NICConfig (index 0=primary ENI IsDefault, 1=mgmt, 2+=extra ENIs)
- Validate exactly one IsDefault NIC at service layer
When ELBv2Enabled=false: existing PC-machine path unchanged.
Adds buildLBAgentEnv, buildMicrovmNICs, subnetCIDRForIP, subnetGatewayIP helpers.
Tests in arn_test.go cover both flag-on and flag-off paths.
Closes mulga-siv-66
* test(daemon,vm): add unit tests for direct-boot helpers to meet diff coverage threshold
* feat(daemon): Phase 1 perf instrumentation + e2e runner microvm flag
- Log t1→t2 boot timing via slog on the direct-boot path
- measureQEMURSSMiB helper reads /proc/<pid>/status (tested)
- Gate constants: phase1BootGateMs=500, phase1RSSGateMiB=25
- run-lb-e2e.sh: --microvm flag, dual-path CI annotation, density test note
Closes mulga-siv-67
* fix(build): remove busybox-initscripts — not in Alpine 3.20, not needed
* fix(microvm): end-to-end fixes for direct-boot LB path
Serial output was silent (x-option-roms=off suppresses BIOS serial init);
fixed by removing it from the machine string and using -serial chardev:console0
to attach to the machine-created ISA serial device instead of adding a
duplicate isa-serial device (which would create ttyS1 while kernel uses ttyS0).
virtio-net interfaces were invisible to the kernel because QEMU's
auto-kernel-cmdline does not append virtio_mmio.device= params when -append
is used. Fixed by generating explicit virtio_mmio.device=0x200@0xfebNNNNN:N
params for each NIC in KernelCmdline, and modprobing virtio_mmio + virtio_net
in init.sh.
sys.micro bumped to 512 MiB — 128 MiB caused kernel OOM during initramfs
decompression (~68 MiB uncompressed).
BusyBox init ::respawn: entries do not source or export env files; fixed
inittab to use set -a; . /etc/conf.d/lb-agent; set +a before exec.
wireLBAgentConfig was documented as reading the CA cert but never set
elbv2Service.CACert; lb-agent TLS handshakes to AWSGW failed with
x509: certificate signed by unknown authority. Fixed by reading
d.config.NATS.CACert (same local CA that signs the AWSGW server cert)
and assigning to elbv2Service.CACert so writeFwCfgBlobs writes a
non-empty cacert blob into fw_cfg.
Adds vm_test TestExecute_MicrovmFileChardev and updates lifecycle_test
to assert SerialSocket is intentionally empty on direct-boot VMs.
* fix(build): auto-detect docker/podman when apk unavailable for microvm image build
* fix(build): create /dev/console and /dev/null as proper device nodes in initramfs
tar extraction without root silently demotes device files to 0-byte regular
files (mknod requires CAP_MKNOD). The kernel opens /dev/console when wiring
init's stdio to the serial console; a regular file there causes all init output
to be silently discarded to RAM — the VM appears stuck at 'Run /init as init
process' with no subsequent console output and lb-agent never heartbeats.
Fix: after rootfs population, wipe /dev and recreate console (c 5 1) and
null (c 1 3) via sudo mknod. devtmpfs provides all other devices at runtime
once init.sh mounts it.
* fix(microvm): fix mgmt NIC routing for all LB schemes
Two bugs prevented lb-agent from reaching the AWSGW via the mgmt interface:
1. RouteDst/RouteVia swapped — resolveMgmtRoute returns (gateway, target) but
buildMicrovmNICs assigned them as (Dst=gateway, Via=target), producing
'ip route add 10.15.8.1 via 192.168.1.33' instead of the correct
'ip route add 192.168.1.33 via 10.15.8.1'.
2. NIC[1] MAC and CIDR never filled — the daemon allocates mgmt IP/MAC
after buildMicrovmNICs returns, but didn't inject them into input.NICs[1]
before calling writeFwCfgBlobs, leaving NIC1_MAC= empty in the netcfg
blob and skipping interface setup in init.sh entirely.
Also extends mgmt routing to internet-facing scheme in the microvm path:
VPC SNAT is not reliable across all VPC topologies for control-plane
heartbeats; mgmt is always a direct L2 path to the host.
* feat(microvm): Phase 2 kernel stripping + boot optimisation
- cpu: switch to host,-pmu (disable PMU — not needed in microvm guest)
- machine type: make isa-serial configurable via microvm.isa_serial config
field; default true (on) preserves console log path
- build: strip unused kernel modules (USB, IDE, ATA, GPU, media, input,
HID, bluetooth, staging, sound, wireless) from initramfs before cpio
- build: add Phase 2 artifact size gate — fail if vmlinuz + initramfs.cpio.gz
exceeds MICROVM_ARTIFACT_MAX_MiB (default 50 MiB); override via env var
during development
- perf: add phase2BootGateMs=300 and phase2ArtifactGateMiB=50 constants
with canary tests to catch unintentional gate changes
- tests: cover buildMicrovmNICs nil-VPC and extra-ENI paths
- tests: cover microvmMachineType nil/true/false branches
* fix(build): use find-based virtio+fw_cfg module keep-list in initramfs strip
The directory-only keep-list (drivers/virtio, drivers/firmware) missed
virtio_net.ko which lives in drivers/net/, virtio_blk in drivers/block/,
and virtio-rng in drivers/char/hw_random/. Switch to find-based approach:
collect all virtio*.ko* and *fw_cfg*.ko* regardless of subdirectory, then
restore them into their original relative paths under kernel/.
Artifacts now: 42 MiB (vmlinuz 11 MiB + initramfs 31 MiB), gate passes.
* fix(build): keep failover modules in initramfs strip
virtio_net depends on net_failover/failover for live-migration support.
Stripping them left virtio_net with unresolved symbols
(net_failover_create/destroy), so the module failed to load and the
guest came up with only lo. init.sh then skipped every NIC ("no
interface found for MAC ..."), leaving lb-agent with no network path
to AWSGW and the LB stuck in provisioning.
Add *failover*.ko* to the keep-list. Image size unchanged at 42 MiB
(under the 50 MiB Phase 2 gate). ELBv2 e2e now reaches active and
2/2 targets healthy for both ALB and NLB internal LBs.
* fix(daemon): restore slog default after viperblock.New mutates it
viperblock.New unconditionally calls vb.SetDebug, which calls
slog.SetDefault with a TextHandler at LevelError — globally silencing
every slog.Info and slog.Warn in the caller process for the rest of
its life. Discovered while validating microvm Phase 2 (mulga-siv-68):
the daemon stopped emitting Info logs the moment the first customer
VM with volumes launched, hiding direct-boot timing,
LaunchSystemInstance completed, and CreateLoadBalancer completed
lines that we need for boot-time tuning.
Workaround in newViperblock: re-install an Info-level TextHandler on
os.Stderr after each viperblock.New call. Also drop the three explicit
vb.SetDebug(false) calls in prepareRootVolume / prepareEFIVolume /
prepareCloudInitVolume — they were no-ops (viperblock.New already
runs SetDebug internally) and each one re-triggered the global
mutation we are trying to undo.
Proper fix is in viperblock itself — tracked as mulga-siv-70: SetDebug
should not call slog.SetDefault. Until then, this restore call is the
shortest path to getting the daemon's observability back so we can
measure t1_to_t2_ms and tune Phase 2.
Preflight note: golangci-lint not available on this dev host, so the
project preflight target cannot run end-to-end. Verified with
go vet ./spinifex/handlers/ec2/instance/... (clean) and
go test ./spinifex/handlers/ec2/instance/... (pass).
* perf(microvm): hit Phase 2 boot/memory gates
t1→t2 hypervisor: 3224 ms → 264 ms (12×). 128 MiB guest now boots
cleanly without OOM, reverting the temporary 512 MiB bump.
Root causes:
1. build-microvm-image.sh staged the entire chroot into the cpio
without dropping /boot, embedding a duplicate vmlinuz, Alpine's
stock initramfs-virt, System.map, and a "boot → ." symlink loop
(~25 MiB). Decompression peak exceeded 128 MiB. Strip /boot after
the kernel is copied out, before the cpio. Initramfs drops from
31 MiB → 11 MiB (53 MiB → 28 MiB uncompressed); total artifact
42 MiB → 22 MiB.
2. vm/lifecycle.go burned 3 s of fixed sleep on every direct-boot
launch: 2 s nbdkit pre-exec wait (no nbdkit on direct-boot) and
1 s post-fork settle (QEMU binds QMP within ~10 ms when there is
no firmware, ROM, or block layer). Both extracted to
directBoot-aware helpers — nbdkitPreExecWait collapses to 0, and
qemuStartSettleWait to 50 ms.
3. KernelCmdline only carried `console=ttyS0 virtio_mmio.device=…`.
Added boot-time perf flags: quiet loglevel=3 (silence printk over
115200-baud isa-serial), mitigations=off (KVM guest on trusted
host, single-tenant LB workload), tsc=reliable, no_timer_check,
reboot=t, i8042.{nopnp,noaux,nokbd} (microvm has no PS/2).
4. init.sh dropped modprobe virtio_mmio (built-in:
CONFIG_VIRTIO_MMIO=y) and tightened polling: fw_cfg/iface waits go
from 10 × 200 ms (up to 2 s) to 5 × 20 ms (up to 100 ms).
Phase 2 gates:
- t1→t2 ≤ 300 ms ✅ 264 ms / 281 ms
- artifact ≤ 50 MiB ✅ 22 MiB (gate already in build script)
- ELBv2 e2e green ✅ 32/33 (pre-existing client-VM failure,
unrelated to microvm path)
* build(microvm): wire build into deploy + distro + release pipeline
Adds install-microvm Makefile target (with grouped-target staleness
detection on vmlinuz/initramfs.cpio.gz), wires it into dev-install.sh
and reset-dev-env.sh, makes 'make deploy' reinstall artifacts when
present, and makes 'make distro-amd64' stage build/microvm/* into the
release tarball staging dir. arm64 distro stays unchanged (no aarch64
microvm artifact yet — Phase 4).
Release workflow gains a setup-go step and a 'make build-microvm-image'
step before 'make distro-amd64' on the amd64 runner so the resulting
tarball includes the kernel + initramfs without a manual copy.
Precursor for the Phase 3 PC-machine LB removal (mulga-siv-69).
Closes mulga-siv-71.
* build(ansible): include microvm artifacts in build role tarball
Adds a build-microvm-image task to the ansible build role and stages
build/microvm/* into the role's release tarball so the install role's
setup.sh run picks them up via INSTALL_SPINIFEX_TARBALL. Skipped
gracefully when the build script is absent (older branches without
direct-boot LB wiring).
* feat(config): enable microvm direct-boot LB path by default
Uncomments the [nodes.<node>.daemon.microvm] section in the generated
spinifex.toml template and sets elbv2_enabled = true so all new
clusters bring up ELBv2 LBs via the direct-boot microvm path. Operators
can opt back to the legacy PC-machine path by flipping the flag to
false in the rendered config.
Required for end-to-end CI on this branch to exercise the direct-boot
path. Existing clusters' configs are unchanged (the template only
affects fresh 'spx admin init' renders).
* fix(microvm): bootstrap apk.static when no host rootfs tool exists
Self-hosted CI runners and the GitHub-hosted ubuntu-24.04 image often
have none of apk, docker, or podman installed. apk-tools is not in the
Debian 13 (trixie) repositories. Without one of these the rootfs build
fails up front with 'no rootfs build tool available'.
When all three are absent, fetch apk-tools-static from the configured
Alpine mirror, extract it to a tempdir, and prepend it to PATH so the
existing native-apk strategy below picks it up transparently. Requires
only curl + tar, both of which are present on every CI runner that can
already check out source.
No-op when apk, docker, or podman is already present.
Unblocks https://github.com/mulgadc/spinifex/actions/runs/25988830244
* revert: drop apk.static bootstrap in build-microvm-image
Reverts 0bd76b7. Self-hosted runners will get docker.io installed
out of band; existing Strategy 2 (docker run alpine:3.20) handles
the rootfs build without host sudo or unsigned package install.
* docs(workbook): drop lb-alpine import for microvm direct-boot LB
LBs now launch via direct-boot QEMU microvm using kernel + initramfs
bundled at /usr/share/spinifex/microvm/, so the legacy lb-alpine system
AMI import is no longer required.
* fix(daemon): own tap devices by daemon user so QEMU can attach without CAP_NET_ADMIN
When the daemon runs non-root (production: User=spinifex-daemon), child
QEMU processes inherit that uid but lack CAP_NET_ADMIN. The kernel's tun
attach check then requires the tap owner uid to match the caller's euid.
Without the `user` flag, `ip tuntap add` defaults to root:root and qemu's
TUNSETIFF fails with EPERM ("could not configure /dev/net/tun: Operation
not permitted").
Surfaced by the e2e-nightly cell-17 microvm LB launch on the ci-pseudo
runner where the kernel enforces strictly; legacy EC2 launches on
ci-single runners happened to attach successfully despite the same code
path. Adding `user $current group $gid` to `ip tuntap add` ensures the
tap is owned by whatever uid the daemon (and its qemu children) run as.
Root daemons skip the flag — they have CAP_NET_ADMIN ambient.
* fix(e2e): redirect lb probe chatter to stderr so stalls are visible
test_http_from / test_tcp_from echoed progress to stdout, which the
callers captured into *_OK vars via $(...). Result: 6+ minute retry
loops ran silently in CI, and the "$ok" comparison never matched
"true" because the captured value contained the full multi-line
chatter. Reachability pass/fail counters were also lost in the
subshell.
Redirect helper output to stderr, leave only the true/false flag on
stdout, and move pass/fail to the caller so they actually count.
* fix(microvm): don't /32 AdvertiseIP via mgmt for internet-facing single-node
buildMicrovmNICs overrode resolveMgmtRoute's deliberate empty return
for the single-node internet-facing case by falling back to
(MgmtBridgeIP, AdvertiseIP). That installed a /32 route to AdvertiseIP
via the mgmt NIC inside the LB microvm. On single-node where the
AWSGW host IP equals the WAN client IP, the /32 also catches reply
traffic for inbound HTTP/TCP to the LB's floating IP — replies leave
via the mgmt NIC with src=data-NIC IP, the host kernel drops the
asymmetric flow, and same-chassis ingress hangs.
resolveMgmtRoute already encodes the correct policy:
- multi-node any scheme → explicit MgmtRoute{Gateway,Target}
- single-node internal → fallback to (MgmtBridgeIP, AdvertiseIP)
- single-node internet-facing → empty (reach AWSGW via VPC SNAT)
Honor it. Matches the legacy lbVMUserData path, whose own comment
flagged the stolen-WAN-return-path symptom this introduced.
Test covers all three branches.
* feat(ansible): dev-deploy playbook for in-place artifact swap
Fills the gap between ansible-dev-install (skips when already installed)
and ansible-dev-reset (full teardown + reinstall + smoketest). Mirrors
Makefile `deploy` target: rebuild + replace binaries and microvm
artifacts on a running node, restart spinifex.target, wait for awsgw.
Skips setup.sh (no user/dir/systemd-unit changes) and smoketest so
iteration is fast. Use ansible-dev-reset when changing systemd units,
helper scripts, logrotate, or setup.sh itself.
Adds:
- roles/deploy/tasks/main.yml — install spx + nbdkit plugin + microvm
artifacts, with plugindir auto-detection via `nbdkit --dump-config`.
- playbooks/dev-deploy.yml — chains build + deploy roles.
- Makefile ansible-dev-deploy target.
- README playbook table + "when to use which" guidance.
* chore(microvm): remove legacy LB Alpine image build pipeline
Phase 3 microvm removal — strip the PC-machine LB image build
artifacts that direct-boot no longer needs. The microvm initramfs
already carries lb-agent + HAProxy via build-microvm-image, so the
lb.conf manifest, lb-setup.sh chroot script, build-lb-image target,
dev-install / reset-dev-env import steps, and ansible LB-image tasks
all become dead.
build-system-image stays — other manifests (e.g. GPU images) still
use it.
* refactor(microvm): remove legacy PC-machine LB boot path
Phase 3 — direct-boot becomes the only ELBv2 LB launch path.
- Drop SystemInstanceInput.DirectBoot / ImageID / UserData (every LB
caller set DirectBoot=true; legacy cloud-init userdata is gone).
- Drop ELBv2ServiceImpl.lbVMUserData, systemAMI* fields, SetSystemAMIFunc,
getSystemAMI, and the lazy-AMI wiring in daemon.startCluster.
- Collapse CreateLoadBalancer to a single direct-boot launch input.
- Collapse daemon.LaunchSystemInstance: drop AMI validation, ImageID
on RunInstancesInput, UserData base64 encode, GenerateVolumes branch.
buildDirectBootConfig hardcodes /usr/share/spinifex/microvm and
isa-serial=on (microvmMachineType no longer takes a flag).
- Drop MicrovmConfig from config.DaemonConfig and the
[daemon.microvm] section from the spinifex.toml template.
- Prune dead tests (lbVMUserData_*, PCMachine_Unchanged, AMI resolver
paths) and refresh stale comments that still mentioned lbVMUserData.
ARM hosts have no LB launch path until Phase 4 adds direct-boot on
virt — CreateLoadBalancer on aarch64 fails naturally at QEMU exec.
* docs: drop lb-alpine catalog entry from cluster setup guide
LB system instances no longer use an AMI — direct-boot microvm
artifacts ship in the spinifex package.
* feat(ui): remove LB image import banner + LB-AMI gating
Direct-boot microvm artifacts ship in the spinifex package, so the
LB system AMI no longer exists — the "Load balancer image not imported"
banner and the Create-button disable on the LB list / wizard pages
can never fire. Strip:
- lb-image-missing-banner.tsx component
- hasLbImage / isLbImage / LB_IMAGE_NAME / LB_MANAGED_BY_VALUE from
system-managed.ts (isSystemManagedImage stays — still used by the
EC2 Images pages to hide system AMIs)
- Banner gating + ec2ImagesQueryOptions imports from the LB list and
wizard pages
- The corresponding tests that asserted the banner appears
* revert: restore lb.conf + lb-setup.sh for mulga install pipeline
Phase 3 microvm removal (commit 6313d74) deleted these too early — the
mulga install/iso artifact builders (scripts/tofu-cluster/build-install
-artifacts.sh, scripts/tofu-iso/build-iso-artifacts.sh) still invoke
scripts/build-system-image.sh scripts/images/lb.conf, breaking e2e with
"ERROR: Manifest not found: scripts/images/lb.conf".
Restore both files verbatim so the legacy pipeline keeps building until
the mulga-side migration to microvm-only artifacts lands post-merge.
The 5s ec2.start.{lastNode} forward timeout was firing before the target
node finished StartStoppedInstance (cold start = volume rehydrate + QEMU
launch + QMP handshake, ~10-20s on a t3a.nano). The forwarding daemon
then fell back to starting the same instance locally, racing the
still-running launch on the original node. Both attempts derive the same
deterministic tap name from the ENI, so the second one fails with
TUNSETIFF: Device or resource busy.
Surfaced on the pseudo-multinode E2E (run 26006933491) where all three
daemons share one kernel and the collision is unavoidable; real
multi-node hides it because separate kernels can each hold the same tap
name without conflict (but the duplicate Run is still semantically
wrong).
Changes:
* Bump startStoppedForwardTimeout from 5s to 30s so it matches the
awsgw upstream NATS request budget. A slow-but-alive target node
finishes well within this window.
* Distinguish nats.ErrNoResponders (no active subscriber on
ec2.start.{lastNode} — node genuinely down) from a timeout or other
transport error (subscriber existed but didn't respond). Only the
no-responders case justifies a local-fallback Run; everything else
surfaces ServerInternal so the caller retries instead of double-
launching.
* Add an info log on every forward attempt for easier diagnosis.
* feat(predastore): support 2-node distributed mode with RS(1,1)
Lower minimum node threshold from 3 to 2. Emit data=1,parity=1 when
exactly 2 nodes configured — effectively mirroring. 3+ nodes continue
to use RS(2,1).
* fix(ec2): route StartInstances back to original node with node-down fallback
ec2.start used a plain queue group, so NATS could route a restart request to
any daemon — including a capacity-constrained NUC that can't fit a second
instance. This caused InsufficientInstanceCapacity in the Phase 10 stop/start
test on 2-node bare-metal clusters where one node has significantly fewer
resources.
Each daemon now also subscribes to ec2.start.{nodeId} (no queue group). The
queue-group handler peeks at the instance ID, looks up LastNode from the
stopped KV, and forwards to ec2.start.{lastNode}. If the original node does
not respond within 5 seconds (down or partitioned), the handler falls back to
starting locally on whichever node picked up the request — preserving the
existing behaviour for node-failure scenarios.
The direct handler (ec2.start.{nodeId}) always starts locally and never
forwards, preventing routing loops.
* fix(ec2): fall back to local start when original node returns InsufficientInstanceCapacity
The forwarding handler was relaying all responses from the targeted node
unconditionally, including InsufficientInstanceCapacity. This meant that if
the NUC (node2) was the LastNode but had no remaining capacity, the error
propagated to the caller rather than falling back to the local node which may
have room.
Now only success and non-capacity errors are relayed; an InsufficientInstance-
Capacity response from the targeted node triggers a local start attempt, giving
any other node in the cluster a chance to absorb the instance.
* test(e2e): make VPC stop/start restart non-fatal on InsufficientInstanceCapacity
Asymmetric hardware (NUC node2) may not have capacity to restart all
VPC instances. IP-persists-while-stopped is still verified and fails
hard; restart verification is skipped with a warning on capacity errors.
* test(e2e): density-aware capacity guard across bare-metal multi-node suite
Sub-3-node bare-metal clusters with asymmetric/undersized nodes (e.g. a
4-vCPU NUC alongside a 16-vCPU box) can fit only one t3.nano on the
smallest node after the daemon's host reserve. Phases that direct or
indirectly land >1 instance on that node hit InsufficientInstanceCapacity
and previously hard-failed the entire suite.
Add is_density_skip helper and skip_test counter, and wrap every
density-sensitive run-instances / start-instances / wait-for-running call
site (phases 3, 5, 7, 10, 11) so capacity exhaustion on sub-3-node
clusters records a SKIP rather than a FAIL. On 3+ node clusters the
guard short-circuits to false, preserving existing failure semantics.
Summary block now prints Skipped count + names so degraded coverage is
visible in CI logs.
Refs: mulga-siv-72
* test(e2e): parameterise lb SSH key + user via PEER_SSH_KEY / PEER_SSH_USER
Allow run-lb-e2e.sh to run against clusters that don't use tf-user with the
tofu-cluster SSH key (e.g. bare-metal nodes which use spinifex@ + bm-ci key).
Defaults preserve existing behaviour on the tofu-cluster CI path.
Refs: mulga-siv-73
- SSH wait: 60 → 120 attempts; NBD-backed VMs on bare metal take
longer to boot than tofu-cluster VMs with local storage
- Diagnostic console log: add /run/spinifex/console-<id>.log which
is where Spinifex QEMU actually writes serial output
/routez reports outbound routes; with N nodes each node has N-1 routes.
A 2-node cluster has 1 route, not 2. Test was written for 3-node tofu
cluster and failed on 2-node baremetal.
Hardcoded 'for i in 1 2' loops assumed 3+ nodes, accessing NODE_IPS[2]
which doesn't exist on a 2-node cluster. With set -u this is a fatal
unbound variable error. Use seq 1 $((NODE_COUNT-1)) instead.
Phase 8.3 polled VPC instance IPs via two-hop SSH. In single-node macvlan
mode the host has no route to VPC subnets, so SSH always timed out (10 min
of dead retries) even when the VMs were healthy (ALB targets passing health
checks).
Replace with a LaunchTime comparison: the daemon resets LaunchTime to
time.Now() when it relaunches QEMU on recovery, so LaunchTime >= REBOOT_START
is a fast, network-topology-independent reboot confirmation.
GIT_BRANCH was hardcoded to dev, so workflow_dispatch runs ignored the
ref chosen in the UI. Use github.ref_name for dispatch (and any non-cron
trigger), keep dev as the explicit target for scheduled cron runs.
Trixie OVN moved the pid/socket to /var/run/ovn/; older installs
use /var/run/openvswitch/. Probe which exists so the same binary
works on both layouts.
Add SkipFormation bool to firstboot.Config. When set, the cluster
formation block (spx admin init/join) is replaced with a no-op echo,
leaving formation to the provisioning controller.
Wired through: autoinstall reads SPINIFEX_SKIP_FORMATION=1 from env,
install.go passes it to firstboot.Config. spinifex-init.sh parses the
kernel cmdline param and exports it. boot-handler.go hardcodes
SPINIFEX_SKIP_FORMATION=1 so all bare-metal CI nodes skip formation —
bm-bootstrap.sh always runs spx admin init/join with the correct
--nodes N from nodes.json, enabling proper multi-node formation.
Add a step before the E2E run that ensures the Ubuntu 24.04 noble cloud
image is cached at BM_ISO_CACHE. Downloads from ubuntu.com if missing,
prints cached size if present. Passes path via BM_UBUNTU_IMAGE to
bm-bootstrap.sh which SCPs it to the node for --file import.
The --no-nvram flag removed the UEFI NVRAM boot entry, causing firmware to
fall through to PXE on every reboot. Restore original --recheck behaviour
which was confirmed working in manual testing.
grub-probe scans all block devices even without --recheck. On N100 mini-PCs
with eMMC + NVMe, scanning the eMMC causes grub-install to hang. Also skip
NVRAM writes (--no-nvram) since --removable installs to the default EFI path
without needing to register a boot entry in UEFI firmware variables.