feat(elbv2): QEMU microvm direct-boot for LB system instances (#228)

* 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.
This commit is contained in:
Josh Siviour
2026-05-18 15:48:04 +10:00
committed by GitHub
parent 35ad76096b
commit 4ae2a4ac53
59 changed files with 2271 additions and 806 deletions
+8
View File
@@ -31,6 +31,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.26.3'
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
env:
VERSION: ${{ github.ref_name }}
+2
View File
@@ -17,6 +17,8 @@ go.work.sum
# Build outputs
bin/
dist/
build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
!spinifex/services/spinifexui/frontend/dist/
tmp/
*.test
+42 -5
View File
@@ -79,8 +79,25 @@ ifndef IMAGE
endif
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
build-lb-image: ## Build LB Alpine image
$(MAKE) build-system-image IMAGE=lb
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
# Grouped target — script writes both files in one run.
$(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
./scripts/build-microvm-image.sh
# Only triggers when bin/lb-agent is missing; preserves the artifact's mtime so
# build-microvm-image stays correctly stale-aware.
bin/lb-agent:
$(MAKE) build-lb-agent
build-microvm-image: $(MICROVM_ARTIFACTS) ## Build microVM kernel + initramfs (incremental — skips when up to date)
install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
sudo install -d /usr/share/spinifex/microvm
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
go_run:
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
@@ -122,9 +139,18 @@ run:
$(MAKE) go_build
$(MAKE) go_run
# Fast iteration: build + install binary + restart all services
# Fast iteration: build + install binary + restart all services.
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
# input timestamps drive a rebuild only if anything actually changed. On a fresh
# checkout (no build/microvm/vmlinuz yet) the install step is skipped; run
# `make install-microvm` explicitly the first time.
deploy: build
sudo install -m 755 bin/spx /usr/local/bin/spx
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
$(MAKE) install-microvm; \
else \
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
fi
sudo systemctl daemon-reload
sudo systemctl restart spinifex.target
@@ -206,6 +232,14 @@ distro-amd64:
-f build/Dockerfile.distro \
--output type=local,dest=dist/amd64/ \
../
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ]; then \
echo "[distro-amd64] staging microvm artifacts into tarball"; \
mkdir -p dist/amd64/microvm; \
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz dist/amd64/microvm/; \
else \
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
fi
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
@@ -238,9 +272,12 @@ ansible-dev-install:
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-lb-image go_build go_run preflight test test-cover test-race diff-coverage bench run \
ansible-dev-deploy:
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
# already enumerated by the time PID 1 runs — no modprobe needed.
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
modprobe qemu_fw_cfg 2>/dev/null || modprobe fw_cfg_sysfs 2>/dev/null || true
modprobe virtio_net 2>/dev/null || true
for i in 1 2 3 4 5; do
[ -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ] && break
sleep 0.02
done
if [ ! -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ]; then
echo "[init] FATAL: fw_cfg netcfg not available" >&2
exit 1
fi
. /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for attempt in 1 2 3 4 5; do
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
[ -n "$iface" ] && break
sleep 0.02
done
if [ -z "$iface" ]; then
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
continue
fi
eval cidr=\$NIC${n}_CIDR
eval gw=\$NIC${n}_GW
eval default=\$NIC${n}_DEFAULT
eval rdst=\$NIC${n}_ROUTE_DST
eval rvia=\$NIC${n}_ROUTE_VIA
ip link set "$iface" up
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/lb-agent-env/raw /etc/conf.d/lb-agent
mkdir -p /etc/ssl/certs
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw /etc/ssl/certs/ca-certificates.crt
exec /sbin/init
+4
View File
@@ -0,0 +1,4 @@
::sysinit:/etc/rc.local
::respawn:/bin/sh -c 'set -a; . /etc/conf.d/lb-agent; set +a; exec /usr/local/bin/lb-agent'
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
@@ -60,7 +60,6 @@ spx admin images list
NAME | DISTRO | VERSION | ARCH | BOOT
debian-13-arm64 | debian | 13 | arm64 | bios
debian-13-x86_64 | debian | 13 | x86_64 | bios
lb-alpine-3.21.6-x86_64 | alpine | 3.21.6 | x86_64 | bios
ubuntu-26.04-arm64 | ubuntu | 26.04 | arm64 | bios
ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | bios
```
+4 -10
View File
@@ -86,22 +86,16 @@ Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Install Load Balancer AMI
### Step 2. Install Debian AMI
Next install the load balancer AMI images, which is used as the AMI disk image for launching the load-balancer and a requirement.
```bash
spx admin images import --name lb-alpine-3.21.6-x86_64
```
### Step 2.1 Install Debian AMI
Next, install the Debian 13 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
Install the Debian 13 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
```bash
spx admin images import --name debian-13-x86_64
```
> **Note:** The load balancer itself runs as a direct-boot QEMU microvm using the kernel + initramfs bundled with the Spinifex distribution (`/usr/share/spinifex/microvm/`), so no separate LB AMI import is required.
### Step 3. Deploy
```bash
+10 -1
View File
@@ -31,6 +31,7 @@ inventory and roles relative to CWD.
| `playbooks/dev-teardown.yml` | Wipe state only (no reinstall) | `reset-dev-env.sh` §teardown |
| `playbooks/dev-install.yml` | Build + install + init + smoketest on clean box | `dev-install.sh` |
| `playbooks/dev-reset.yml` | Capture settings → teardown → build → install → init → smoketest | `reset-dev-env.sh` (full) |
| `playbooks/dev-deploy.yml` | Rebuild + swap binaries/microvm artifacts + restart (no setup.sh, no smoketest) | `make deploy` |
Upcoming (not yet implemented):
@@ -44,6 +45,7 @@ ansible-playbook playbooks/dev-preflight.yml
ansible-playbook playbooks/dev-teardown.yml
ansible-playbook playbooks/dev-install.yml
ansible-playbook playbooks/dev-reset.yml
ansible-playbook playbooks/dev-deploy.yml
```
Or via `make` (from `spinifex/`):
@@ -53,8 +55,16 @@ make ansible-dev-preflight
make ansible-dev-teardown
make ansible-dev-install
make ansible-dev-reset
make ansible-dev-deploy
```
### When to use which
- First-time / clean box → `ansible-dev-install`
- Iterate on Go code, microvm initramfs, lb-agent → `ansible-dev-deploy` (fast)
- Changed systemd units, helper scripts, logrotate, setup.sh → `ansible-dev-reset` (slow, full rebuild)
- Need a clean slate without reinstall → `ansible-dev-teardown`
## Variable overrides
Defaults in `vars/defaults.yml`. Override per run with `-e`:
@@ -74,7 +84,6 @@ Useful overrides:
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
| `spinifex_external_mode` | `pool` | `pool` \| `nat` external networking |
| `spinifex_build_lb_image` | `true` | Build + import LB system image after init |
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
pool range, gateway, prefix-len, nat gateway_ip from the existing
+14
View File
@@ -0,0 +1,14 @@
---
# In-place artifact swap: rebuilds + replaces binaries/microvm artifacts on
# an already-installed node, then restarts spinifex.target. Use when iterating
# on code without altering systemd units, users, dirs, or running the full
# smoketest. For first-time or unit/script changes use dev-install / dev-reset.
- name: Dev deploy — build + swap binaries + restart (no setup.sh, no smoketest)
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- build
- deploy
+19 -1
View File
@@ -11,6 +11,18 @@
chdir: "{{ mulga_root }}/viperblock"
changed_when: true
- name: build — detect microVM build script (present only on direct-boot branches)
ansible.builtin.stat:
path: "{{ spinifex_root }}/scripts/build-microvm-image.sh"
register: build_microvm_script
- name: build — microVM kernel + initramfs (direct-boot LBs)
ansible.builtin.command:
cmd: make build-microvm-image
chdir: "{{ spinifex_root }}"
changed_when: true
when: build_microvm_script.stat.exists
- name: build — verify build artifacts present
ansible.builtin.stat:
path: "{{ item }}"
@@ -40,9 +52,15 @@
mkdir -p "$STAGING/scripts"
cp "{{ spinifex_root }}/build/scripts/"* "$STAGING/scripts/"
cp "{{ spinifex_root }}/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
# microVM artifacts ship only when the branch built them.
if [ -f "{{ spinifex_root }}/build/microvm/vmlinuz" ] && [ -f "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" ]; then
mkdir -p "$STAGING/microvm"
cp "{{ spinifex_root }}/build/microvm/vmlinuz" "$STAGING/microvm/"
cp "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" "$STAGING/microvm/"
fi
tar czf "{{ build_tarball_path }}" -C "$STAGING" .
changed_when: true
- name: build — summary
ansible.builtin.debug:
msg: "Built spx + nbdkit plugin, tarball at {{ build_tarball_path }}"
msg: "Built spx + nbdkit plugin{{ ' + microvm artifacts' if build_microvm_script.stat.exists else '' }}, tarball at {{ build_tarball_path }}"
+148
View File
@@ -0,0 +1,148 @@
---
# In-place artifact swap for an already-installed spinifex node. Mirrors
# `make deploy`: replace binaries + microvm artifacts, restart services.
# Does NOT run setup.sh (no user/dir/systemd-unit changes) or smoketest.
- name: deploy — fail fast if spinifex isn't already installed
ansible.builtin.stat:
path: "{{ spinifex_config_file }}"
register: deploy_installed_stat
failed_when: not deploy_installed_stat.stat.exists
- name: deploy — assert config present
ansible.builtin.assert:
that: deploy_installed_stat.stat.exists
fail_msg: |
{{ spinifex_config_file }} missing — this playbook only updates an
existing install. Run ansible-dev-install or ansible-dev-reset first.
- name: deploy — verify build artifacts present
ansible.builtin.stat:
path: "{{ item }}"
register: deploy_artifacts
loop:
- "{{ spinifex_root }}/bin/spx"
- "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
- name: deploy — assert artifacts exist after build role
ansible.builtin.assert:
that: item.stat.exists
fail_msg: "Build artifact missing: {{ item.item }} — build role should have produced it"
loop: "{{ deploy_artifacts.results }}"
loop_control:
label: "{{ item.item }}"
- name: deploy — detect microvm artifacts (direct-boot branches only)
ansible.builtin.stat:
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
register: deploy_microvm_stat
loop:
- vmlinuz
- initramfs.cpio.gz
- name: deploy — set microvm presence fact
ansible.builtin.set_fact:
deploy_microvm_present: "{{ deploy_microvm_stat.results | map(attribute='stat.exists') | min }}"
- name: deploy — detect nbdkit plugin directory
ansible.builtin.shell: |
set -e
nbdkit --dump-config | awk -F= '/^plugindir=/{print $2; exit}'
register: deploy_plugindir_cmd
changed_when: false
- name: deploy — set plugin path fact
ansible.builtin.set_fact:
deploy_plugindir: "{{ deploy_plugindir_cmd.stdout | trim }}"
- name: deploy — assert plugindir resolved
ansible.builtin.assert:
that: deploy_plugindir | length > 0
fail_msg: "Could not detect nbdkit plugin directory via `nbdkit --dump-config`"
- name: deploy — stop spinifex.target before binary swap
become: true
ansible.builtin.systemd:
name: spinifex.target
state: stopped
failed_when: false
- name: deploy — install spx binary
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/bin/spx"
dest: /usr/local/bin/spx
mode: "0755"
owner: root
group: root
- name: deploy — install viperblock nbdkit plugin
become: true
ansible.builtin.copy:
src: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
dest: "{{ deploy_plugindir }}/nbdkit-viperblock-plugin.so"
mode: "0755"
owner: root
group: root
- name: deploy — ensure microvm dir
become: true
ansible.builtin.file:
path: /usr/share/spinifex/microvm
state: directory
mode: "0755"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — install microvm kernel
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/build/microvm/vmlinuz"
dest: /usr/share/spinifex/microvm/vmlinuz
mode: "0644"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — install microvm initramfs
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz"
dest: /usr/share/spinifex/microvm/initramfs.cpio.gz
mode: "0644"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — daemon-reload (covers any unit changes shipped via setup.sh later)
become: true
ansible.builtin.systemd:
daemon_reload: true
- name: deploy — start spinifex.target
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: deploy — wait for awsgw to respond
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-regions
register: deploy_awsgw
retries: 15
delay: 2
until: deploy_awsgw.rc == 0
changed_when: false
- name: deploy — summary
ansible.builtin.debug:
msg: |
Deployed:
spx → /usr/local/bin/spx
nbdkit plugin → {{ deploy_plugindir }}/nbdkit-viperblock-plugin.so
microvm → {{ '/usr/share/spinifex/microvm/{vmlinuz,initramfs.cpio.gz}' if deploy_microvm_present else 'skipped (not built on this branch)' }}
spinifex.target restarted; awsgw responding.
-14
View File
@@ -74,19 +74,6 @@
delay: 2
changed_when: false
- name: init — build LB agent
ansible.builtin.command:
cmd: make build-lb-agent
chdir: "{{ spinifex_root }}"
changed_when: true
when: spinifex_build_lb_image | bool
- name: init — import LB system image
ansible.builtin.command:
cmd: "{{ spinifex_root }}/scripts/build-system-image.sh {{ spinifex_lb_image_conf }} --import --quiet"
changed_when: true
when: spinifex_build_lb_image | bool
- name: init — summary
ansible.builtin.debug:
msg: |
@@ -95,4 +82,3 @@
external_mode={{ spinifex_external_mode }}
spinifex.target: started
CA: {{ spinifex_ca_trust_path }}
LB image: {{ 'imported' if spinifex_build_lb_image else 'skipped' }}
-4
View File
@@ -79,10 +79,6 @@ mulga_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../../..') }
# Build output.
build_tarball_path: /tmp/spinifex-local.tar.gz
# LB system image (built + imported at end of init).
spinifex_build_lb_image: true
spinifex_lb_image_conf: "{{ spinifex_root }}/scripts/images/lb.conf"
# Smoketest image — parameterised so tofu examples can share the same base.
spinifex_smoketest_distro: debian
spinifex_smoketest_version: "13"
+261
View File
@@ -0,0 +1,261 @@
#!/bin/bash
# build-microvm-image.sh — build a minimal Alpine-based microVM kernel + initramfs
# for use with QEMU microvm machine type and Spinifex lb-agent.
#
# Outputs:
# $REPO_ROOT/build/microvm/vmlinuz
# $REPO_ROOT/build/microvm/initramfs.cpio.gz
#
# Rootfs strategy (first match wins):
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
# 2. docker available → run alpine container, export filesystem
# 3. podman available → same via podman
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
ALPINE_PACKAGES="busybox linux-virt haproxy iproute2 ca-certificates"
echo "[build-microvm-image] repo root: $REPO_ROOT"
echo "[build-microvm-image] build dir: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# --- Verify non-negotiable tools ---
for tool in cpio gzip find; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
cleanup() {
echo "[build-microvm-image] cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
}
trap cleanup EXIT
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "[build-microvm-image] rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
# Strategy 2/3: container runtime (dev machines)
else
CONTAINER_TOOL=""
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINER_TOOL=docker
elif command -v podman >/dev/null 2>&1; then
CONTAINER_TOOL=podman
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
echo "[build-microvm-image] rootfs: $CONTAINER_TOOL export (alpine:${ALPINE_VERSION})"
cid=$($CONTAINER_TOOL run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$($CONTAINER_TOOL wait "$cid")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
$CONTAINER_TOOL rm "$cid" >/dev/null 2>&1 || true
exit 1
fi
$CONTAINER_TOOL export "$cid" | tar -x -C "$CHROOT_DIR"
$CONTAINER_TOOL rm "$cid" >/dev/null 2>&1
fi
# --- Fix /dev device nodes ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes (mknod requires CAP_MKNOD). The kernel opens /dev/console to wire
# init's stdio to the serial console; a regular file there causes all init
# output to be silently discarded. Recreate the two nodes needed before
# devtmpfs mounts (which provides the rest at runtime).
rm -rf "$CHROOT_DIR/dev"
mkdir "$CHROOT_DIR/dev"
sudo mknod -m 600 "$CHROOT_DIR/dev/console" c 5 1
sudo mknod -m 666 "$CHROOT_DIR/dev/null" c 1 3
# --- Place init script ---
echo "[build-microvm-image] installing init.sh as /init..."
if [ ! -f "$INIT_SH" ]; then
echo "ERROR: $INIT_SH not found — cannot build initramfs" >&2
exit 1
fi
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
# --- Place inittab ---
if [ -f "$INITTAB" ]; then
echo "[build-microvm-image] installing inittab..."
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
fi
# --- Copy lb-agent binary ---
LB_AGENT_BIN="$REPO_ROOT/bin/lb-agent"
if [ -f "$LB_AGENT_BIN" ]; then
echo "[build-microvm-image] copying lb-agent binary..."
mkdir -p "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$LB_AGENT_BIN" "$CHROOT_DIR/usr/local/bin/lb-agent"
else
echo "[build-microvm-image] WARNING: $LB_AGENT_BIN not found — lb-agent will be absent from initramfs" >&2
fi
# --- Create required directories for init ---
mkdir -p \
"$CHROOT_DIR/proc" \
"$CHROOT_DIR/sys" \
"$CHROOT_DIR/dev" \
"$CHROOT_DIR/etc/ssl/certs" \
"$CHROOT_DIR/etc/conf.d"
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "[build-microvm-image] kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "[build-microvm-image] fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "[build-microvm-image] fw_cfg: module found in lib/modules"
fi
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
echo "[build-microvm-image] stripping kernel modules to virtio+fw_cfg only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
# Collect keeper modules and their relative paths from the kernel/ tree.
tmp_keep=$(mktemp -d)
find "$kernel_dir" -name "virtio*.ko.gz" -o -name "virtio*.ko" \
-o -name "*fw_cfg*.ko.gz" -o -name "*fw_cfg*.ko" \
-o -name "*failover*.ko.gz" -o -name "*failover*.ko" \
2>/dev/null | while read -r mod; do
rel="${mod#"$kernel_dir/"}"
dest_dir="$tmp_keep/$(dirname "$rel")"
mkdir -p "$dest_dir"
cp "$mod" "$dest_dir/"
done
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Regenerate module dependency map from the surviving modules.
depmod -b "$CHROOT_DIR" "$kver" 2>/dev/null || true
done
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "[build-microvm-image] stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
echo "[build-microvm-image] stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
"$CHROOT_DIR/usr/local/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$BUILD_DIR/vmlinuz"
echo "[build-microvm-image] copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "$CHROOT_DIR/boot"
# --- Build initramfs ---
INITRAMFS_OUT="$BUILD_DIR/initramfs.cpio.gz"
echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
(
cd "$CHROOT_DIR"
find . | cpio --quiet -o -H newc | gzip -9 > "$INITRAMFS_OUT"
)
# --- Log artifact sizes ---
echo ""
echo "[build-microvm-image] artifacts:"
ls -lh "$VMLINUZ_OUT" "$INITRAMFS_OUT"
echo ""
# --- Phase 2 artifact size gate (50 MiB total) ---
ARTIFACT_MAX_MiB="${MICROVM_ARTIFACT_MAX_MiB:-50}"
total_bytes=$(( $(stat -c %s "$VMLINUZ_OUT") + $(stat -c %s "$INITRAMFS_OUT") ))
max_bytes=$(( ARTIFACT_MAX_MiB * 1024 * 1024 ))
total_mib=$(( (total_bytes + 1048575) / 1048576 ))
if [ "$total_bytes" -gt "$max_bytes" ]; then
echo "ERROR: artifact size ${total_mib} MiB exceeds ${ARTIFACT_MAX_MiB} MiB gate" >&2
echo " Set MICROVM_ARTIFACT_MAX_MiB=N to override during development." >&2
exit 1
fi
echo "[build-microvm-image] artifact size gate: PASS (${total_mib} MiB / ${ARTIFACT_MAX_MiB} MiB)"
echo ""
echo "[build-microvm-image] done."
+2 -3
View File
@@ -91,9 +91,8 @@ if ! id -Gn 2>/dev/null | grep -qw spinifex; then
newgrp spinifex
fi
echo "=== Building and importing LB image ==="
cd "$PROJECT_ROOT" && make build-lb-agent
"$PROJECT_ROOT/scripts/build-system-image.sh" "$PROJECT_ROOT/scripts/images/lb.conf" --import --quiet
echo "=== Building and installing microVM artifacts ==="
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
echo "=== Done ==="
echo "Services: sudo systemctl status spinifex.target"
+3 -4
View File
@@ -268,10 +268,9 @@ sudo systemctl start spinifex.target
# Wait for services to start
sleep 5
# --- Build + import LB image (needs services running) ---
echo "==> Building and importing LB image"
cd "$PROJECT_ROOT" && make build-lb-agent
"$PROJECT_ROOT/scripts/build-system-image.sh" "$PROJECT_ROOT/scripts/images/lb.conf" --import --quiet
# --- Build + install microVM artifacts (kernel + initramfs for direct-boot LBs) ---
echo "==> Building and installing microVM artifacts"
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
# --- Smoke test ---
sudo -u "$INVOKING_USER" "$SCRIPT_DIR/smoke-test.sh"
+7
View File
@@ -346,6 +346,13 @@ install_files() {
$SUDO install -m 0755 "$EXTRACT_DIR/setup.sh" /usr/local/share/spinifex/setup.sh
info " /usr/local/share/spinifex/setup.sh"
fi
# microVM kernel + initramfs
$SUDO install -d /usr/share/spinifex/microvm
if [ -d "$EXTRACT_DIR/microvm" ]; then
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
info " /usr/share/spinifex/microvm/*"
fi
}
# --- Create directories ---
+20 -31
View File
@@ -49,7 +49,6 @@ import (
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/tags"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
@@ -1186,29 +1185,6 @@ func (d *Daemon) startCluster() error {
// Wire system credentials + gateway URL for LB agent SigV4 auth.
d.wireLBAgentConfig()
// Set up lazy system AMI discovery for LB VMs. The image may not exist
// at daemon startup (imported later), so we resolve it at request time.
if d.imageService != nil {
imgSvc := d.imageService
d.elbv2Service.SetSystemAMIFunc(func() (string, error) {
imagesOut, imgErr := imgSvc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{{
Name: aws.String("tag:" + tags.ManagedByKey),
Values: []*string{aws.String(tags.ManagedByELBv2)},
}},
}, utils.GlobalAccountID)
if imgErr != nil {
return "", fmt.Errorf("describe LB system images: %w", imgErr)
}
if len(imagesOut.Images) == 0 {
return "", errors.New("LB system image not imported; run: spx admin images import --name lb-alpine-3.21.6-x86_64")
}
amiID := aws.StringValue(imagesOut.Images[0].ImageId)
slog.Info("System AMI resolved for LB VMs", "amiId", amiID, "name", aws.StringValue(imagesOut.Images[0].Name))
return amiID, nil
})
}
// System VMs (LB, NAT GW) use the dedicated sys.micro instance type.
d.elbv2Service.SetSystemInstanceTypeFunc(func() string {
return "sys.micro"
@@ -2129,21 +2105,34 @@ func (d *Daemon) wireLBAgentConfig() {
"awsgwBindIP", awsgwBindIP, "mgmtBridgeIP", d.mgmtBridgeIP, "advertiseIP", advertiseIP)
}
// Pass mgmt route info so lbVMUserData can add a bootcmd route for
// Pass mgmt route info so buildMicrovmNICs can add a host route for
// internal LBs that reach the AWSGW via the management NIC.
if d.mgmtRouteVia != "" {
d.elbv2Service.MgmtRouteGateway = d.mgmtBridgeIP
d.elbv2Service.MgmtRouteTarget = d.mgmtRouteVia
}
// Always expose mgmtBridgeIP and advertiseIP so lbVMUserData can synthesize
// a mgmt-NIC fallback route for internal-scheme LBs on single-node setups
// (where MgmtRoute{Gateway,Target} stay empty because internet-facing LBs
// reach AWSGW via VPC + EIP SNAT). Internal LBs have no EIP, so without
// this fallback the agent has no return path and the LB stays in
// provisioning forever.
// Always expose mgmtBridgeIP and advertiseIP so buildMicrovmNICs can
// synthesize a mgmt-NIC fallback route for internal-scheme LBs on
// single-node setups (where MgmtRoute{Gateway,Target} stay empty because
// internet-facing LBs reach AWSGW via VPC + EIP SNAT). Internal LBs have
// no EIP, so without this fallback the agent has no return path and the
// LB stays in provisioning forever.
d.elbv2Service.MgmtBridgeIP = d.mgmtBridgeIP
d.elbv2Service.AdvertiseIP = advertiseIP
// Read CA cert so direct-boot microvm guests can verify AWSGW TLS.
// The NATS CA cert signs the AWSGW server cert (same local CA).
if d.config.NATS.CACert != "" {
if caBytes, err := os.ReadFile(d.config.NATS.CACert); err == nil {
d.elbv2Service.CACert = string(caBytes)
slog.Info("CA cert loaded for LB agent TLS", "path", d.config.NATS.CACert)
} else {
slog.Warn("Failed to read CA cert for LB agent TLS", "path", d.config.NATS.CACert, "err", err)
}
} else {
slog.Warn("NATS CACert not configured — direct-boot LB VMs will not verify AWSGW TLS")
}
}
// resolveGPUModel maps a discovered GPU to an instance type model.
+238 -36
View File
@@ -1,9 +1,11 @@
package daemon
import (
"encoding/base64"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
@@ -17,13 +19,11 @@ import (
// Compile-time check that Daemon implements SystemInstanceLauncher.
var _ handlers_elbv2.SystemInstanceLauncher = (*Daemon)(nil)
// LaunchSystemInstance creates and starts a system-managed VM. The VM is owned
// by the system account (GlobalAccountID) and is not visible to customer
// DescribeInstances calls.
//
// This is the internal equivalent of RunInstances — it reuses the same
// instance service, resource manager, volume preparation, and QEMU launch
// path, but skips the NATS request/response envelope and key pair validation.
// LaunchSystemInstance creates and starts a system-managed VM (ELBv2 LB).
// The VM is owned by the system account (GlobalAccountID), is not visible to
// customer DescribeInstances calls, and always boots via direct kernel boot
// (bundled vmlinuz+initramfs, fw_cfg-delivered config). There is no AMI,
// volume, or cloud-init path.
func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput) (*handlers_elbv2.SystemInstanceOutput, error) {
accountID := utils.GlobalAccountID
// ENI account may differ from system account — the ENI is created under
@@ -39,14 +39,6 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
return nil, fmt.Errorf("unknown instance type: %s", input.InstanceType)
}
// Validate AMI
if d.imageService == nil {
return nil, fmt.Errorf("image service not initialized")
}
if _, err := d.imageService.GetAMIConfig(input.ImageID); err != nil {
return nil, fmt.Errorf("AMI %s not found: %w", input.ImageID, err)
}
// Allocate resources
if err := d.resourceMgr.allocate(instanceType); err != nil {
return nil, fmt.Errorf("insufficient capacity for %s: %w", input.InstanceType, err)
@@ -57,7 +49,6 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
// customer-facing listings (Nodes page).
runInput := &ec2.RunInstancesInput{
InstanceType: aws.String(input.InstanceType),
ImageId: aws.String(input.ImageID),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
TagSpecifications: []*ec2.TagSpecification{
@@ -72,10 +63,6 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
if input.SubnetID != "" {
runInput.SubnetId = aws.String(input.SubnetID)
}
if input.UserData != "" {
encoded := base64.StdEncoding.EncodeToString([]byte(input.UserData))
runInput.UserData = aws.String(encoded)
}
// Create VM via instance service
instance, ec2Instance, err := d.instanceService.RunInstance(runInput)
@@ -276,6 +263,14 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
instance.MgmtMAC = generateMgmtMAC(instance.ID)
instance.MgmtIP = mgmtIP
// Inject the allocated MAC and CIDR into NIC[1] so writeFwCfgBlobs
// carries real values — the ELBv2 service leaves them blank because
// MAC/IP are only known after daemon allocation.
if len(input.NICs) > 1 && input.NICs[1].MAC == "" {
input.NICs[1].MAC = instance.MgmtMAC
input.NICs[1].CIDR = instance.MgmtIP + "/24"
}
tapName := vm.MgmtTapName(instance.ID)
tapErr := d.networkPlumber.SetupTap(vm.TapSpec{Name: tapName, Bridge: mgmtBridge})
if tapErr != nil {
@@ -302,30 +297,25 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
}
}
// Prepare volumes (root volume clone from AMI, cloud-init ISO, etc.)
volumeInfos, err := d.instanceService.GenerateVolumes(runInput, instance)
// Direct-boot (microvm) path: build the vm.Config directly with microvm
// machine settings and fw_cfg blobs for network, lb-agent env, and CA cert.
cfg, err := d.buildDirectBootConfig(instance.ID, input)
if err != nil {
d.cleanupFailedSystemInstance(instance, instanceType)
return nil, fmt.Errorf("generate volumes: %w", err)
}
instance.Instance.BlockDeviceMappings = make([]*ec2.InstanceBlockDeviceMapping, 0, len(volumeInfos))
for _, vi := range volumeInfos {
mapping := &ec2.InstanceBlockDeviceMapping{}
mapping.SetDeviceName(vi.DeviceName)
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
mapping.Ebs.SetVolumeId(vi.VolumeId)
mapping.Ebs.SetAttachTime(vi.AttachTime)
mapping.Ebs.SetDeleteOnTermination(vi.DeleteOnTermination)
mapping.Ebs.SetStatus("attached")
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
return nil, fmt.Errorf("build direct-boot config: %w", err)
}
instance.Config = cfg
instance.DirectBoot = true
// Launch QEMU VM
t1 := time.Now()
if err := d.vmMgr.Run(instance); err != nil {
d.cleanupFailedSystemInstance(instance, instanceType)
return nil, fmt.Errorf("launch instance: %w", err)
}
slog.Info("direct-boot timing",
"instanceId", instance.ID,
"t1_to_t2_ms", time.Since(t1).Milliseconds())
slog.Info("LaunchSystemInstance completed",
"instanceId", instance.ID,
@@ -418,3 +408,215 @@ func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration)
}
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
}
// buildDirectBootConfig constructs a vm.Config for a direct-boot microVM
// launch. It does not call buildBaseVMConfig — the microvm machine type
// requires no PCIe root ports, no UEFI/BIOS, and no drives. Runtime paths
// (PID file, console log, serial socket) are filled in later by startQEMU.
//
// Network topology from input.NICs is serialised to three fw_cfg tmpfiles
// (netcfg, lb-agent-env, ca-cert) under utils.RuntimeDir().
func (d *Daemon) buildDirectBootConfig(instanceID string, input *handlers_elbv2.SystemInstanceInput) (vm.Config, error) {
it := d.resourceMgr.instanceTypes[input.InstanceType]
architecture := "x86_64"
if it != nil && it.ProcessorInfo != nil && len(it.ProcessorInfo.SupportedArchitectures) > 0 && it.ProcessorInfo.SupportedArchitectures[0] != nil {
architecture = *it.ProcessorInfo.SupportedArchitectures[0]
}
vcpus := 1
memMiB := 128
if it != nil {
vcpus = int(instanceTypeVCPUs(it))
memMiB = int(instanceTypeMemoryMiB(it))
}
const imagePath = "/usr/share/spinifex/microvm"
fwCfg, err := d.writeFwCfgBlobs(instanceID, input)
if err != nil {
return vm.Config{}, fmt.Errorf("write fw_cfg blobs: %w", err)
}
// Build kernel cmdline.
//
// QEMU microvm maps virtio-mmio slots at 0xfeb00000 + n*0x200 with IRQ 5+n.
// Without virtio_mmio.device params the kernel never discovers the devices
// (auto-kernel-cmdline does not append when -append is specified).
//
// Boot-time perf flags (Phase 2):
// quiet loglevel=3 — suppress printk to ttyS0 (115200 baud serial
// writes dominate boot time; warnings+errors only)
// mitigations=off — skip spectre/meltdown microcode patching at
// boot. Safe here: KVM guest on trusted host CPU,
// single-tenant LB workload, no untrusted code in
// the VM. Saves ~200-500ms.
// tsc=reliable — skip TSC calibration loop on KVM (host already
// vouched for invariant TSC)
// no_timer_check — skip APIC/PIT cross-check; microvm has no PIT
// reboot=t — triple-fault reboot; skip ACPI/keyboard probes
// i8042.no{pnp,aux,kbd} — microvm has no PS/2; skip i8042 probe timeouts
var sb strings.Builder
sb.WriteString("console=ttyS0 quiet loglevel=3 mitigations=off tsc=reliable no_timer_check reboot=t i8042.nopnp i8042.noaux i8042.nokbd")
for i := range input.NICs {
fmt.Fprintf(&sb, " virtio_mmio.device=0x200@0x%x:%d",
0xfeb00000+i*0x200, 5+i)
}
cmdline := sb.String()
machineType := microvmMachineType()
cfg := vm.Config{
Name: instanceID,
EnableKVM: true,
NoGraphic: true,
Architecture: architecture,
CPUType: "host,-pmu",
CPUCount: vcpus,
Memory: memMiB,
MachineType: machineType,
KernelImage: filepath.Join(imagePath, "vmlinuz"),
Initrd: filepath.Join(imagePath, "initramfs.cpio.gz"),
KernelCmdline: cmdline,
FwCfg: fwCfg,
}
// Wire netdevs and devices for each NIC in declaration order.
// Tap device creation happens later in startQEMU (host-side only here).
allNICs := buildNICNetdevs(instanceID, input, machineType)
cfg.NetDevs = append(cfg.NetDevs, allNICs.netdevs...)
cfg.Devices = append(cfg.Devices, allNICs.devices...)
return cfg, nil
}
// nicNetdevResult holds the QEMU netdev and device entries for all NICs.
type nicNetdevResult struct {
netdevs []vm.NetDev
devices []vm.Device
}
// microvmMachineType returns the QEMU -M string for the microvm machine.
// isa-serial is kept on permanently for per-VM console log access — the
// boot-time saving from turning it off is not worth losing console output.
func microvmMachineType() string {
return "microvm,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on"
}
// buildNICNetdevs produces QEMU -netdev and -device entries for each NIC in
// input. The NIC order determines the netdev IDs: net0 = primary ENI,
// net1 = mgmt (if present), net2+ = extra ENIs. Extra ENIs beyond the primary
// are only included when corresponding ExtraENIs entries exist.
func buildNICNetdevs(instanceID string, input *handlers_elbv2.SystemInstanceInput, machineType string) nicNetdevResult {
var res nicNetdevResult
for i, nic := range input.NICs {
netID := fmt.Sprintf("net%d", i)
// Resolve the tap name from the corresponding ENI.
tapName := tapNameForNIC(i, nic, instanceID, input)
res.netdevs = append(res.netdevs, vm.NetDev{
Value: fmt.Sprintf("tap,id=%s,ifname=%s,script=no,downscript=no", netID, tapName),
})
res.devices = append(res.devices, vm.NetDevice(machineType, netID, nic.MAC))
}
return res
}
// tapNameForNIC returns the Linux tap device name for a NIC at the given index.
// Index 0 → primary ENI tap, index 1 → mgmt tap, index 2+ → extra ENI taps.
func tapNameForNIC(idx int, _ handlers_elbv2.NICConfig, instanceID string, input *handlers_elbv2.SystemInstanceInput) string {
switch idx {
case 0:
return vm.TapDeviceName(input.ENIID)
case 1:
return vm.MgmtTapName(instanceID)
default:
extraIdx := idx - 2
if extraIdx < len(input.ExtraENIs) {
return vm.TapDeviceName(input.ExtraENIs[extraIdx].ENIID)
}
return fmt.Sprintf("tap-unknown-%d", idx)
}
}
// writeFwCfgBlobs serialises NIC configuration, lb-agent env, and CA cert to
// per-VM tmpfiles under utils.RuntimeDir(). Returns the fw_cfg entries for the
// three blobs and an error if any write fails or the NIC default invariant is
// violated.
func (d *Daemon) writeFwCfgBlobs(instanceID string, input *handlers_elbv2.SystemInstanceInput) ([]vm.FwCfgEntry, error) {
runtimeDir := utils.RuntimeDir()
netcfgPath := filepath.Join(runtimeDir, fmt.Sprintf("fwcfg-%s-netcfg.tmp", instanceID))
lbenvPath := filepath.Join(runtimeDir, fmt.Sprintf("fwcfg-%s-lbenv.tmp", instanceID))
cacertPath := filepath.Join(runtimeDir, fmt.Sprintf("fwcfg-%s-cacert.tmp", instanceID))
netcfg, err := buildNetcfgBlob(input.NICs)
if err != nil {
return nil, err
}
if err := os.WriteFile(netcfgPath, []byte(netcfg), 0600); err != nil {
return nil, fmt.Errorf("write netcfg blob: %w", err)
}
if err := os.WriteFile(lbenvPath, []byte(input.LBAgentEnv), 0600); err != nil {
_ = os.Remove(netcfgPath)
return nil, fmt.Errorf("write lb-agent-env blob: %w", err)
}
if err := os.WriteFile(cacertPath, []byte(input.CACert), 0600); err != nil {
_ = os.Remove(netcfgPath)
_ = os.Remove(lbenvPath)
return nil, fmt.Errorf("write ca-cert blob: %w", err)
}
slog.Debug("wrote fw_cfg blobs",
"instanceId", instanceID,
"netcfg", netcfgPath,
"lbenv", lbenvPath,
"cacert", cacertPath,
)
return []vm.FwCfgEntry{
{Name: "opt/spinifex/netcfg", File: netcfgPath},
{Name: "opt/spinifex/lb-agent-env", File: lbenvPath},
{Name: "opt/spinifex/ca-cert", File: cacertPath},
}, nil
}
// buildNetcfgBlob serialises a slice of NICConfig entries to the shell KEY=value
// format consumed by the initramfs init script. Exactly one NIC must have
// IsDefault=true; returns an error otherwise.
func buildNetcfgBlob(nics []handlers_elbv2.NICConfig) (string, error) {
defaultCount := 0
for _, n := range nics {
if n.IsDefault {
defaultCount++
}
}
if defaultCount != 1 {
return "", fmt.Errorf("netcfg: exactly one NIC must have IsDefault=true, got %d", defaultCount)
}
var sb strings.Builder
for i, n := range nics {
prefix := fmt.Sprintf("NIC%d", i)
fmt.Fprintf(&sb, "%s_MAC=%s\n", prefix, n.MAC)
if n.CIDR != "" {
fmt.Fprintf(&sb, "%s_CIDR=%s\n", prefix, n.CIDR)
}
if n.Gateway != "" {
fmt.Fprintf(&sb, "%s_GW=%s\n", prefix, n.Gateway)
}
if n.IsDefault {
fmt.Fprintf(&sb, "%s_DEFAULT=1\n", prefix)
} else {
fmt.Fprintf(&sb, "%s_DEFAULT=0\n", prefix)
}
if n.RouteDst != "" {
fmt.Fprintf(&sb, "%s_ROUTE_DST=%s\n", prefix, n.RouteDst)
}
if n.RouteVia != "" {
fmt.Fprintf(&sb, "%s_ROUTE_VIA=%s\n", prefix, n.RouteVia)
}
}
return sb.String(), nil
}
@@ -1,10 +1,16 @@
package daemon
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newDaemonWithVMs(vms ...*vm.VM) *Daemon {
@@ -66,3 +72,240 @@ func TestWaitForSystemInstance_Timeout(t *testing.T) {
t.Fatal("expected timeout error")
}
}
// ---------------------------------------------------------------------------
// buildNetcfgBlob
// ---------------------------------------------------------------------------
func TestBuildNetcfgBlob_NoDefault(t *testing.T) {
nics := []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", CIDR: "10.0.1.5/24", IsDefault: false},
}
_, err := buildNetcfgBlob(nics)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one NIC must have IsDefault=true")
}
func TestBuildNetcfgBlob_TwoDefaults(t *testing.T) {
nics := []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", IsDefault: true},
{MAC: "02:aa:bb:cc:dd:02", IsDefault: true},
}
_, err := buildNetcfgBlob(nics)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one NIC must have IsDefault=true, got 2")
}
func TestBuildNetcfgBlob_SingleNICMinimal(t *testing.T) {
nics := []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", IsDefault: true},
}
got, err := buildNetcfgBlob(nics)
require.NoError(t, err)
assert.Contains(t, got, "NIC0_MAC=02:aa:bb:cc:dd:01\n")
assert.Contains(t, got, "NIC0_DEFAULT=1\n")
// Optional fields absent when empty.
assert.NotContains(t, got, "NIC0_CIDR=")
assert.NotContains(t, got, "NIC0_GW=")
}
func TestBuildNetcfgBlob_FullNIC(t *testing.T) {
nics := []handlers_elbv2.NICConfig{
{
MAC: "02:aa:bb:cc:dd:01",
CIDR: "10.0.1.5/24",
Gateway: "10.0.1.1",
IsDefault: true,
RouteDst: "10.20.0.5/32",
RouteVia: "10.0.1.254",
},
}
got, err := buildNetcfgBlob(nics)
require.NoError(t, err)
assert.Contains(t, got, "NIC0_MAC=02:aa:bb:cc:dd:01\n")
assert.Contains(t, got, "NIC0_CIDR=10.0.1.5/24\n")
assert.Contains(t, got, "NIC0_GW=10.0.1.1\n")
assert.Contains(t, got, "NIC0_DEFAULT=1\n")
assert.Contains(t, got, "NIC0_ROUTE_DST=10.20.0.5/32\n")
assert.Contains(t, got, "NIC0_ROUTE_VIA=10.0.1.254\n")
}
func TestBuildNetcfgBlob_MultiNIC_DefaultFlags(t *testing.T) {
nics := []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", CIDR: "10.0.1.5/24", Gateway: "10.0.1.1", IsDefault: true},
{MAC: "02:aa:bb:cc:dd:02", CIDR: "192.168.100.5/24", IsDefault: false},
}
got, err := buildNetcfgBlob(nics)
require.NoError(t, err)
assert.Contains(t, got, "NIC0_DEFAULT=1\n")
assert.Contains(t, got, "NIC1_DEFAULT=0\n")
assert.Contains(t, got, "NIC1_MAC=02:aa:bb:cc:dd:02\n")
}
// ---------------------------------------------------------------------------
// tapNameForNIC
// ---------------------------------------------------------------------------
func TestTapNameForNIC_PrimaryENI(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{
ENIID: "eni-0123456789abcdef0",
}
got := tapNameForNIC(0, handlers_elbv2.NICConfig{}, "i-abc123", input)
// TapDeviceName strips "eni-" prefix → "tap0123456789abcde" (15 chars max)
assert.True(t, strings.HasPrefix(got, "tap"), "expected tap prefix, got %q", got)
assert.LessOrEqual(t, len(got), 15)
}
func TestTapNameForNIC_MgmtNIC(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{ENIID: "eni-aaa"}
got := tapNameForNIC(1, handlers_elbv2.NICConfig{}, "i-deadbeef", input)
// MgmtTapName strips "i-" prefix → "mgdeadbeef" (10 chars)
assert.True(t, strings.HasPrefix(got, "mg"), "expected mg prefix, got %q", got)
assert.LessOrEqual(t, len(got), 15)
}
func TestTapNameForNIC_ExtraENI(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{
ENIID: "eni-primary",
ExtraENIs: []handlers_elbv2.ExtraENIInput{
{ENIID: "eni-extra0"},
{ENIID: "eni-extra1"},
},
}
got := tapNameForNIC(2, handlers_elbv2.NICConfig{}, "i-inst", input)
assert.True(t, strings.HasPrefix(got, "tap"), "expected tap prefix for extra ENI, got %q", got)
}
func TestTapNameForNIC_ExtraENI_OutOfRange(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{
ENIID: "eni-primary",
ExtraENIs: []handlers_elbv2.ExtraENIInput{},
}
// idx=2 → extraIdx=0 but no ExtraENIs → fallback name
got := tapNameForNIC(2, handlers_elbv2.NICConfig{}, "i-inst", input)
assert.Equal(t, fmt.Sprintf("tap-unknown-%d", 2), got)
}
// ---------------------------------------------------------------------------
// buildNICNetdevs
// ---------------------------------------------------------------------------
func TestBuildNICNetdevs_SingleNIC(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{
ENIID: "eni-000000000000000a",
NICs: []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", IsDefault: true},
},
}
res := buildNICNetdevs("i-test", input, microvmMachineType())
require.Len(t, res.netdevs, 1)
require.Len(t, res.devices, 1)
assert.Contains(t, res.netdevs[0].Value, "tap,id=net0,")
// microvm machine type → virtio-net-device transport
assert.Contains(t, res.devices[0].Value, "virtio-net-device,netdev=net0")
assert.Contains(t, res.devices[0].Value, "02:aa:bb:cc:dd:01")
}
func TestBuildNICNetdevs_TwoNICs(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{
ENIID: "eni-000000000000000b",
NICs: []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", IsDefault: true},
{MAC: "02:aa:bb:cc:dd:02", IsDefault: false},
},
}
res := buildNICNetdevs("i-test2", input, microvmMachineType())
require.Len(t, res.netdevs, 2)
require.Len(t, res.devices, 2)
assert.Contains(t, res.netdevs[0].Value, "id=net0,")
assert.Contains(t, res.netdevs[1].Value, "id=net1,")
assert.Contains(t, res.devices[0].Value, "netdev=net0")
assert.Contains(t, res.devices[1].Value, "netdev=net1")
}
func TestBuildNICNetdevs_EmptyNICs(t *testing.T) {
input := &handlers_elbv2.SystemInstanceInput{ENIID: "eni-empty"}
res := buildNICNetdevs("i-empty", input, microvmMachineType())
assert.Empty(t, res.netdevs)
assert.Empty(t, res.devices)
}
// ---------------------------------------------------------------------------
// microvmMachineType
// ---------------------------------------------------------------------------
func TestMicrovmMachineType(t *testing.T) {
mt := microvmMachineType()
assert.Contains(t, mt, "microvm,")
assert.Contains(t, mt, "isa-serial=on")
}
// ---------------------------------------------------------------------------
// writeFwCfgBlobs
// ---------------------------------------------------------------------------
func TestWriteFwCfgBlobs_HappyPath(t *testing.T) {
// Use a temp dir as runtime dir so files land somewhere writable.
tmpDir := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", tmpDir)
d := &Daemon{}
instanceID := "i-fwcfg-test"
input := &handlers_elbv2.SystemInstanceInput{
NICs: []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", CIDR: "10.0.1.5/24", Gateway: "10.0.1.1", IsDefault: true},
},
LBAgentEnv: "LB_BACKEND=10.0.1.10:8080\n",
CACert: "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n",
}
entries, err := d.writeFwCfgBlobs(instanceID, input)
require.NoError(t, err)
require.Len(t, entries, 3)
// Check names are as expected.
assert.Equal(t, "opt/spinifex/netcfg", entries[0].Name)
assert.Equal(t, "opt/spinifex/lb-agent-env", entries[1].Name)
assert.Equal(t, "opt/spinifex/ca-cert", entries[2].Name)
// Verify files were written and contain expected content.
netcfgData, err := os.ReadFile(entries[0].File)
require.NoError(t, err)
assert.Contains(t, string(netcfgData), "NIC0_MAC=02:aa:bb:cc:dd:01")
lbenvData, err := os.ReadFile(entries[1].File)
require.NoError(t, err)
assert.Equal(t, "LB_BACKEND=10.0.1.10:8080\n", string(lbenvData))
cacertData, err := os.ReadFile(entries[2].File)
require.NoError(t, err)
assert.Contains(t, string(cacertData), "BEGIN CERTIFICATE")
// Cleanup: remove the tmpfiles (proves they are real filesystem files).
for _, e := range entries {
assert.NoError(t, os.Remove(e.File))
}
}
func TestWriteFwCfgBlobs_InvalidNICs_NoDefault(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", tmpDir)
d := &Daemon{}
input := &handlers_elbv2.SystemInstanceInput{
NICs: []handlers_elbv2.NICConfig{
{MAC: "02:aa:bb:cc:dd:01", IsDefault: false},
},
}
_, err := d.writeFwCfgBlobs("i-bad-nics", input)
require.Error(t, err)
assert.Contains(t, err.Error(), "IsDefault")
// No tmpfiles must be left behind.
entries, _ := os.ReadDir(tmpDir)
for _, e := range entries {
assert.False(t, strings.HasPrefix(e.Name(), "fwcfg-i-bad-nics"),
"unexpected tmpfile left behind: %s", e.Name())
}
}
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"net"
"os"
"os/exec"
"os/user"
"sort"
"strings"
@@ -48,7 +49,20 @@ func (p *OVSNetworkPlumber) SetupTap(spec vm.TapSpec) error {
}
}
if out, err := sudoCommand("ip", "tuntap", "add", "dev", spec.Name, "mode", "tap").CombinedOutput(); err != nil {
// When the daemon runs non-root (production: User=spinifex-daemon), child
// QEMU processes inherit that uid without CAP_NET_ADMIN. The kernel's tun
// attach check then requires the tap to be owned by the calling euid
// (drivers/net/tun.c: tun_chr_open + TUNSETIFF perm check). Without the
// `user` flag the tap defaults to root:root and qemu's TUNSETIFF fails
// with EPERM ("could not configure /dev/net/tun: Operation not permitted").
// Root daemons skip this — they have CAP_NET_ADMIN ambient.
addArgs := []string{"tuntap", "add", "dev", spec.Name, "mode", "tap"}
if os.Geteuid() != 0 {
if u, err := user.Current(); err == nil {
addArgs = append(addArgs, "user", u.Username, "group", u.Gid)
}
}
if out, err := sudoCommand("ip", addArgs...).CombinedOutput(); err != nil {
return fmt.Errorf("create tap %s: %s: %w", spec.Name, strings.TrimSpace(string(out)), err)
}
+62
View File
@@ -0,0 +1,62 @@
package daemon
import (
"os"
"testing"
)
// Phase gate constants — changing these is a deliberate decision.
const (
phase1BootGateMs = 500
phase1RSSGateMiB = 25
phase2BootGateMs = 300
phase2ArtifactGateMiB = 50
)
func TestBootGate_Phase1(t *testing.T) {
if phase1BootGateMs != 500 {
t.Errorf("Phase 1 boot gate changed: got %d, want 500", phase1BootGateMs)
}
if phase1RSSGateMiB != 25 {
t.Errorf("Phase 1 RSS gate changed: got %d, want 25", phase1RSSGateMiB)
}
}
func TestBootGate_Phase2(t *testing.T) {
if phase2BootGateMs != 300 {
t.Errorf("Phase 2 boot gate changed: got %d, want 300", phase2BootGateMs)
}
if phase2ArtifactGateMiB != 50 {
t.Errorf("Phase 2 artifact gate changed: got %d, want 50", phase2ArtifactGateMiB)
}
}
func TestMeasureQEMURSSMiB_Self(t *testing.T) {
rss, err := measureQEMURSSMiB(os.Getpid())
if err != nil {
t.Fatalf("measureQEMURSSMiB self: %v", err)
}
if rss <= 0 {
t.Errorf("expected RSS > 0, got %f", rss)
}
if rss > 1024 {
t.Errorf("RSS %f MiB seems unreasonably high for a test process", rss)
}
}
func TestMeasureQEMURSSMiB_InvalidPID(t *testing.T) {
for _, pid := range []int{0, -1, -999} {
_, err := measureQEMURSSMiB(pid)
if err == nil {
t.Errorf("expected error for pid=%d, got nil", pid)
}
}
}
func TestMeasureQEMURSSMiB_NonExistentPID(t *testing.T) {
_, err := measureQEMURSSMiB(999999999)
if err == nil {
t.Error("expected error for non-existent pid, got nil")
}
}
+39
View File
@@ -0,0 +1,39 @@
package daemon
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// measureQEMURSSMiB reads /proc/<pid>/status and returns RSS in MiB.
func measureQEMURSSMiB(pid int) (float64, error) {
if pid <= 0 {
return 0, fmt.Errorf("invalid pid: %d", pid)
}
f, err := os.Open(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return 0, fmt.Errorf("open /proc/%d/status: %w", pid, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "VmRSS:") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
return 0, fmt.Errorf("unexpected VmRSS format: %q", line)
}
kb, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return 0, fmt.Errorf("parse VmRSS kB: %w", err)
}
return kb / 1024, nil
}
return 0, fmt.Errorf("VmRSS not found in /proc/%d/status", pid)
}
@@ -67,5 +67,5 @@ func TestGenerateNetworkConfig_MgmtIPWithoutMAC(t *testing.T) {
assert.NotContains(t, cfg, "mgmt0:", "mgmt NIC should not appear without MAC")
}
// Route for multi-node is handled via bootcmd in lbVMUserData, not
// in the network-config (Alpine cloud-init doesn't support v2 routes).
// Route for multi-node LB mgmt traffic is handled via the fw_cfg netcfg
// blob delivered to the LB microVM, not in the network-config.
+16 -9
View File
@@ -149,8 +149,8 @@ func generateNetworkConfig(eniMAC, devMAC, mgmtMAC, mgmtIP string, extraENIMACs
addresses:
- "%s/24"
`, mgmtMAC, mgmtIP)
// Route for multi-node is added via bootcmd in lbVMUserData (Alpine
// cloud-init does not support v2 routes under ethernets).
// Route for multi-node LB mgmt traffic is delivered to the LB microVM
// via the fw_cfg netcfg blob, not here.
}
return cfg
@@ -877,7 +877,20 @@ func (s *InstanceServiceImpl) newViperblock(volumeName string, size int, volumeC
VolumeConfig: volumeConfig,
}
return viperblock.New(&vbconfig, "s3", cfg)
vb, err := viperblock.New(&vbconfig, "s3", cfg)
restoreSlogDefault()
return vb, err
}
// restoreSlogDefault re-installs the daemon's Info-level slog handler after
// viperblock.New mutates the global slog default via its SetDebug method
// (see viperblock.go SetDebug — it calls slog.SetDefault with LevelError,
// silencing every Info/Warn in the entire process). Tracked for proper fix
// in viperblock as mulga-siv-70.
func restoreSlogDefault() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
}
// prepareRootVolume handles creation/cloning of the root volume
@@ -888,8 +901,6 @@ func (s *InstanceServiceImpl) prepareRootVolume(input *ec2.RunInstancesInput, im
return errors.New(awserrors.ErrorServerInternal)
}
vb.SetDebug(false)
// Initialize the backend
err = vb.Backend.Init()
if err != nil {
@@ -996,8 +1007,6 @@ func (s *InstanceServiceImpl) prepareEFIVolume(imageId string, volumeConfig vipe
return errors.New(awserrors.ErrorServerInternal)
}
efiVb.SetDebug(false)
// Initialize the backend
slog.Debug("Initializing EFI Viperblock store backend")
err = efiVb.Backend.Init()
@@ -1083,8 +1092,6 @@ func (s *InstanceServiceImpl) prepareCloudInitVolume(input *ec2.RunInstancesInpu
return errors.New(awserrors.ErrorServerInternal)
}
cloudInitVb.SetDebug(false)
// Initialize the backend
slog.Debug("Initializing cloud-init Viperblock store backend")
err = cloudInitVb.Backend.Init()
+221 -104
View File
@@ -4,63 +4,16 @@ import (
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// extractAgentEnv parses the cloud-config user-data into the env-var map that
// cloud-init will write to /etc/conf.d/lb-agent. We don't pull in a YAML
// dependency just for tests — the format is stable enough that line-based
// parsing catches the things substring matching misses (misplaced keys,
// broken indentation, content that escaped the agent-conf block).
//
// Returns the parsed env map plus the `runcmd:` section's body (raw lines)
// so callers can assert on the launch command shape.
func extractAgentEnv(t *testing.T, ud string) (envs map[string]string, runcmd []string) {
t.Helper()
require.True(t, strings.HasPrefix(ud, "#cloud-config\n"),
"user-data must start with #cloud-config directive")
envs = map[string]string{}
const agentPathMarker = " - path: /etc/conf.d/lb-agent"
const contentMarker = " content: |"
const envIndent = " " // 6 spaces under `content: |`
lines := strings.Split(ud, "\n")
inAgentEnv := false
inRuncmd := false
for i := 0; i < len(lines); i++ {
line := lines[i]
switch {
case line == agentPathMarker:
require.Less(t, i+1, len(lines), "agent path entry truncated")
require.Equal(t, contentMarker, lines[i+1],
"agent path must be followed by `content: |` block")
inAgentEnv = true
i++ // skip the content marker
case inAgentEnv:
if !strings.HasPrefix(line, envIndent) {
inAgentEnv = false
i-- // re-process this line in case it starts another section
continue
}
kv := strings.TrimPrefix(line, envIndent)
k, v, ok := strings.Cut(kv, "=")
require.True(t, ok, "agent env line missing '=': %q", line)
envs[k] = v
case line == "runcmd:":
inRuncmd = true
case inRuncmd:
if !strings.HasPrefix(line, " ") {
inRuncmd = false
continue
}
runcmd = append(runcmd, line)
}
}
return envs, runcmd
}
func TestBuildLBArn(t *testing.T) {
arn := buildLBArn("us-east-1", "123456789012", "my-alb", "50dc6c495c0c9188", LoadBalancerTypeApplication)
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188", arn)
@@ -86,70 +39,234 @@ func TestBuildListenerArn_NLB(t *testing.T) {
assert.Equal(t, "arn:aws:elasticloadbalancing:eu-west-1:999888777666:listener/net/my-nlb/lbid123/listener456", arn)
}
// TestLbVMUserData_Structural parses the generated cloud-config and asserts
// the structure: agent env vars land in /etc/conf.d/lb-agent, runcmd starts
// lb-agent via OpenRC (not the bare binary), the CA cert section — owned by
// the instance service's cloud-init template — is absent, and no bootcmd is
// emitted in the single-node default.
func TestLbVMUserData_Structural(t *testing.T) {
// TestBuildLBAgentEnv verifies that buildLBAgentEnv produces a KEY=value blob
// containing all five env vars with the correct values.
func TestBuildLBAgentEnv(t *testing.T) {
svc := &ELBv2ServiceImpl{
GatewayURL: "https://192.168.1.33:9999",
GatewayURL: "https://10.0.0.1:9999",
SystemAccessKey: "AKIAIOSFODNN7EXAMPLE",
SystemSecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
region: "ap-southeast-2",
}
ud, err := svc.lbVMUserData("lb-abc123", SchemeInternetFacing)
env := svc.buildLBAgentEnv("lb-abc123")
lines := strings.Split(strings.TrimRight(env, "\n"), "\n")
kvs := make(map[string]string, len(lines))
for _, l := range lines {
k, v, ok := strings.Cut(l, "=")
require.True(t, ok, "env line missing '=': %q", l)
kvs[k] = v
}
assert.Equal(t, "lb-abc123", kvs["LB_LB_ID"])
assert.Equal(t, "https://10.0.0.1:9999", kvs["LB_GATEWAY_URL"])
assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", kvs["LB_ACCESS_KEY"])
assert.Equal(t, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", kvs["LB_SECRET_KEY"])
assert.Equal(t, "ap-southeast-2", kvs["LB_REGION"])
}
// TestSubnetCIDRForIP and TestSubnetGatewayIP cover the CIDR helpers.
func TestSubnetCIDRForIP(t *testing.T) {
assert.Equal(t, "10.0.1.5/24", subnetCIDRForIP("10.0.1.5", "10.0.1.0/24"))
assert.Equal(t, "172.16.2.10/20", subnetCIDRForIP("172.16.2.10", "172.16.0.0/20"))
assert.Equal(t, "", subnetCIDRForIP("10.0.1.5", "bad-cidr"))
}
func TestSubnetGatewayIP(t *testing.T) {
assert.Equal(t, "10.0.1.1", subnetGatewayIP("10.0.1.0/24"))
assert.Equal(t, "172.16.0.1", subnetGatewayIP("172.16.0.0/20"))
assert.Equal(t, "", subnetGatewayIP("not-a-cidr"))
}
// setupMicrovmTestService creates an ELBv2 service wired to a real VPC
// service. The VPC has one subnet (10.0.1.0/24) pre-created.
func setupMicrovmTestService(t *testing.T) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl, string) {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
testutil.StubVpcdSGResponder(t, nc)
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
require.NoError(t, err)
envs, runcmd := extractAgentEnv(t, ud)
assert.Equal(t, map[string]string{
"LB_LB_ID": "lb-abc123",
"LB_GATEWAY_URL": "https://192.168.1.33:9999",
"LB_ACCESS_KEY": "AKIAIOSFODNN7EXAMPLE",
"LB_SECRET_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"LB_REGION": "ap-southeast-2",
}, envs)
for k := range envs {
assert.NotContains(t, k, "NATS", "NATS URL must not leak into agent config")
cfg := &config.Config{
Daemon: config.DaemonConfig{
DevNetworking: true,
},
}
elbv2Svc, err := NewELBv2ServiceImplWithNATS(cfg, nc)
require.NoError(t, err)
elbv2Svc.VPCService = vpcSvc
elbv2Svc.GatewayURL = "https://10.20.0.5:9999"
elbv2Svc.SystemAccessKey = "AKID"
elbv2Svc.SystemSecretKey = "SECRET"
elbv2Svc.region = "us-east-1"
elbv2Svc.CACert = "-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n"
// Agent is launched via OpenRC, never as a bare binary path.
require.Len(t, runcmd, 1)
assert.Contains(t, runcmd[0], `[ "rc-service", "lb-agent", "start" ]`)
assert.NotContains(t, ud, "/usr/local/bin/lb-agent")
vpcOut, err := vpcSvc.CreateVpc(&ec2.CreateVpcInput{
CidrBlock: aws.String("10.0.0.0/16"),
}, testAccountID)
require.NoError(t, err)
vpcID := *vpcOut.Vpc.VpcId
// CA cert is injected upstream by the instance service's template.
assert.NotContains(t, ud, "ca_certs:")
subnetOut, err := vpcSvc.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: aws.String(vpcID),
CidrBlock: aws.String("10.0.1.0/24"),
AvailabilityZone: aws.String("us-east-1a"),
}, testAccountID)
require.NoError(t, err)
subnetID := *subnetOut.Subnet.SubnetId
// Single-node default: no MgmtRoute set means no bootcmd.
assert.NotContains(t, ud, "bootcmd:")
return elbv2Svc, vpcSvc, subnetID
}
// TestLbVMUserData_MissingCredentials covers the three required-field error
// paths (gateway URL, access key, secret key) in one table-driven test.
func TestLbVMUserData_MissingCredentials(t *testing.T) {
tests := []struct {
name string
svc *ELBv2ServiceImpl
}{
{
name: "missing GatewayURL",
svc: &ELBv2ServiceImpl{SystemAccessKey: "AKID", SystemSecretKey: "SECRET"},
},
{
name: "missing SystemAccessKey",
svc: &ELBv2ServiceImpl{GatewayURL: "https://10.0.0.1:9999", SystemSecretKey: "SECRET"},
},
{
name: "missing SystemSecretKey",
svc: &ELBv2ServiceImpl{GatewayURL: "https://10.0.0.1:9999", SystemAccessKey: "AKID"},
// TestCreateLoadBalancer_DeliversCACert asserts the CACert plumbed into the
// service is forwarded to the launcher for fw_cfg delivery.
func TestCreateLoadBalancer_DeliversCACert(t *testing.T) {
svc, _, subnetID := setupMicrovmTestService(t)
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{
InstanceID: "i-microvm-test",
PrivateIP: "10.0.1.5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.svc.lbVMUserData("lb-test", SchemeInternetFacing)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing system credentials")
})
}
svc.InstanceLauncher = mock
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("mv-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, mock.launchCalls, 1)
assert.Equal(t, "-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n",
mock.launchCalls[0].CACert)
}
// TestCreateLoadBalancer_Microvm_LBAgentEnv asserts all five lb-agent env vars
// appear in LBAgentEnv with correct values.
func TestCreateLoadBalancer_Microvm_LBAgentEnv(t *testing.T) {
svc, _, subnetID := setupMicrovmTestService(t)
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{InstanceID: "i-env-test", PrivateIP: "10.0.1.6"},
}
svc.InstanceLauncher = mock
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("env-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, mock.launchCalls, 1)
envBlob := mock.launchCalls[0].LBAgentEnv
kvs := parseEnvBlob(t, envBlob)
assert.NotEmpty(t, kvs["LB_LB_ID"])
assert.Equal(t, "https://10.20.0.5:9999", kvs["LB_GATEWAY_URL"])
assert.Equal(t, "AKID", kvs["LB_ACCESS_KEY"])
assert.Equal(t, "SECRET", kvs["LB_SECRET_KEY"])
assert.Equal(t, "us-east-1", kvs["LB_REGION"])
}
// TestCreateLoadBalancer_Microvm_NICs asserts NIC[0].IsDefault=true and
// NIC[1].IsDefault=false, and that CIDR/Gateway are derived from the subnet.
func TestCreateLoadBalancer_Microvm_NICs(t *testing.T) {
svc, _, subnetID := setupMicrovmTestService(t)
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{InstanceID: "i-nic-test", PrivateIP: "10.0.1.7"},
}
svc.InstanceLauncher = mock
_, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("nic-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, mock.launchCalls, 1)
nics := mock.launchCalls[0].NICs
require.GreaterOrEqual(t, len(nics), 2, "must have at least primary ENI NIC and mgmt NIC")
// NIC[0]: primary VPC ENI — must be the default route owner.
assert.True(t, nics[0].IsDefault, "NIC[0] must have IsDefault=true")
assert.NotEmpty(t, nics[0].MAC, "NIC[0] MAC must be populated from ENI")
assert.Contains(t, nics[0].CIDR, "/24", "NIC[0] CIDR must include prefix from subnet")
assert.Equal(t, "10.0.1.1", nics[0].Gateway, "NIC[0] gateway must be subnet .1 address")
// NIC[1]: management NIC — daemon allocates IP/MAC.
assert.False(t, nics[1].IsDefault, "NIC[1] must have IsDefault=false")
}
// parseEnvBlob parses a KEY=value newline-separated blob into a map.
func parseEnvBlob(t *testing.T, blob string) map[string]string {
t.Helper()
kvs := make(map[string]string)
for line := range strings.SplitSeq(strings.TrimRight(blob, "\n"), "\n") {
if line == "" {
continue
}
k, v, ok := strings.Cut(line, "=")
require.True(t, ok, "env blob line missing '=': %q", line)
kvs[k] = v
}
return kvs
}
// TestBuildMicrovmNICs_NilVPC verifies buildMicrovmNICs works when VPCService
// is nil — CIDR and gateway remain empty, NIC structure is still correct.
func TestBuildMicrovmNICs_NilVPC(t *testing.T) {
svc := &ELBv2ServiceImpl{VPCService: nil}
nics := svc.buildMicrovmNICs("10.0.1.5", "02:aa:bb:cc:dd:01", "subnet-abc", "eni-abc", SchemeInternal, nil, testAccountID)
require.Len(t, nics, 2, "primary ENI NIC + mgmt NIC")
assert.True(t, nics[0].IsDefault)
assert.Equal(t, "02:aa:bb:cc:dd:01", nics[0].MAC)
assert.Empty(t, nics[0].CIDR, "CIDR unknown when VPC service unavailable")
assert.False(t, nics[1].IsDefault)
}
// TestBuildMicrovmNICs_ExtraENIs verifies NIC[2+] are appended for multi-subnet ALBs.
func TestBuildMicrovmNICs_ExtraENIs(t *testing.T) {
svc := &ELBv2ServiceImpl{VPCService: nil}
extras := []ExtraENIInput{
{ENIID: "eni-extra1", ENIMac: "02:aa:bb:cc:dd:02", ENIIP: "10.0.2.5", SubnetID: "subnet-extra1"},
}
nics := svc.buildMicrovmNICs("10.0.1.5", "02:aa:bb:cc:dd:01", "subnet-abc", "eni-abc", SchemeInternal, extras, testAccountID)
require.Len(t, nics, 3, "primary ENI + mgmt + 1 extra")
assert.Equal(t, "02:aa:bb:cc:dd:02", nics[2].MAC)
assert.False(t, nics[2].IsDefault)
}
// TestBuildMicrovmNICs_MgmtRoute covers the same single-node/multi-node matrix
// as TestLBVMUserData_InternalSingleNodeFallback: internet-facing single-node
// must NOT emit a /32 via mgmt to AdvertiseIP because that steals the host's
// WAN return path for reply traffic, breaking same-chassis ingress.
func TestBuildMicrovmNICs_MgmtRoute(t *testing.T) {
mk := func() *ELBv2ServiceImpl {
return &ELBv2ServiceImpl{
VPCService: nil,
MgmtBridgeIP: "10.15.8.1",
AdvertiseIP: "192.168.1.33",
}
}
internal := mk().buildMicrovmNICs("10.0.1.5", "02:aa:bb:cc:dd:01", "subnet-abc", "eni-abc", SchemeInternal, nil, testAccountID)
require.Len(t, internal, 2)
assert.Equal(t, "192.168.1.33", internal[1].RouteDst, "internal single-node forces /32 via mgmt")
assert.Equal(t, "10.15.8.1", internal[1].RouteVia)
inet := mk().buildMicrovmNICs("10.0.1.5", "02:aa:bb:cc:dd:01", "subnet-abc", "eni-abc", SchemeInternetFacing, nil, testAccountID)
require.Len(t, inet, 2)
assert.Empty(t, inet[1].RouteDst, "internet-facing single-node must not /32 AdvertiseIP via mgmt")
assert.Empty(t, inet[1].RouteVia)
multi := mk()
multi.MgmtRouteGateway = "10.15.8.1"
multi.MgmtRouteTarget = "10.15.8.100"
nics := multi.buildMicrovmNICs("10.0.1.5", "02:aa:bb:cc:dd:01", "subnet-abc", "eni-abc", SchemeInternetFacing, nil, testAccountID)
require.Len(t, nics, 2)
assert.Equal(t, "10.15.8.100", nics[1].RouteDst, "multi-node uses explicit MgmtRouteTarget")
assert.Equal(t, "10.15.8.1", nics[1].RouteVia)
}
+28 -6
View File
@@ -19,15 +19,15 @@ type SystemInstanceLauncher interface {
TerminateSystemInstance(instanceID string) error
}
// SystemInstanceInput describes the VM to launch.
// SystemInstanceInput describes the direct-boot microVM to launch for an
// ELBv2 load balancer. There is no AMI or cloud-init path — kernel and
// initrd come from the bundled spinifex package and per-VM config is
// delivered via QEMU fw_cfg blobs.
type SystemInstanceInput struct {
InstanceType string // e.g. "t3.nano"
ImageID string // AMI ID
InstanceType string // e.g. "sys.micro"
SubnetID string // VPC subnet for networking
UserData string // Cloud-init user data (plain text, will be base64-encoded)
// ENI fields — if ENIID is set, the VM uses this pre-created ENI instead
// of auto-creating one. Used for ALB VMs that reuse the ALB's ENI.
// ENI fields — the VM always uses a pre-created ENI (the ALB's primary ENI).
ENIID string
ENIMac string
ENIIP string
@@ -49,6 +49,17 @@ type SystemInstanceInput struct {
// via the QEMU user-mode dev NIC (dev_networking mode only).
// Each entry is a guest port (e.g. 80, 443). The host port is auto-assigned.
HostfwdPorts []int
// NICs defines the network interfaces for the microVM.
// Index 0 is the primary VPC ENI, index 1 is the management NIC, index 2+ are extra ENIs.
NICs []NICConfig
// LBAgentEnv is a KEY=value blob written to /etc/conf.d/lb-agent inside
// the guest via fw_cfg.
LBAgentEnv string
// CACert holds PEM-encoded CA certificate bytes delivered to the guest via fw_cfg.
CACert string
}
// ExtraENIInput describes an additional pre-created ENI to attach to a
@@ -60,6 +71,17 @@ type ExtraENIInput struct {
SubnetID string
}
// NICConfig describes a single network interface for a microVM direct-boot launch.
// Index 0 is the primary VPC ENI, index 1 is the management NIC, index 2+ are extra ENIs.
type NICConfig struct {
MAC string // e.g. "02:0a:01:23:45:67"
CIDR string // e.g. "10.0.1.5/24"
Gateway string // e.g. "10.0.1.1"; empty for mgmt NIC
IsDefault bool // true for exactly one NIC (primary VPC ENI)
RouteDst string // specific host route dst, e.g. "10.20.0.5/32"
RouteVia string // next-hop for RouteDst
}
// SystemInstanceOutput contains the result of a successful launch.
type SystemInstanceOutput struct {
InstanceID string // e.g. "i-xxxxx"
+125 -95
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"maps"
"net"
"regexp"
"slices"
"strings"
@@ -64,45 +65,10 @@ func validateHealthCheckMatcher(m string) error {
return nil
}
// lbVMUserData generates cloud-config user data for a load balancer VM.
// Uses write_files to populate /etc/conf.d/lb-agent with the lb-id, gateway
// URL, and system credentials for SigV4 auth. The CA cert is already injected
// by the instance service's cloud-init template (same as regular EC2 VMs).
// Cloud-init guarantees write_files runs before runcmd. The service is NOT
// enabled at boot in the image — cloud-init is the sole trigger so the env
// vars are always present before the agent starts.
func (s *ELBv2ServiceImpl) lbVMUserData(lbID, scheme string) (string, error) {
if s.GatewayURL == "" || s.SystemAccessKey == "" || s.SystemSecretKey == "" {
return "", fmt.Errorf("missing system credentials: gatewayURL=%q accessKey=%q secretKey-set=%t",
s.GatewayURL, s.SystemAccessKey, s.SystemSecretKey != "")
}
cfg := fmt.Sprintf(`#cloud-config
write_files:
- path: /etc/conf.d/lb-agent
content: |
LB_LB_ID=%s
LB_GATEWAY_URL=%s
LB_ACCESS_KEY=%s
LB_SECRET_KEY=%s
LB_REGION=%s
`, lbID, s.GatewayURL, s.SystemAccessKey, s.SystemSecretKey, s.region)
mgmtGW, mgmtTarget := s.resolveMgmtRoute(scheme)
if mgmtGW != "" && mgmtTarget != "" {
cfg += fmt.Sprintf(`bootcmd:
- [ "ip", "route", "add", "%s/32", "via", "%s" ]
`, mgmtTarget, mgmtGW)
}
cfg += `runcmd:
- [ "rc-service", "lb-agent", "start" ]
`
return cfg, nil
}
// resolveMgmtRoute returns the (gateway, target) pair for the bootcmd host
// route the lb-agent uses to reach AWSGW, or empty strings to skip the route.
// resolveMgmtRoute returns the (gateway, target) pair for the host route the
// lb-agent uses to reach AWSGW from inside the LB microVM, or empty strings
// to skip the route. Consumed by buildMicrovmNICs to populate NIC[1]'s
// RouteDst/RouteVia for the netcfg fw_cfg blob.
//
// Multi-node (AWSGW on dedicated mgmt IP): MgmtRoute{Gateway,Target} are set
// by the daemon for both schemes — return them.
@@ -141,16 +107,13 @@ type ELBv2ServiceImpl struct {
MgmtRouteTarget string // AWSGW bind IP to route via mgmt NIC
MgmtBridgeIP string // br-mgmt IP, populated whenever br-mgmt exists (single + multi node) for the internal-scheme fallback route
AdvertiseIP string // AdvertiseIP / WAN gateway, populated whenever set; used as the internal-scheme fallback route target on single-node
CACert string // PEM-encoded CA certificate delivered to microvm guests via fw_cfg
nodeID string
region string
systemAMI string // AMI ID for system VMs (ALB VMs); resolved lazily via systemAMIFunc
systemAMIFunc func() (string, error) // returns the current system AMI ID (queries image store)
systemAMIMu sync.Mutex // guards lazy resolution of systemAMI
systemAMIResolved bool // true once systemAMI has been resolved to a non-empty value
systemInstanceType string // instance type for system VMs; resolved lazily via systemInstanceTypeFunc
systemInstanceTypeFunc func() string // returns the smallest available instance type
systemInstanceTypeMu sync.Mutex // guards lazy resolution of systemInstanceType
systemInstanceTypeResolved bool // true once systemInstanceType has been resolved to a non-empty value
systemInstanceType string // instance type for system VMs; resolved lazily via systemInstanceTypeFunc
systemInstanceTypeFunc func() string // returns the smallest available instance type
systemInstanceTypeMu sync.Mutex // guards lazy resolution of systemInstanceType
systemInstanceTypeResolved bool // true once systemInstanceType has been resolved to a non-empty value
ctx context.Context
cancel context.CancelFunc
hc *healthChecker
@@ -194,39 +157,6 @@ func (s *ELBv2ServiceImpl) Close() {
}
}
// SetSystemAMIFunc sets a function that resolves the current system AMI ID.
// This is called at request time so the AMI is discovered even if imported
// after the daemon starts.
func (s *ELBv2ServiceImpl) SetSystemAMIFunc(fn func() (string, error)) {
s.systemAMIMu.Lock()
defer s.systemAMIMu.Unlock()
s.systemAMIFunc = fn
s.systemAMI = ""
s.systemAMIResolved = false
}
// getSystemAMI returns the system AMI ID, resolving it lazily if needed.
// Caches only successful resolutions so the resolver retries after errors.
func (s *ELBv2ServiceImpl) getSystemAMI() (string, error) {
s.systemAMIMu.Lock()
defer s.systemAMIMu.Unlock()
if s.systemAMIResolved {
return s.systemAMI, nil
}
if s.systemAMIFunc == nil {
return "", nil
}
ami, err := s.systemAMIFunc()
if err != nil {
return "", err
}
if ami != "" {
s.systemAMI = ami
s.systemAMIResolved = true
}
return ami, nil
}
// SetSystemInstanceTypeFunc sets a function that resolves the smallest available
// instance type. Called at request time so it adapts to node capacity.
func (s *ELBv2ServiceImpl) SetSystemInstanceTypeFunc(fn func() string) {
@@ -255,6 +185,111 @@ func (s *ELBv2ServiceImpl) getSystemInstanceType() string {
return s.systemInstanceType
}
// buildLBAgentEnv returns the KEY=value blob written to /etc/conf.d/lb-agent
// via fw_cfg on the direct-boot path.
func (s *ELBv2ServiceImpl) buildLBAgentEnv(lbID string) string {
return fmt.Sprintf("LB_LB_ID=%s\nLB_GATEWAY_URL=%s\nLB_ACCESS_KEY=%s\nLB_SECRET_KEY=%s\nLB_REGION=%s\n",
lbID, s.GatewayURL, s.SystemAccessKey, s.SystemSecretKey, s.region)
}
// subnetCIDRForIP computes "ip/prefixlen" given a host IP and the subnet's
// CIDR block (e.g. "10.0.1.0/24" → "10.0.1.5/24"). Returns empty string on
// parse failure so the caller can log and continue without panicking.
func subnetCIDRForIP(hostIP, subnetCIDR string) string {
_, ipNet, err := net.ParseCIDR(subnetCIDR)
if err != nil {
return ""
}
ones, _ := ipNet.Mask.Size()
return fmt.Sprintf("%s/%d", hostIP, ones)
}
// subnetGatewayIP derives the gateway IP (network address +1) from a subnet
// CIDR block. Returns empty string on parse failure.
func subnetGatewayIP(subnetCIDR string) string {
_, ipNet, err := net.ParseCIDR(subnetCIDR)
if err != nil {
return ""
}
gw := ipNet.IP.To4()
if gw == nil {
return ""
}
gwCopy := make(net.IP, len(gw))
copy(gwCopy, gw)
gwCopy[3]++
return gwCopy.String()
}
// buildMicrovmNICs constructs the NIC slice for a direct-boot microvm launch.
//
// NIC[0] is the primary VPC ENI (IsDefault=true): MAC/IP from the ENI record,
// CIDR from ip+subnet prefix, gateway = subnet network address +1.
//
// NIC[1] is the management NIC (IsDefault=false): MAC and CIDR are left empty
// here — the daemon injects them after allocating the per-instance mgmt IP.
// RouteDst/RouteVia encode the host route the lb-agent needs to reach AWSGW
// via the management interface (same logic as resolveMgmtRoute).
//
// NIC[2+] are extra VPC ENIs for multi-subnet ALBs.
func (s *ELBv2ServiceImpl) buildMicrovmNICs(primaryIP, primaryMAC, primarySubnetID, _, scheme string, extraENIs []ExtraENIInput, accountID string) []NICConfig {
// Resolve primary subnet CIDR for NIC[0] prefix and gateway.
primaryCIDR := ""
primaryGateway := ""
if s.VPCService != nil && primarySubnetID != "" {
if subnet, err := s.VPCService.GetSubnet(accountID, primarySubnetID); err == nil && subnet != nil {
primaryCIDR = subnetCIDRForIP(primaryIP, subnet.CidrBlock)
primaryGateway = subnetGatewayIP(subnet.CidrBlock)
} else {
slog.Warn("buildMicrovmNICs: could not look up primary subnet", "subnetId", primarySubnetID, "err", err)
}
}
nics := []NICConfig{
// NIC[0]: primary VPC ENI — default route owner.
{
MAC: primaryMAC,
CIDR: primaryCIDR,
Gateway: primaryGateway,
IsDefault: true,
},
}
// NIC[1]: management NIC — daemon fills MAC/CIDR after IP allocation.
// RouteDst = AWSGW IP (destination), RouteVia = br-mgmt IP (next-hop).
// Honor resolveMgmtRoute's deliberate empty return for single-node
// internet-facing: adding a /32 to AdvertiseIP via mgmt steals the host's
// WAN return path for reply traffic, breaking same-chassis ingress.
mgmtRouteGW, mgmtRouteTarget := s.resolveMgmtRoute(scheme)
nics = append(nics, NICConfig{
IsDefault: false,
RouteDst: mgmtRouteTarget, // AWSGW IP — destination to reach
RouteVia: mgmtRouteGW, // br-mgmt IP — next-hop
})
// NIC[2+]: extra ENIs for multi-subnet ALBs.
for _, extra := range extraENIs {
extraCIDR := ""
extraGateway := ""
if s.VPCService != nil && extra.SubnetID != "" {
if subnet, err := s.VPCService.GetSubnet(accountID, extra.SubnetID); err == nil && subnet != nil {
extraCIDR = subnetCIDRForIP(extra.ENIIP, subnet.CidrBlock)
extraGateway = subnetGatewayIP(subnet.CidrBlock)
} else {
slog.Warn("buildMicrovmNICs: could not look up extra subnet", "subnetId", extra.SubnetID, "err", err)
}
}
nics = append(nics, NICConfig{
MAC: extra.ENIMac,
CIDR: extraCIDR,
Gateway: extraGateway,
IsDefault: false,
})
}
return nics
}
// resolveENIBindAddr looks up the private IP of the first ENI belonging to the ALB.
// Returns empty string if VPC service is unavailable or no ENIs exist.
func (s *ELBv2ServiceImpl) resolveENIBindAddr(lb *LoadBalancerRecord) string {
@@ -671,15 +706,6 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
var hostPorts map[int]int
var launchFailed bool
if s.InstanceLauncher != nil && len(eniIDs) > 0 && len(subnets) > 0 {
systemAMI, amiErr := s.getSystemAMI()
if amiErr != nil {
slog.Error("CreateLoadBalancer: cannot resolve LB system AMI", "lbId", lbID, "err", amiErr)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if systemAMI == "" {
slog.Error("CreateLoadBalancer: LB system AMI not resolved", "lbId", lbID)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Resolve MAC/IP for every ENI in one describe call.
eniDetails := make(map[string]*ec2.NetworkInterface, len(eniIDs))
if s.VPCService != nil {
@@ -721,23 +747,27 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp
})
}
userData, udErr := s.lbVMUserData(lbID, scheme)
if udErr != nil {
slog.Error("CreateLoadBalancer: system credentials not configured — cannot launch ALB VM", "lbId", lbID, "err", udErr)
var launchInput *SystemInstanceInput
if s.GatewayURL == "" || s.SystemAccessKey == "" || s.SystemSecretKey == "" {
slog.Error("CreateLoadBalancer: system credentials not configured — cannot launch ALB VM", "lbId", lbID)
launchFailed = true
} else {
launchInput := &SystemInstanceInput{
nics := s.buildMicrovmNICs(primaryIP, primaryMAC, subnets[0], eniIDs[0], scheme, extraENIInputs, accountID)
launchInput = &SystemInstanceInput{
InstanceType: s.getSystemInstanceType(),
ImageID: systemAMI,
SubnetID: subnets[0],
UserData: userData,
ENIID: eniIDs[0],
ENIMac: primaryMAC,
ENIIP: primaryIP,
ExtraENIs: extraENIInputs,
Scheme: scheme,
AccountID: accountID,
NICs: nics,
LBAgentEnv: s.buildLBAgentEnv(lbID),
CACert: s.CACert,
}
}
if !launchFailed && launchInput != nil {
// Dev-mode only: forward HTTP/HTTPS ports from host for local testing.
// In production (VPC networking), traffic reaches the ALB VM's VPC IP directly.
if s.config != nil && s.config.Daemon.DevNetworking {
+2 -116
View File
@@ -1,7 +1,6 @@
package handlers_elbv2
import (
"errors"
"sort"
"strings"
"sync"
@@ -1526,26 +1525,8 @@ func TestUpdateStoredConfig_SkipsWhenNoInstance(t *testing.T) {
// --- Service lifecycle and setter tests ---
// TestGetSystemAMI_ColdStart and TestGetSystemInstanceType_ColdStart pin the
// no-resolver-wired path. TestGet*_RetryOn{Error,Empty} only exercise paths
// after a resolver is set, so they don't cover the boot-time race where a
// caller asks for the system AMI/instance type before LB-image import has
// installed the resolver. Cold-start must return empty + no error rather
// than panic on a nil func.
func TestGetSystemAMI_ColdStart(t *testing.T) {
svc := setupTestService(t)
ami, err := svc.getSystemAMI()
require.NoError(t, err)
assert.Empty(t, ami)
svc.SetSystemAMIFunc(func() (string, error) { return "ami-system-001", nil })
ami, err = svc.getSystemAMI()
require.NoError(t, err)
assert.Equal(t, "ami-system-001", ami)
}
// TestGetSystemInstanceType_ColdStart pins the no-resolver-wired path —
// cold-start must return empty rather than panic on a nil func.
func TestGetSystemInstanceType_ColdStart(t *testing.T) {
svc := setupTestService(t)
@@ -1555,38 +1536,6 @@ func TestGetSystemInstanceType_ColdStart(t *testing.T) {
assert.Equal(t, "t3.micro", svc.getSystemInstanceType())
}
func TestGetSystemAMI_RetryOnError(t *testing.T) {
svc := setupTestService(t)
calls := 0
svc.SetSystemAMIFunc(func() (string, error) {
calls++
if calls < 3 {
return "", errors.New("LB system image not imported")
}
return "ami-system-001", nil
})
// First two calls error — must NOT be cached
_, err := svc.getSystemAMI()
require.Error(t, err)
_, err = svc.getSystemAMI()
require.Error(t, err)
assert.Equal(t, 2, calls)
// Third call succeeds — cached from now on
ami, err := svc.getSystemAMI()
require.NoError(t, err)
assert.Equal(t, "ami-system-001", ami)
assert.Equal(t, 3, calls)
// Subsequent calls return cached value without re-invoking
ami, err = svc.getSystemAMI()
require.NoError(t, err)
assert.Equal(t, "ami-system-001", ami)
assert.Equal(t, 3, calls)
}
func TestGetSystemInstanceType_RetryOnEmpty(t *testing.T) {
svc := setupTestService(t)
@@ -1610,21 +1559,6 @@ func TestGetSystemInstanceType_RetryOnEmpty(t *testing.T) {
assert.Equal(t, 2, calls)
}
func TestGetSystemAMI_Concurrent(t *testing.T) {
svc := setupTestService(t)
svc.SetSystemAMIFunc(func() (string, error) { return "ami-system-001", nil })
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
got, err := svc.getSystemAMI()
assert.NoError(t, err)
assert.Equal(t, "ami-system-001", got)
})
}
wg.Wait()
}
func TestGetSystemInstanceType_Concurrent(t *testing.T) {
svc := setupTestService(t)
svc.SetSystemInstanceTypeFunc(func() string { return "t3.micro" })
@@ -1639,54 +1573,6 @@ func TestGetSystemInstanceType_Concurrent(t *testing.T) {
wg.Wait()
}
func TestLBVMUserData_MgmtRoute(t *testing.T) {
svc := setupTestService(t)
svc.MgmtRouteGateway = "10.15.8.1"
svc.MgmtRouteTarget = "10.15.8.100"
svc.GatewayURL = "https://10.15.8.100:9999"
svc.SystemAccessKey = "AK"
svc.SystemSecretKey = "SK"
data, err := svc.lbVMUserData("lb-test1", SchemeInternetFacing)
require.NoError(t, err)
assert.Contains(t, data, "bootcmd:")
assert.Contains(t, data, `"10.15.8.100/32"`)
assert.Contains(t, data, `"10.15.8.1"`)
}
// TestLBVMUserData_InternalSingleNodeFallback covers the single-node case
// where MgmtRoute{Gateway,Target} are empty (AWSGW reachable on advertiseIP
// via VPC for internet-facing LBs) but an internal LB has no EIP and so
// needs a forced mgmt-NIC route to AWSGW. Internet-facing in the same setup
// must still get no bootcmd (would steal the host's WAN return path).
func TestLBVMUserData_InternalSingleNodeFallback(t *testing.T) {
mkSvc := func() *ELBv2ServiceImpl {
svc := setupTestService(t)
svc.GatewayURL = "https://192.168.1.33:9999"
svc.SystemAccessKey = "AK"
svc.SystemSecretKey = "SK"
svc.MgmtBridgeIP = "10.15.8.1"
svc.AdvertiseIP = "192.168.1.33"
return svc
}
internal, err := mkSvc().lbVMUserData("lb-int", SchemeInternal)
require.NoError(t, err)
assert.Contains(t, internal, "bootcmd:")
assert.Contains(t, internal, `"192.168.1.33/32"`)
assert.Contains(t, internal, `"10.15.8.1"`)
inetFacing, err := mkSvc().lbVMUserData("lb-inet", SchemeInternetFacing)
require.NoError(t, err)
assert.NotContains(t, inetFacing, "bootcmd:", "internet-facing single-node must not get a /32 mgmt route")
noBridge := mkSvc()
noBridge.MgmtBridgeIP = ""
out, err := noBridge.lbVMUserData("lb-int", SchemeInternal)
require.NoError(t, err)
assert.NotContains(t, out, "bootcmd:", "no fallback when br-mgmt is absent")
}
func TestIsCompatibleProtocol_UnknownListenerProtocol(t *testing.T) {
assert.False(t, isCompatibleProtocol("SCTP", ProtocolTCP))
assert.False(t, isCompatibleProtocol("", ProtocolHTTP))
@@ -1,13 +1,11 @@
package handlers_elbv2
import (
"errors"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/testutil"
@@ -160,7 +158,6 @@ func TestCreateLoadBalancer_MultiSubnet_AllENIsPassedToLauncher(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -281,7 +278,6 @@ func TestCreateLoadBalancer_InternetFacing_AllocatesPublicIP(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -330,7 +326,6 @@ func TestCreateLoadBalancer_Internal_NoPublicIP(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -371,7 +366,6 @@ func TestCreateLoadBalancer_NLB_Internal_NoPublicIP(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-nlb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -420,7 +414,6 @@ func TestDeleteLoadBalancer_TerminatesVM_WithPublicIP(t *testing.T) {
terminateDone: make(chan struct{}),
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -468,7 +461,6 @@ func TestDescribeLoadBalancers_InternetFacing_IncludesPublicIP(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -518,7 +510,6 @@ func TestDescribeLoadBalancers_Internal_NoPublicIP(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -557,7 +548,6 @@ func TestCreateLoadBalancer_LaunchFailure_SetsStateFailed(t *testing.T) {
launchErr: assert.AnError,
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
@@ -586,7 +576,6 @@ func TestCreateLoadBalancer_MissingCredentials_SetsStateFailed(t *testing.T) {
},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil })
// Deliberately NOT setting credentials
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
@@ -643,43 +632,6 @@ func TestENI_RequesterManagedFlag(t *testing.T) {
}
}
// TestCreateLoadBalancer_AMIResolverError_ReturnsServerInternal verifies that
// when the launch path would fire (launcher + networking present) but the LB
// system AMI cannot be resolved, CreateLoadBalancer returns ServerInternal and
// does not persist a record or invoke the launcher.
func TestCreateLoadBalancer_AMIResolverError_ReturnsServerInternal(t *testing.T) {
svc, vpcSvc := setupTestServiceWithVPC(t)
subnets, err := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID)
require.NoError(t, err)
subnetID := *subnets.Subnets[0].SubnetId
mock := &mockSystemInstanceLauncher{
launchResult: &SystemInstanceOutput{InstanceID: "i-should-not-launch"},
}
svc.InstanceLauncher = mock
svc.SetSystemAMIFunc(func() (string, error) {
return "", errors.New("LB system image not imported")
})
svc.GatewayURL = "https://10.0.0.1:9999"
svc.SystemAccessKey = "AKID"
svc.SystemSecretKey = "SECRET"
out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{
Name: aws.String("ami-err-alb"),
Subnets: []*string{aws.String(subnetID)},
}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Nil(t, out)
assert.Empty(t, mock.launchCalls, "launcher must not be invoked when AMI resolver errors")
lbs, err := svc.store.ListLoadBalancers()
require.NoError(t, err)
assert.Empty(t, lbs, "no LB record should be persisted when AMI resolver errors")
}
// TestCreateLoadBalancer_AttachesSpecifiedSecurityGroupsToENI verifies that
// when the caller supplies SecurityGroups on the LB input, the underlying ALB
// ENI is created with those SGs (not the VPC default SG). Without this the
+1 -1
View File
@@ -1 +1 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{n as i}from"./ec22.js";import{t as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./state-badge.js";import{r as c}from"./system-managed.js";import{t as l}from"./ami-details.js";var u=t(),d=e();function f(){let e=(0,u.c)(14),{id:t}=a.useParams(),f;e[0]===t?f=e[1]:(f=i(t),e[0]=t,e[1]=f);let{data:p}=n(f),m=p?.Images?.[0];if(!m?.ImageId||c(m)){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]}),e[2]=t):t=e[2],t}let h;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),e[3]=h):h=e[3];let g;e[4]===m.State?g=e[5]:(g=(0,d.jsx)(s,{state:m.State}),e[4]=m.State,e[5]=g);let _;e[6]!==m.ImageId||e[7]!==g?(_=(0,d.jsx)(o,{actions:g,subtitle:`EC2 Image Details`,title:m.ImageId}),e[6]=m.ImageId,e[7]=g,e[8]=_):_=e[8];let v;e[9]===m?v=e[10]:(v=(0,d.jsx)(l,{image:m,showExtendedDetails:!0}),e[9]=m,e[10]=v);let y;return e[11]!==_||e[12]!==v?(y=(0,d.jsxs)(d.Fragment,{children:[h,(0,d.jsxs)(`div`,{className:`space-y-6`,children:[_,v]})]}),e[11]=_,e[12]=v,e[13]=y):y=e[13],y}export{f as component};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./back-link.js";import{n as i}from"./ec22.js";import{t as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./state-badge.js";import{t as c}from"./system-managed.js";import{t as l}from"./ami-details.js";var u=t(),d=e();function f(){let e=(0,u.c)(14),{id:t}=a.useParams(),f;e[0]===t?f=e[1]:(f=i(t),e[0]=t,e[1]=f);let{data:p}=n(f),m=p?.Images?.[0];if(!m?.ImageId||c(m)){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,d.jsxs)(d.Fragment,{children:[(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),(0,d.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]}),e[2]=t):t=e[2],t}let h;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,d.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),e[3]=h):h=e[3];let g;e[4]===m.State?g=e[5]:(g=(0,d.jsx)(s,{state:m.State}),e[4]=m.State,e[5]=g);let _;e[6]!==m.ImageId||e[7]!==g?(_=(0,d.jsx)(o,{actions:g,subtitle:`EC2 Image Details`,title:m.ImageId}),e[6]=m.ImageId,e[7]=g,e[8]=_):_=e[8];let v;e[9]===m?v=e[10]:(v=(0,d.jsx)(l,{image:m,showExtendedDetails:!0}),e[9]=m,e[10]=v);let y;return e[11]!==_||e[12]!==v?(y=(0,d.jsxs)(d.Fragment,{children:[h,(0,d.jsxs)(`div`,{className:`space-y-6`,children:[_,v]})]}),e[11]=_,e[12]=v,e[13]=y):y=e[13],y}export{f as component};
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useNavigate.js";import{t as i}from"./back-link.js";import{a as ee,i as a,n as o,r as s,t as c}from"./select.js";import{t as l}from"./cli-command-panel.js";import{c as u}from"./ec2.js";import{L as d}from"./index.js";import{t as f}from"./page-heading.js";import{a as p,r as m,t as h}from"./field.js";import{n as g,r as _,t as v}from"./zod.js";import{i as y}from"./ec23.js";import{t as b}from"./form-actions.js";var te=t(),x=e();function S(){let e=(0,te.c)(52),t=r(),S=u(),C;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(C={resolver:v(y),defaultValues:{groupName:``,strategy:`spread`}},e[0]=C):C=e[0];let{control:w,handleSubmit:T,register:E,watch:D,formState:O}=_(C),{errors:k,isSubmitting:A}=O,j;e[1]!==S||e[2]!==t?(j=async e=>{t({to:`/ec2/describe-placement-groups/$id`,params:{id:(await S.mutateAsync(e)).PlacementGroup?.GroupId??``}})},e[1]=S,e[2]=t,e[3]=j):j=e[3];let M=j,N,P;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,x.jsx)(i,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),P=(0,x.jsx)(f,{title:`Create Placement Group`}),e[4]=N,e[5]=P):(N=e[4],P=e[5]);let F;e[6]===S.error?F=e[7]:(F=S.error&&(0,x.jsx)(n,{error:S.error,msg:`Failed to create placement group`}),e[6]=S.error,e[7]=F);let I;e[8]!==T||e[9]!==M?(I=T(M),e[8]=T,e[9]=M,e[10]=I):I=e[10];let L;e[11]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,x.jsx)(p,{children:(0,x.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),e[11]=L):L=e[11];let R=!!k.groupName,z;e[12]===E?z=e[13]:(z=E(`groupName`),e[12]=E,e[13]=z);let B;e[14]!==R||e[15]!==z?(B=(0,x.jsx)(d,{"aria-invalid":R,id:`groupName`,placeholder:`my-placement-group`,...z}),e[14]=R,e[15]=z,e[16]=B):B=e[16];let V;e[17]===k.groupName?V=e[18]:(V=(0,x.jsx)(m,{errors:[k.groupName]}),e[17]=k.groupName,e[18]=V);let H;e[19]!==B||e[20]!==V?(H=(0,x.jsxs)(h,{children:[L,B,V]}),e[19]=B,e[20]=V,e[21]=H):H=e[21];let U;e[22]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,x.jsx)(p,{children:(0,x.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),e[22]=U):U=e[22];let W;e[23]===k.strategy?W=e[24]:(W=e=>{let{field:t}=e;return(0,x.jsxs)(c,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,x.jsx)(a,{"aria-invalid":!!k.strategy,className:`w-full`,id:`strategy`,children:(0,x.jsx)(ee,{})}),(0,x.jsxs)(o,{children:[(0,x.jsx)(s,{value:`spread`,children:`spread`}),(0,x.jsx)(s,{value:`cluster`,children:`cluster`})]})]})},e[23]=k.strategy,e[24]=W);let G;e[25]!==w||e[26]!==W?(G=(0,x.jsx)(g,{control:w,name:`strategy`,render:W}),e[25]=w,e[26]=W,e[27]=G):G=e[27];let K;e[28]===k.strategy?K=e[29]:(K=(0,x.jsx)(m,{errors:[k.strategy]}),e[28]=k.strategy,e[29]=K);let q;e[30]!==G||e[31]!==K?(q=(0,x.jsxs)(h,{children:[U,G,K]}),e[30]=G,e[31]=K,e[32]=q):q=e[32];let J;e[33]===D?J=e[34]:(J=ne(D),e[33]=D,e[34]=J);let Y;e[35]===J?Y=e[36]:(Y=(0,x.jsx)(l,{commands:J}),e[35]=J,e[36]=Y);let X;e[37]===t?X=e[38]:(X=async()=>await t({to:`/ec2/describe-placement-groups`}),e[37]=t,e[38]=X);let Z;e[39]!==S.isPending||e[40]!==A||e[41]!==X?(Z=(0,x.jsx)(b,{isPending:S.isPending,isSubmitting:A,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),e[39]=S.isPending,e[40]=A,e[41]=X,e[42]=Z):Z=e[42];let Q;e[43]!==H||e[44]!==q||e[45]!==Y||e[46]!==Z||e[47]!==I?(Q=(0,x.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[43]=H,e[44]=q,e[45]=Y,e[46]=Z,e[47]=I,e[48]=Q):Q=e[48];let $;return e[49]!==Q||e[50]!==F?($=(0,x.jsxs)(x.Fragment,{children:[N,P,F,Q]}),e[49]=Q,e[50]=F,e[51]=$):$=e[51],$}function ne(e){let t=e(`groupName`),n=typeof t==`string`?t:``,r=e(`strategy`),i=typeof r==`string`?r:``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},{type:`flag`,value:` \\
--group-name`},{type:`value`,value:` ${n||`<GroupName>`}`},{type:`flag`,value:` \\
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./error-banner.js";import{t as r}from"./useNavigate.js";import{t as i}from"./back-link.js";import{a as ee,i as a,n as o,r as s,t as c}from"./select.js";import{t as l}from"./cli-command-panel.js";import{c as u}from"./ec2.js";import{L as d}from"./index.js";import{t as f}from"./page-heading.js";import{a as p,r as m,t as h}from"./field.js";import{n as g,r as _,t as v}from"./zod.js";import{i as y}from"./ec23.js";import{t as b}from"./form-actions.js";var te=t(),x=e();function S(){let e=(0,te.c)(52),t=r(),S=u(),C;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(C={resolver:v(y),defaultValues:{groupName:``,strategy:`spread`}},e[0]=C):C=e[0];let{control:w,handleSubmit:T,register:E,watch:D,formState:O}=_(C),{errors:k,isSubmitting:A}=O,j;e[1]!==S||e[2]!==t?(j=async e=>{t({to:`/ec2/describe-placement-groups/$id`,params:{id:(await S.mutateAsync(e)).PlacementGroup?.GroupId??``}})},e[1]=S,e[2]=t,e[3]=j):j=e[3];let M=j,N,P;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,x.jsx)(i,{to:`/ec2/describe-placement-groups`,children:`Back to placement groups`}),P=(0,x.jsx)(f,{title:`Create Placement Group`}),e[4]=N,e[5]=P):(N=e[4],P=e[5]);let F;e[6]===S.error?F=e[7]:(F=S.error&&(0,x.jsx)(n,{error:S.error,msg:`Failed to create placement group`}),e[6]=S.error,e[7]=F);let I;e[8]!==T||e[9]!==M?(I=T(M),e[8]=T,e[9]=M,e[10]=I):I=e[10];let L;e[11]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,x.jsx)(p,{children:(0,x.jsx)(`label`,{htmlFor:`groupName`,children:`Group Name`})}),e[11]=L):L=e[11];let R=!!k.groupName,z;e[12]===E?z=e[13]:(z=E(`groupName`),e[12]=E,e[13]=z);let B;e[14]!==R||e[15]!==z?(B=(0,x.jsx)(d,{"aria-invalid":R,id:`groupName`,placeholder:`my-placement-group`,...z}),e[14]=R,e[15]=z,e[16]=B):B=e[16];let V;e[17]===k.groupName?V=e[18]:(V=(0,x.jsx)(m,{errors:[k.groupName]}),e[17]=k.groupName,e[18]=V);let H;e[19]!==B||e[20]!==V?(H=(0,x.jsxs)(h,{children:[L,B,V]}),e[19]=B,e[20]=V,e[21]=H):H=e[21];let U;e[22]===Symbol.for(`react.memo_cache_sentinel`)?(U=(0,x.jsx)(p,{children:(0,x.jsx)(`label`,{htmlFor:`strategy`,children:`Strategy`})}),e[22]=U):U=e[22];let W;e[23]===k.strategy?W=e[24]:(W=e=>{let{field:t}=e;return(0,x.jsxs)(c,{onValueChange:e=>t.onChange(e),value:t.value??``,children:[(0,x.jsx)(a,{"aria-invalid":!!k.strategy,className:`w-full`,id:`strategy`,children:(0,x.jsx)(ee,{})}),(0,x.jsxs)(o,{children:[(0,x.jsx)(s,{value:`spread`,children:`spread`}),(0,x.jsx)(s,{value:`cluster`,children:`cluster`})]})]})},e[23]=k.strategy,e[24]=W);let G;e[25]!==w||e[26]!==W?(G=(0,x.jsx)(g,{control:w,name:`strategy`,render:W}),e[25]=w,e[26]=W,e[27]=G):G=e[27];let K;e[28]===k.strategy?K=e[29]:(K=(0,x.jsx)(m,{errors:[k.strategy]}),e[28]=k.strategy,e[29]=K);let q;e[30]!==G||e[31]!==K?(q=(0,x.jsxs)(h,{children:[U,G,K]}),e[30]=G,e[31]=K,e[32]=q):q=e[32];let J;e[33]===D?J=e[34]:(J=ne(D),e[33]=D,e[34]=J);let Y;e[35]===J?Y=e[36]:(Y=(0,x.jsx)(l,{commands:J}),e[35]=J,e[36]=Y);let X;e[37]===t?X=e[38]:(X=async()=>await t({to:`/ec2/describe-placement-groups`}),e[37]=t,e[38]=X);let Z;e[39]!==S.isPending||e[40]!==A||e[41]!==X?(Z=(0,x.jsx)(b,{isPending:S.isPending,isSubmitting:A,onCancel:X,pendingLabel:`Creating…`,submitLabel:`Create Placement Group`}),e[39]=S.isPending,e[40]=A,e[41]=X,e[42]=Z):Z=e[42];let Q;e[43]!==H||e[44]!==q||e[45]!==Y||e[46]!==Z||e[47]!==I?(Q=(0,x.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:I,children:[H,q,Y,Z]}),e[43]=H,e[44]=q,e[45]=Y,e[46]=Z,e[47]=I,e[48]=Q):Q=e[48];let $;return e[49]!==Q||e[50]!==F?($=(0,x.jsxs)(x.Fragment,{children:[N,P,F,Q]}),e[49]=Q,e[50]=F,e[51]=$):$=e[51],$}function C(e){return`'${e.replaceAll(`'`,`'"'"'`)}'`}function ne(e){let t=e(`groupName`),n=typeof t==`string`?t:``,r=e(`strategy`),i=typeof r==`string`?r:``;return[{label:`Create Placement Group`,parts:[{type:`bin`,value:`AWS_PROFILE=spinifex aws ec2 create-placement-group`},{type:`flag`,value:` \\
--group-name`},{type:`value`,value:` ${n?C(n):`<GroupName>`}`},{type:`flag`,value:` \\
--strategy`},{type:`value`,value:` ${i||`spread`}`}]}]}export{S as component};
@@ -1,14 +1,14 @@
import{n as e}from"./compiler-runtime.js";import{t}from"./error-banner.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./useNavigate.js";import{t as i}from"./back-link.js";import{t as a}from"./cli-command-panel.js";import{x as o}from"./ec22.js";import{r as s}from"./elbv2.js";import{t as c}from"./page-heading.js";import{r as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./target-group-form.js";import{r as p}from"./elbv23.js";var m=e();function h(){let e=r(),{data:h}=n(o),_=s(),v=h.Vpcs??[],y=l({resolver:u(p),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:v[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:b,watch:x,formState:{isSubmitting:S}}=y;return(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(i,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,m.jsx)(c,{title:`Create target group`}),_.error&&(0,m.jsx)(t,{error:_.error,msg:`Failed to create target group`}),(0,m.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:b(async t=>{try{let n=(await _.mutateAsync(t)).TargetGroups?.[0]?.TargetGroupArn;e(n?{to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(n)}}:{to:`/ec2/describe-target-groups`})}catch{}}),children:[(0,m.jsx)(f,{form:y,vpcs:v}),(0,m.jsx)(a,{commands:g(x)}),(0,m.jsx)(d,{isPending:_.isPending,isSubmitting:S,onCancel:async()=>await e({to:`/ec2/describe-target-groups`}),pendingLabel:`Creating…`,submitLabel:`Create target group`})]})]})}function g(e){let t=t=>{let n=e(t);return typeof n==`string`?n:``},n=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},r=t(`name`),i=t(`protocol`)||`HTTP`,a=n(`port`),o=t(`vpcId`),s=t(`healthCheck.path`)||`/`,c=t(`healthCheck.port`)||`traffic-port`,l=n(`healthCheck.intervalSeconds`),u=n(`healthCheck.timeoutSeconds`),d=n(`healthCheck.healthyThresholdCount`),f=n(`healthCheck.unhealthyThresholdCount`),p=t(`healthCheck.matcher`)||`200`,m=[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
import{n as e}from"./compiler-runtime.js";import{t}from"./error-banner.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./useNavigate.js";import{t as i}from"./back-link.js";import{t as a}from"./cli-command-panel.js";import{x as o}from"./ec22.js";import{r as s}from"./elbv2.js";import{t as c}from"./page-heading.js";import{r as l,t as u}from"./zod.js";import{t as d}from"./form-actions.js";import{t as f}from"./target-group-form.js";import{r as p}from"./elbv23.js";var m=e();function h(){let e=r(),{data:h}=n(o),_=s(),v=h.Vpcs??[],y=l({resolver:u(p),defaultValues:{name:``,protocol:`HTTP`,port:80,vpcId:v[0]?.VpcId??``,healthCheck:{protocol:`HTTP`,path:`/`,port:`traffic-port`,intervalSeconds:30,timeoutSeconds:5,healthyThresholdCount:5,unhealthyThresholdCount:2,matcher:`200`},tags:[]}}),{handleSubmit:b,watch:x,formState:{isSubmitting:S}}=y;return(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(i,{to:`/ec2/describe-target-groups`,children:`Back to target groups`}),(0,m.jsx)(c,{title:`Create target group`}),_.error&&(0,m.jsx)(t,{error:_.error,msg:`Failed to create target group`}),(0,m.jsxs)(`form`,{className:`max-w-4xl space-y-6`,onSubmit:b(async t=>{try{let n=(await _.mutateAsync(t)).TargetGroups?.[0]?.TargetGroupArn;e(n?{to:`/ec2/describe-target-groups/$id`,params:{id:encodeURIComponent(n)}}:{to:`/ec2/describe-target-groups`})}catch{}}),children:[(0,m.jsx)(f,{form:y,vpcs:v}),(0,m.jsx)(a,{commands:g(x)}),(0,m.jsx)(d,{isPending:_.isPending,isSubmitting:S,onCancel:async()=>await e({to:`/ec2/describe-target-groups`}),pendingLabel:`Creating…`,submitLabel:`Create target group`})]})]})}function g(e){let t=t=>{let n=e(t);return typeof n==`string`?n:``},n=t=>{let n=e(t);return typeof n==`number`&&Number.isFinite(n)?n:void 0},r=t(`name`),i=t(`protocol`)||`HTTP`,a=n(`port`),o=t(`vpcId`),s=t(`healthCheck.protocol`)||`HTTP`,c=t(`healthCheck.path`)||`/`,l=t(`healthCheck.port`)||`traffic-port`,u=n(`healthCheck.intervalSeconds`),d=n(`healthCheck.timeoutSeconds`),f=n(`healthCheck.healthyThresholdCount`),p=n(`healthCheck.unhealthyThresholdCount`),m=t(`healthCheck.matcher`)||`200`,h=[{type:`bin`,value:`AWS_PROFILE=spinifex aws elbv2 create-target-group`},{type:`flag`,value:` \\
--name`},{type:`value`,value:` ${r||`<Name>`}`},{type:`flag`,value:` \\
--protocol`},{type:`value`,value:` ${i}`},{type:`flag`,value:` \\
--port`},{type:`value`,value:` ${a??80}`},{type:`flag`,value:` \\
--target-type`},{type:`value`,value:` instance`}];return o&&m.push({type:`flag`,value:` \\
--vpc-id`},{type:`value`,value:` ${o}`}),m.push({type:`flag`,value:` \\
--health-check-protocol`},{type:`value`,value:` HTTP`},{type:`flag`,value:` \\
--health-check-path`},{type:`value`,value:` ${s}`},{type:`flag`,value:` \\
--health-check-port`},{type:`value`,value:` ${c}`},{type:`flag`,value:` \\
--health-check-interval-seconds`},{type:`value`,value:` ${l??30}`},{type:`flag`,value:` \\
--health-check-timeout-seconds`},{type:`value`,value:` ${u??5}`},{type:`flag`,value:` \\
--healthy-threshold-count`},{type:`value`,value:` ${d??5}`},{type:`flag`,value:` \\
--unhealthy-threshold-count`},{type:`value`,value:` ${f??2}`},{type:`flag`,value:` \\
--matcher`},{type:`value`,value:` HttpCode=${p}`}),[{label:`Create Target Group`,parts:m}]}var _=h;export{_ as component};
--target-type`},{type:`value`,value:` instance`}];return o&&h.push({type:`flag`,value:` \\
--vpc-id`},{type:`value`,value:` ${o}`}),h.push({type:`flag`,value:` \\
--health-check-protocol`},{type:`value`,value:` ${s}`},{type:`flag`,value:` \\
--health-check-path`},{type:`value`,value:` ${c}`},{type:`flag`,value:` \\
--health-check-port`},{type:`value`,value:` ${l}`},{type:`flag`,value:` \\
--health-check-interval-seconds`},{type:`value`,value:` ${u??30}`},{type:`flag`,value:` \\
--health-check-timeout-seconds`},{type:`value`,value:` ${d??5}`},{type:`flag`,value:` \\
--healthy-threshold-count`},{type:`value`,value:` ${f??5}`},{type:`flag`,value:` \\
--unhealthy-threshold-count`},{type:`value`,value:` ${p??2}`},{type:`flag`,value:` \\
--matcher`},{type:`value`,value:` HttpCode=${m}`}),[{label:`Create Target Group`,parts:h}]}var _=h;export{_ as component};
@@ -1 +1 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{r}from"./ec22.js";import{t as i}from"./page-heading.js";import{t as a}from"./state-badge.js";import{r as o}from"./system-managed.js";import{t as s}from"./list-card.js";var c=t(),l=e();function u(){let e=(0,c.c)(3),{data:t}=n(r),a;if(e[0]!==t.Images){let n=(t.Images??[]).filter(f),r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,l.jsx)(i,{title:`EC2 Images`}),e[2]=r):r=e[2],a=(0,l.jsxs)(l.Fragment,{children:[r,n.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:n.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No images found.`})]}),e[0]=t.Images,e[1]=a}else a=e[1];return a}function d(e){return e.ImageId?(0,l.jsx)(s,{badge:(0,l.jsx)(a,{state:e.State}),params:{id:e.ImageId},subtitle:e.ImageId??``,title:e.Name??e.ImageId??``,to:`/ec2/describe-images/$id`},e.ImageId):null}function f(e){return!o(e)}export{u as component};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{r}from"./ec22.js";import{t as i}from"./page-heading.js";import{t as a}from"./state-badge.js";import{t as o}from"./system-managed.js";import{t as s}from"./list-card.js";var c=t(),l=e();function u(){let e=(0,c.c)(3),{data:t}=n(r),a;if(e[0]!==t.Images){let n=(t.Images??[]).filter(f),r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,l.jsx)(i,{title:`EC2 Images`}),e[2]=r):r=e[2],a=(0,l.jsxs)(l.Fragment,{children:[r,n.length>0?(0,l.jsx)(`div`,{className:`space-y-4`,children:n.map(d)}):(0,l.jsx)(`p`,{className:`text-muted-foreground`,children:`No images found.`})]}),e[0]=t.Images,e[1]=a}else a=e[1];return a}function d(e){return e.ImageId?(0,l.jsx)(s,{badge:(0,l.jsx)(a,{state:e.State}),params:{id:e.ImageId},subtitle:e.ImageId??``,title:e.Name??e.ImageId??``,to:`/ec2/describe-images/$id`},e.ImageId):null}function f(e){return!o(e)}export{u as component};
@@ -1 +1 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./link.js";import{t as i}from"./useNavigate.js";import{t as a}from"./button.js";import{r as o}from"./ec22.js";import{i as s}from"./elbv22.js";import{t as c}from"./page-heading.js";import{t as l}from"./state-badge.js";import{n as u}from"./system-managed.js";import{t as d}from"./lb-image-missing-banner.js";var f=t(),p=e();function m(e){return e?new Date(e).toLocaleString():``}function h(){let e=(0,f.c)(20),t=i(),{data:h}=n(s),{data:_}=n(o),v;e[0]===h.LoadBalancers?v=e[1]:(v=h.LoadBalancers??[],e[0]=h.LoadBalancers,e[1]=v);let y=v,b;e[2]===_.Images?b=e[3]:(b=_.Images??[],e[2]=_.Images,e[3]=b);let x;e[4]===b?x=e[5]:(x=u(b),e[4]=b,e[5]=x);let S=x,C=!S,w;e[6]===t?w=e[7]:(w=async()=>await t({to:`/ec2/create-load-balancer`}),e[6]=t,e[7]=w);let T;e[8]!==C||e[9]!==w?(T=(0,p.jsx)(c,{actions:(0,p.jsx)(a,{disabled:C,onClick:w,children:`Create Load Balancer`}),title:`Load Balancers`}),e[8]=C,e[9]=w,e[10]=T):T=e[10];let E;e[11]===S?E=e[12]:(E=!S&&(0,p.jsx)(d,{}),e[11]=S,e[12]=E);let D;e[13]!==y||e[14]!==t?(D=y.length>0?(0,p.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,p.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,p.jsx)(`thead`,{children:(0,p.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`DNS Name`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`State`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Type`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Scheme`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,p.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Created`})]})}),(0,p.jsx)(`tbody`,{children:y.map(e=>{if(!e.LoadBalancerArn)return null;let n=e.LoadBalancerArn;return(0,p.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await t({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n)}}),children:[(0,p.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,p.jsx)(r,{className:`text-primary hover:underline`,onClick:g,params:{id:encodeURIComponent(n)},to:`/ec2/describe-load-balancers/$id`,children:e.LoadBalancerName??n})}),(0,p.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.DNSName??``}),(0,p.jsx)(`td`,{className:`px-4 py-2`,children:(0,p.jsx)(l,{state:e.State?.Code})}),(0,p.jsx)(`td`,{className:`px-4 py-2`,children:e.Type??``}),(0,p.jsx)(`td`,{className:`px-4 py-2`,children:e.Scheme??``}),(0,p.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,p.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:m(e.CreatedTime)})]},n)})})]})}):(0,p.jsx)(`p`,{className:`text-muted-foreground`,children:`No load balancers found.`}),e[13]=y,e[14]=t,e[15]=D):D=e[15];let O;return e[16]!==T||e[17]!==E||e[18]!==D?(O=(0,p.jsxs)(p.Fragment,{children:[T,E,D]}),e[16]=T,e[17]=E,e[18]=D,e[19]=O):O=e[19],O}function g(e){return e.stopPropagation()}var _=h;export{_ as component};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./link.js";import{t as i}from"./useNavigate.js";import{t as a}from"./button.js";import{i as o}from"./elbv22.js";import{t as s}from"./page-heading.js";import{t as c}from"./state-badge.js";var l=t(),u=e();function d(e){return e?new Date(e).toLocaleString():``}function f(){let e=(0,l.c)(10),t=i(),{data:f}=n(o),m;e[0]===f.LoadBalancers?m=e[1]:(m=f.LoadBalancers??[],e[0]=f.LoadBalancers,e[1]=m);let h=m,g;e[2]===t?g=e[3]:(g=(0,u.jsx)(s,{actions:(0,u.jsx)(a,{onClick:async()=>await t({to:`/ec2/create-load-balancer`}),children:`Create Load Balancer`}),title:`Load Balancers`}),e[2]=t,e[3]=g);let _;e[4]!==h||e[5]!==t?(_=h.length>0?(0,u.jsx)(`div`,{className:`overflow-x-auto rounded-lg border bg-card`,children:(0,u.jsxs)(`table`,{className:`w-full text-sm`,children:[(0,u.jsx)(`thead`,{children:(0,u.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Name`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`DNS Name`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`State`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Type`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Scheme`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`VPC`}),(0,u.jsx)(`th`,{className:`px-4 py-2 font-medium`,children:`Created`})]})}),(0,u.jsx)(`tbody`,{children:h.map(e=>{if(!e.LoadBalancerArn)return null;let n=e.LoadBalancerArn;return(0,u.jsxs)(`tr`,{className:`cursor-pointer border-b transition-colors last:border-0 hover:bg-accent`,onClick:async()=>await t({to:`/ec2/describe-load-balancers/$id`,params:{id:encodeURIComponent(n)}}),children:[(0,u.jsx)(`td`,{className:`px-4 py-2 font-medium`,children:(0,u.jsx)(r,{className:`text-primary hover:underline`,onClick:p,params:{id:encodeURIComponent(n)},to:`/ec2/describe-load-balancers/$id`,children:e.LoadBalancerName??n})}),(0,u.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.DNSName??``}),(0,u.jsx)(`td`,{className:`px-4 py-2`,children:(0,u.jsx)(c,{state:e.State?.Code})}),(0,u.jsx)(`td`,{className:`px-4 py-2`,children:e.Type??``}),(0,u.jsx)(`td`,{className:`px-4 py-2`,children:e.Scheme??``}),(0,u.jsx)(`td`,{className:`px-4 py-2 font-mono text-xs`,children:e.VpcId??``}),(0,u.jsx)(`td`,{className:`px-4 py-2 text-xs`,children:d(e.CreatedTime)})]},n)})})]})}):(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`No load balancers found.`}),e[4]=h,e[5]=t,e[6]=_):_=e[6];let v;return e[7]!==g||e[8]!==_?(v=(0,u.jsxs)(u.Fragment,{children:[g,_]}),e[7]=g,e[8]=_,e[9]=v):v=e[9],v}function p(e){return e.stopPropagation()}var m=f;export{m as component};
+1 -1
View File
@@ -1 +1 @@
import{D as e,E as t,O as n,T as r,j as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=i().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=[`gp3`],p=a({imageId:i(`Please select an Image`),instanceType:i(`Please select an instance type`),keyName:i(`Please select a key pair`).min(1,`Key pair is required`),subnetId:i().optional(),placementGroupName:i().optional(),count:e(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`),rootDeviceName:i().optional(),rootVolumeSize:n().int(`Volume size must be a whole number`).min(1,`Volume size must be at least 1 GiB`).max(16384,`Volume size must be at most 16384 GiB`).optional(),rootVolumeType:o(f).optional(),rootDeleteOnTermination:t().optional()}),m=a({keyName:d}),h=a({keyName:d,publicKeyMaterial:i().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),g=a({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:i().min(1,`Availability zone is required`)}),_=a({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),v=a({volumeId:i().min(1,`Volume is required`),description:i().optional()});a({sourceSnapshotId:i().min(1,`Source snapshot is required`),sourceRegion:i().min(1,`Source region is required`),description:i().optional()}),a({volumeId:i().min(1,`Volume is required`),instanceId:i().min(1,`Instance is required`),device:i().optional()}),a({volumeId:i().min(1,`Volume is required`),instanceId:i().optional(),force:t().optional()}),a({instanceType:i().min(1,`Instance type is required`)}),a({name:i().min(1,`Name is required`),description:i().optional()});var y=a({vpcId:i().min(1,`VPC is required`),cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:i().optional()});a({cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:i().optional()});var b=a({key:i().min(1,`Key is required`),value:i()}),x=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:i().optional(),autoGenerateNames:t(),cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:n().int().min(0).max(1),privateSubnetCount:n().int().min(0).max(2),publicSubnetCidrs:r(i()),privateSubnetCidrs:r(i()),tags:r(b)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),S=a({groupName:i().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),strategy:i().min(1,`Strategy is required`)}),C=a({groupName:i().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:i().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:i().min(1,`VPC is required`)}),w=a({ipProtocol:i().min(1,`Protocol is required`),fromPort:n().int().min(-1).max(65535),toPort:n().int().min(-1).max(65535),cidrIp:i().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{C as a,g as c,_ as d,w as f,S as i,x as l,p as n,v as o,m as r,y as s,f as t,h as u};
import{D as e,E as t,O as n,T as r,j as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=i().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=[`gp3`],p=a({imageId:i(`Please select an Image`),instanceType:i(`Please select an instance type`),keyName:i(`Please select a key pair`).min(1,`Key pair is required`),subnetId:i().optional(),placementGroupName:i().optional(),count:e(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`),rootDeviceName:i().optional(),rootVolumeSize:n().int(`Volume size must be a whole number`).min(1,`Volume size must be at least 1 GiB`).max(16384,`Volume size must be at most 16384 GiB`).optional(),rootVolumeType:o(f).optional(),rootDeleteOnTermination:t().optional()}),m=a({keyName:d}),h=a({keyName:d,publicKeyMaterial:i().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),g=a({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:i().min(1,`Availability zone is required`)}),_=a({size:n().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),v=a({volumeId:i().min(1,`Volume is required`),description:i().optional()});a({sourceSnapshotId:i().min(1,`Source snapshot is required`),sourceRegion:i().min(1,`Source region is required`),description:i().optional()}),a({volumeId:i().min(1,`Volume is required`),instanceId:i().min(1,`Instance is required`),device:i().optional()}),a({volumeId:i().min(1,`Volume is required`),instanceId:i().optional(),force:t().optional()}),a({instanceType:i().min(1,`Instance type is required`)}),a({name:i().min(1,`Name is required`),description:i().optional()});var y=a({vpcId:i().min(1,`VPC is required`),cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:i().optional()});a({cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:i().optional()});var b=a({key:i().min(1,`Key is required`),value:i()}),x=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:i().optional(),autoGenerateNames:t(),cidrBlock:i().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:n().int().min(0).max(1),privateSubnetCount:n().int().min(0).max(2),publicSubnetCidrs:r(i()),privateSubnetCidrs:r(i()),tags:r(b)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),S=a({groupName:i().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Group name contains invalid characters`),strategy:i().min(1,`Strategy is required`)}),C=a({groupName:i().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:i().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:i().min(1,`VPC is required`)}),w=a({ipProtocol:i().min(1,`Protocol is required`),fromPort:n().int().min(-1).max(65535),toPort:n().int().min(-1).max(65535),cidrIp:i().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{C as a,g as c,_ as d,w as f,S as i,x as l,p as n,v as o,m as r,y as s,f as t,h as u};
+1 -1
View File
@@ -1 +1 @@
import{j as e,k as t}from"./SignatureV4.js";var n=t({userName:e().min(1,`User name is required`).max(64,`User name must be 64 characters or less`).regex(/^[\w+=,.@-]+$/,`User name contains invalid characters`),path:e().optional()}),r=t({policyName:e().min(1,`Policy name is required`).max(128,`Policy name must be 128 characters or less`),description:e().optional(),policyDocument:e().min(1,`Policy document is required`).refine(e=>{try{return JSON.parse(e),!0}catch{return!1}},{message:`Policy document must be valid JSON`})});export{n,r as t};
import{j as e,k as t}from"./SignatureV4.js";var n=t({userName:e().min(1,`User name is required`).max(64,`User name must be 64 characters or less`).regex(/^[\w+=,.@-]+$/,`User name contains invalid characters`),path:e().optional()}),r=t({policyName:e().min(1,`Policy name is required`).max(128,`Policy name must be 128 characters or less`).regex(/^[\w+=,.@-]+$/,`Policy name contains invalid characters`),description:e().optional(),policyDocument:e().min(1,`Policy document is required`).refine(e=>{try{return JSON.parse(e),!0}catch{return!1}},{message:`Policy document must be valid JSON`})});export{n,r as t};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./system-managed.js";var r=t(),i=e();function a(){let e=(0,r.c)(1),t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,i.jsxs)(`div`,{className:`mb-6 max-w-4xl rounded-md border border-amber-500/50 bg-amber-500/10 p-4`,children:[(0,i.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Load balancer image not imported`}),(0,i.jsx)(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`The load balancer system image has not been imported on this cluster. A cluster administrator must run the following command before load balancers can be created:`}),(0,i.jsxs)(`pre`,{className:`mt-2 overflow-x-auto rounded bg-muted px-3 py-2 font-mono text-xs`,children:[`spx admin images import --name `,n]})]}),e[0]=t):t=e[0],t}export{a as t};
@@ -1 +1 @@
var e=`lb-alpine-3.21.6-x86_64`;function t(e){return e.Tags?.some(e=>e.Key===`spinifex:managed-by`)??!1}function n(e){return e.Tags?.some(e=>e.Key===`spinifex:managed-by`&&e.Value===`elbv2`)??!1}function r(e){return e.some(n)}export{r as n,t as r,e as t};
function e(e){return e.Tags?.some(e=>e.Key===`spinifex:managed-by`)??!1}export{e as t};
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
import { LB_IMAGE_NAME } from "@/lib/system-managed"
export function LbImageMissingBanner() {
return (
<div className="mb-6 max-w-4xl rounded-md border border-amber-500/50 bg-amber-500/10 p-4">
<h2 className="text-sm font-semibold">
Load balancer image not imported
</h2>
<p className="mt-1 text-sm text-muted-foreground">
The load balancer system image has not been imported on this cluster. A
cluster administrator must run the following command before load
balancers can be created:
</p>
<pre className="mt-2 overflow-x-auto rounded bg-muted px-3 py-2 font-mono text-xs">
spx admin images import --name {LB_IMAGE_NAME}
</pre>
</div>
)
}
@@ -1,13 +1,7 @@
import type { Image } from "@aws-sdk/client-ec2"
import { describe, expect, it } from "vitest"
import {
LB_MANAGED_BY_VALUE,
SYSTEM_MANAGED_TAG_KEY,
hasLbImage,
isLbImage,
isSystemManagedImage,
} from "./system-managed"
import { SYSTEM_MANAGED_TAG_KEY, isSystemManagedImage } from "./system-managed"
describe("isSystemManagedImage", () => {
it("returns true when image carries the managed-by tag", () => {
@@ -30,49 +24,3 @@ describe("isSystemManagedImage", () => {
expect(isSystemManagedImage({ ImageId: "ami-3" })).toBeFalsy()
})
})
describe("isLbImage", () => {
it("returns true when tag value matches elbv2", () => {
const image: Image = {
ImageId: "ami-lb",
Tags: [{ Key: SYSTEM_MANAGED_TAG_KEY, Value: LB_MANAGED_BY_VALUE }],
}
expect(isLbImage(image)).toBeTruthy()
})
it("returns false for system-managed images owned by other components", () => {
const image: Image = {
ImageId: "ami-other",
Tags: [{ Key: SYSTEM_MANAGED_TAG_KEY, Value: "other-service" }],
}
expect(isLbImage(image)).toBeFalsy()
})
it("returns false for customer images", () => {
expect(isLbImage({ ImageId: "ami-cust" })).toBeFalsy()
})
})
describe("hasLbImage", () => {
it("returns true when any image in the list is the LB AMI", () => {
const images: Image[] = [
{ ImageId: "ami-cust", Tags: [{ Key: "Name", Value: "ubuntu" }] },
{
ImageId: "ami-lb",
Tags: [{ Key: SYSTEM_MANAGED_TAG_KEY, Value: LB_MANAGED_BY_VALUE }],
},
]
expect(hasLbImage(images)).toBeTruthy()
})
it("returns false for an empty list", () => {
expect(hasLbImage([])).toBeFalsy()
})
it("returns false when no image carries the elbv2 tag", () => {
const images: Image[] = [
{ ImageId: "ami-cust", Tags: [{ Key: "Name", Value: "ubuntu" }] },
]
expect(hasLbImage(images)).toBeFalsy()
})
})
@@ -1,30 +1,10 @@
import type { Image } from "@aws-sdk/client-ec2"
// Tag key applied by Spinifex to platform-managed resources
// (HAProxy VMs, their ENIs, the LB AMI). The value identifies the
// owning component (e.g. "elbv2"). See spinifex/docs/TAG-CONVENTIONS.md.
// (HAProxy VMs, their ENIs). The value identifies the owning component
// (e.g. "elbv2"). See spinifex/docs/TAG-CONVENTIONS.md.
export const SYSTEM_MANAGED_TAG_KEY = "spinifex:managed-by"
// Value of SYSTEM_MANAGED_TAG_KEY carried by the LB system AMI and its VMs.
export const LB_MANAGED_BY_VALUE = "elbv2"
// Canonical name of the LB system image in utils.AvailableImages.
// Kept in sync with spinifex/utils/images.go.
export const LB_IMAGE_NAME = "lb-alpine-3.21.6-x86_64"
export function isSystemManagedImage(image: Image): boolean {
return image.Tags?.some((tag) => tag.Key === SYSTEM_MANAGED_TAG_KEY) ?? false
}
export function isLbImage(image: Image): boolean {
return (
image.Tags?.some(
(tag) =>
tag.Key === SYSTEM_MANAGED_TAG_KEY && tag.Value === LB_MANAGED_BY_VALUE,
) ?? false
)
}
export function hasLbImage(images: Image[]): boolean {
return images.some(isLbImage)
}
@@ -12,7 +12,6 @@ import {
type CliCommand,
type CommandPart,
} from "@/components/cli-command-panel"
import { LbImageMissingBanner } from "@/components/elbv2/lb-image-missing-banner"
import { TargetGroupForm } from "@/components/elbv2/target-group-form"
import { ErrorBanner } from "@/components/error-banner"
import { FormActions } from "@/components/form-actions"
@@ -27,14 +26,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { hasLbImage } from "@/lib/system-managed"
import { getNameTag } from "@/lib/utils"
import {
type LbWizardResult,
useCreateLoadBalancerWizard,
} from "@/mutations/elbv2"
import {
ec2ImagesQueryOptions,
ec2SecurityGroupsQueryOptions,
ec2SubnetsQueryOptions,
ec2VpcsQueryOptions,
@@ -85,10 +82,8 @@ export function CreateLoadBalancerPage() {
const { data: subnetsData } = useSuspenseQuery(ec2SubnetsQueryOptions)
const { data: sgsData } = useSuspenseQuery(ec2SecurityGroupsQueryOptions)
const { data: tgsData } = useSuspenseQuery(elbv2TargetGroupsQueryOptions)
const { data: imagesData } = useSuspenseQuery(ec2ImagesQueryOptions)
const wizardMutation = useCreateLoadBalancerWizard()
const lbImageImported = hasLbImage(imagesData.Images ?? [])
const [wizardResult, setWizardResult] = useState<LbWizardResult | null>(null)
const vpcs = vpcsData.Vpcs ?? []
@@ -223,18 +218,6 @@ export function CreateLoadBalancerPage() {
const groupedSubnets = groupSubnetsByAz(vpcSubnets)
if (!lbImageImported) {
return (
<>
<BackLink to="/ec2/describe-load-balancers">
Back to load balancers
</BackLink>
<PageHeading title="Create load balancer" />
<LbImageMissingBanner />
</>
)
}
return (
<>
<BackLink to="/ec2/describe-load-balancers">
@@ -2,12 +2,9 @@ import type { LoadBalancer } from "@aws-sdk/client-elastic-load-balancing-v2"
import { useSuspenseQuery } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { LbImageMissingBanner } from "@/components/elbv2/lb-image-missing-banner"
import { PageHeading } from "@/components/page-heading"
import { StateBadge } from "@/components/state-badge"
import { Button } from "@/components/ui/button"
import { hasLbImage } from "@/lib/system-managed"
import { ec2ImagesQueryOptions } from "@/queries/ec2"
import { elbv2LoadBalancersQueryOptions } from "@/queries/elbv2"
function formatCreatedAt(createdAt: Date | undefined): string {
@@ -20,17 +17,14 @@ function formatCreatedAt(createdAt: Date | undefined): string {
export function DescribeLoadBalancersPage() {
const navigate = useNavigate()
const { data } = useSuspenseQuery(elbv2LoadBalancersQueryOptions)
const { data: imagesData } = useSuspenseQuery(ec2ImagesQueryOptions)
const loadBalancers = data.LoadBalancers ?? []
const lbImageImported = hasLbImage(imagesData.Images ?? [])
return (
<>
<PageHeading
actions={
<Button
disabled={!lbImageImported}
onClick={async () =>
await navigate({ to: "/ec2/create-load-balancer" })
}
@@ -41,8 +35,6 @@ export function DescribeLoadBalancersPage() {
title="Load Balancers"
/>
{!lbImageImported && <LbImageMissingBanner />}
{loadBalancers.length > 0 ? (
<div className="overflow-x-auto rounded-lg border bg-card">
<table className="w-full text-sm">
@@ -1,4 +1,4 @@
import type { Image, SecurityGroup, Subnet, Vpc } from "@aws-sdk/client-ec2"
import type { SecurityGroup, Subnet, Vpc } from "@aws-sdk/client-ec2"
import type { TargetGroup } from "@aws-sdk/client-elastic-load-balancing-v2"
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
@@ -90,22 +90,12 @@ const EXISTING_TG: TargetGroup = {
Port: 80,
VpcId: VPC_ID,
}
const LB_IMAGE: Image = {
ImageId: "ami-lb",
Name: "lb-alpine-3.21.6-x86_64",
Tags: [{ Key: "spinifex:managed-by", Value: "elbv2" }],
}
function seedClient(options: { lbImageImported?: boolean } = {}) {
const { lbImageImported = true } = options
function seedClient() {
const qc = createTestQueryClient()
qc.setQueryData(["ec2", "vpcs"], { Vpcs: VPCS })
qc.setQueryData(["ec2", "subnets"], { Subnets: SUBNETS })
qc.setQueryData(["ec2", "securityGroups"], { SecurityGroups: SGS })
qc.setQueryData(["elbv2", "targetGroups"], { TargetGroups: [EXISTING_TG] })
qc.setQueryData(["ec2", "images"], {
Images: lbImageImported ? [LB_IMAGE] : [],
})
return qc
}
@@ -268,22 +258,4 @@ describe("create-load-balancer route", () => {
expect(screen.getByText(/arn:tg:new/)).toBeInTheDocument()
expect(routerState.navigate).not.toHaveBeenCalled()
})
it("blocks form render with import banner when LB AMI missing", () => {
renderWithClient(
<CreateLoadBalancerPage />,
seedClient({ lbImageImported: false }),
)
expect(
screen.getByText("Load balancer image not imported"),
).toBeInTheDocument()
expect(
screen.getByText(/spx admin images import --name lb-alpine/),
).toBeInTheDocument()
// Form inputs must not render
expect(
screen.queryByLabelText("Name", { selector: "#lb-name" }),
).not.toBeInTheDocument()
})
})
@@ -1,4 +1,3 @@
import type { Image } from "@aws-sdk/client-ec2"
import type { LoadBalancer } from "@aws-sdk/client-elastic-load-balancing-v2"
import { screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
@@ -79,12 +78,6 @@ const LBS: LoadBalancer[] = [
},
]
const LB_IMAGE: Image = {
ImageId: "ami-lb",
Name: "lb-alpine-3.21.6-x86_64",
Tags: [{ Key: "spinifex:managed-by", Value: "elbv2" }],
}
describe("describe-load-balancers list route", () => {
beforeEach(() => sdk.reset())
afterEach(() => vi.clearAllMocks())
@@ -92,7 +85,6 @@ describe("describe-load-balancers list route", () => {
it("renders load-balancer rows with resolved fields", () => {
const qc = createTestQueryClient()
qc.setQueryData(["elbv2", "loadBalancers"], { LoadBalancers: LBS })
qc.setQueryData(["ec2", "images"], { Images: [LB_IMAGE] })
renderWithClient(<DescribeLoadBalancersPage />, qc)
@@ -100,9 +92,6 @@ describe("describe-load-balancers list route", () => {
expect(screen.getByText("lb-one.example")).toBeInTheDocument()
expect(screen.getByText("application")).toBeInTheDocument()
expect(screen.getByText("internet-facing")).toBeInTheDocument()
expect(
screen.queryByText("Load balancer image not imported"),
).not.toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Create Load Balancer" }),
).not.toBeDisabled()
@@ -111,47 +100,9 @@ describe("describe-load-balancers list route", () => {
it("shows empty state when no load balancers", () => {
const qc = createTestQueryClient()
qc.setQueryData(["elbv2", "loadBalancers"], { LoadBalancers: [] })
qc.setQueryData(["ec2", "images"], { Images: [LB_IMAGE] })
renderWithClient(<DescribeLoadBalancersPage />, qc)
expect(screen.getByText("No load balancers found.")).toBeInTheDocument()
})
it("shows import banner and disables Create button when LB AMI missing", () => {
const qc = createTestQueryClient()
qc.setQueryData(["elbv2", "loadBalancers"], { LoadBalancers: [] })
qc.setQueryData(["ec2", "images"], { Images: [] })
renderWithClient(<DescribeLoadBalancersPage />, qc)
expect(
screen.getByText("Load balancer image not imported"),
).toBeInTheDocument()
expect(
screen.getByText(/spx admin images import --name lb-alpine/),
).toBeInTheDocument()
expect(
screen.getByRole("button", { name: "Create Load Balancer" }),
).toBeDisabled()
})
it("treats non-LB system images as missing", () => {
const qc = createTestQueryClient()
qc.setQueryData(["elbv2", "loadBalancers"], { LoadBalancers: [] })
qc.setQueryData(["ec2", "images"], {
Images: [
{
ImageId: "ami-customer",
Tags: [{ Key: "Name", Value: "ubuntu" }],
},
],
})
renderWithClient(<DescribeLoadBalancersPage />, qc)
expect(
screen.getByText("Load balancer image not imported"),
).toBeInTheDocument()
})
})
+60
View File
@@ -0,0 +1,60 @@
package vm
import (
"fmt"
"strings"
)
// IsMMIO reports whether machineType is a memory-mapped I/O bus machine (e.g.
// microvm). MMIO machines use virtio-*-device transports instead of
// virtio-*-pci, because they have no PCI bus.
func IsMMIO(machineType string) bool {
return strings.HasPrefix(machineType, "microvm")
}
// NetDevice returns the appropriate QEMU virtio-net device string for
// machineType, wiring netdev and mac. mac is omitted when empty.
func NetDevice(machineType, netdev, mac string) Device {
var transport string
if IsMMIO(machineType) {
transport = "virtio-net-device"
} else {
transport = "virtio-net-pci"
}
if mac != "" {
return Device{Value: fmt.Sprintf("%s,netdev=%s,mac=%s", transport, netdev, mac)}
}
return Device{Value: fmt.Sprintf("%s,netdev=%s", transport, netdev)}
}
// BlkDevice returns the appropriate QEMU virtio-blk device string for
// machineType. iothread and queues are omitted when empty/zero. bootIdx is
// only emitted for PCI machines (MMIO has no bootindex concept).
func BlkDevice(machineType, drive, iothread string, queues int, bootIdx int) Device {
var b strings.Builder
if IsMMIO(machineType) {
b.WriteString("virtio-blk-device")
} else {
b.WriteString("virtio-blk-pci")
}
fmt.Fprintf(&b, ",drive=%s", drive)
if iothread != "" {
fmt.Fprintf(&b, ",iothread=%s", iothread)
}
if queues > 0 {
fmt.Fprintf(&b, ",num-queues=%d", queues)
}
if !IsMMIO(machineType) && bootIdx > 0 {
fmt.Fprintf(&b, ",bootindex=%d", bootIdx)
}
return Device{Value: b.String()}
}
// RngDevice returns the appropriate QEMU virtio-rng device string for
// machineType.
func RngDevice(machineType string) Device {
if IsMMIO(machineType) {
return Device{Value: "virtio-rng-device"}
}
return Device{Value: "virtio-rng-pci"}
}
+86
View File
@@ -0,0 +1,86 @@
package vm
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsMMIO(t *testing.T) {
tests := []struct {
machineType string
want bool
}{
{"microvm", true},
{"microvm,x-option-roms=off", true},
{"q35", false},
{"virt", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.machineType, func(t *testing.T) {
assert.Equal(t, tt.want, IsMMIO(tt.machineType))
})
}
}
func TestNetDevice_PCI(t *testing.T) {
d := NetDevice("q35", "net0", "02:00:00:aa:bb:cc")
assert.Equal(t, "virtio-net-pci,netdev=net0,mac=02:00:00:aa:bb:cc", d.Value)
}
func TestNetDevice_PCI_NoMAC(t *testing.T) {
d := NetDevice("q35", "net0", "")
assert.Equal(t, "virtio-net-pci,netdev=net0", d.Value)
}
func TestNetDevice_MMIO(t *testing.T) {
d := NetDevice("microvm", "net0", "02:00:00:aa:bb:cc")
assert.Equal(t, "virtio-net-device,netdev=net0,mac=02:00:00:aa:bb:cc", d.Value)
}
func TestNetDevice_MMIO_NoMAC(t *testing.T) {
d := NetDevice("microvm,x-option-roms=off", "net0", "")
assert.Equal(t, "virtio-net-device,netdev=net0", d.Value)
}
func TestBlkDevice_PCI(t *testing.T) {
d := BlkDevice("q35", "os", "ioth-os", 4, 1)
assert.Equal(t, "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=4,bootindex=1", d.Value)
}
func TestBlkDevice_PCI_NoBootIdx(t *testing.T) {
d := BlkDevice("q35", "data", "ioth-data", 2, 0)
assert.Equal(t, "virtio-blk-pci,drive=data,iothread=ioth-data,num-queues=2", d.Value)
}
func TestBlkDevice_PCI_NoIOThread(t *testing.T) {
d := BlkDevice("q35", "data", "", 0, 0)
assert.Equal(t, "virtio-blk-pci,drive=data", d.Value)
}
func TestBlkDevice_MMIO(t *testing.T) {
d := BlkDevice("microvm", "os", "ioth-os", 4, 1)
// MMIO has no bootindex
assert.Equal(t, "virtio-blk-device,drive=os,iothread=ioth-os,num-queues=4", d.Value)
}
func TestBlkDevice_MMIO_NoIOThread(t *testing.T) {
d := BlkDevice("microvm,x-option-roms=off", "data", "", 0, 0)
assert.Equal(t, "virtio-blk-device,drive=data", d.Value)
}
func TestRngDevice_PCI(t *testing.T) {
d := RngDevice("q35")
assert.Equal(t, "virtio-rng-pci", d.Value)
}
func TestRngDevice_MMIO(t *testing.T) {
d := RngDevice("microvm")
assert.Equal(t, "virtio-rng-device", d.Value)
}
func TestRngDevice_MMIO_WithOptions(t *testing.T) {
d := RngDevice("microvm,x-option-roms=off,pic=off")
assert.Equal(t, "virtio-rng-device", d.Value)
}
+128 -74
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -170,6 +171,28 @@ func (m *Manager) launch(instance *VM) error {
return nil
}
// nbdkitPreExecWait returns how long startQEMU sleeps before exec'ing QEMU to
// give nbdkit time to bind its sockets. Direct-boot instances have no drives
// and no nbdkit, so the wait collapses to zero and the boot-time budget is
// preserved.
func nbdkitPreExecWait(directBoot bool) time.Duration {
if directBoot {
return 0
}
return 2 * time.Second
}
// qemuStartSettleWait returns the post-fork sleep that lets QEMU surface an
// immediate-start crash (bad cmdline, missing kernel) before we attempt QMP.
// Direct-boot binds the QMP socket within a few ms because there is no
// firmware, ROM, or block layer setup, so a full second is unnecessary.
func qemuStartSettleWait(directBoot bool) time.Duration {
if directBoot {
return 50 * time.Millisecond
}
return time.Second
}
// startQEMU launches the QEMU process for instance and waits for startup to
// confirm.
func (m *Manager) startQEMU(instance *VM) error {
@@ -191,78 +214,111 @@ func (m *Manager) startQEMU(instance *VM) error {
consoleLogPath := filepath.Join(runtimeDir, fmt.Sprintf("console-%s.log", instance.ID))
serialSocket := filepath.Join(runtimeDir, fmt.Sprintf("serial-%s.sock", instance.ID))
instance.Config = buildBaseVMConfig(instance.ID, pidFile, consoleLogPath, serialSocket, spec.Architecture, spec.VCPUs, spec.MemoryMiB)
if instance.DirectBoot {
// Direct-boot (microvm) path: vm.Config was pre-built by the launcher.
// Only fill in the runtime-generated paths that are not known until now.
// SerialSocket is intentionally omitted: microvm uses a file chardev
// so kernel output is captured without a socket client.
instance.Config.PIDFile = pidFile
instance.Config.ConsoleLogPath = consoleLogPath
} else {
instance.Config = buildBaseVMConfig(instance.ID, pidFile, consoleLogPath, serialSocket, spec.Architecture, spec.VCPUs, spec.MemoryMiB)
instance.EBSRequests.Mu.Lock()
drives, iothreads, devices, err := buildDrives(instance.EBSRequests.Requests, spec.VCPUs)
instance.EBSRequests.Mu.Unlock()
if err != nil {
return err
}
instance.Config.Drives = append(instance.Config.Drives, drives...)
instance.Config.IOThreads = append(instance.Config.IOThreads, iothreads...)
instance.Config.Devices = append(instance.Config.Devices, devices...)
if instance.ENIId != "" && m.deps.NetworkPlumber != nil {
spec := VPCTapSpec(instance.ENIId, instance.ENIMac)
if err := m.deps.NetworkPlumber.SetupTap(spec); err != nil {
slog.Error("Failed to set up tap device", "eni", instance.ENIId, "err", err)
return fmt.Errorf("setup tap device: %w", err)
}
tapName := spec.Name
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("tap,id=net0,ifname=%s,script=no,downscript=no", tapName),
})
instance.Config.Devices = append(instance.Config.Devices, Device{
Value: fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", instance.ENIMac),
})
slog.Info("VPC networking configured", "tap", tapName, "eni", instance.ENIId, "mac", instance.ENIMac)
if err := m.setupExtraENINICs(instance); err != nil {
instance.EBSRequests.Mu.Lock()
drives, iothreads, devices, err := buildDrives(instance.EBSRequests.Requests, spec.VCPUs, instance.Config.MachineType)
instance.EBSRequests.Mu.Unlock()
if err != nil {
return err
}
instance.Config.Drives = append(instance.Config.Drives, drives...)
instance.Config.IOThreads = append(instance.Config.IOThreads, iothreads...)
instance.Config.Devices = append(instance.Config.Devices, devices...)
}
if m.deps.DevNetworking {
m.appendDevHostfwdNIC(instance)
if instance.DirectBoot {
// Direct-boot (microvm) path: NetDevs and Devices are already set in
// the pre-built Config. Only create host-side tap devices for VPC ENIs
// so the kernel tap interfaces exist before QEMU opens them.
if instance.ENIId != "" && m.deps.NetworkPlumber != nil {
tapSpec := VPCTapSpec(instance.ENIId, instance.ENIMac)
if err := m.deps.NetworkPlumber.SetupTap(tapSpec); err != nil {
slog.Error("Failed to set up tap device (direct-boot)", "eni", instance.ENIId, "err", err)
return fmt.Errorf("setup tap device: %w", err)
}
slog.Info("VPC tap configured (direct-boot)", "tap", tapSpec.Name, "eni", instance.ENIId, "mac", instance.ENIMac)
for _, extra := range instance.ExtraENIs {
extraSpec := VPCTapSpec(extra.ENIID, extra.ENIMac)
if err := m.deps.NetworkPlumber.SetupTap(extraSpec); err != nil {
slog.Error("Failed to set up extra ENI tap (direct-boot)", "eni", extra.ENIID, "err", err)
return fmt.Errorf("setup tap device for extra ENI %s: %w", extra.ENIID, err)
}
}
}
if instance.MgmtMAC != "" && m.deps.NetworkPlumber != nil {
mgmtTap := MgmtTapName(instance.ID)
mgmtBridge := "br-mgmt"
if err := m.deps.NetworkPlumber.SetupTap(TapSpec{Name: mgmtTap, Bridge: mgmtBridge}); err != nil {
slog.Error("Failed to set up mgmt tap (direct-boot)", "tap", mgmtTap, "err", err)
return fmt.Errorf("setup mgmt tap: %w", err)
}
slog.Info("Mgmt tap configured (direct-boot)", "tap", mgmtTap, "mac", instance.MgmtMAC, "ip", instance.MgmtIP, "instanceId", instance.ID)
}
} else {
sshDebugAddr, err := viperblock.FindFreePort()
if err != nil {
slog.Error("Failed to find free port", "err", err)
return err
}
_, sshDebugPort, err := net.SplitHostPort(sshDebugAddr)
if err != nil {
slog.Error("Failed to parse port from address", "addr", sshDebugAddr, "err", err)
return fmt.Errorf("parse port from %s: %w", sshDebugAddr, err)
if instance.ENIId != "" && m.deps.NetworkPlumber != nil {
spec := VPCTapSpec(instance.ENIId, instance.ENIMac)
if err := m.deps.NetworkPlumber.SetupTap(spec); err != nil {
slog.Error("Failed to set up tap device", "eni", instance.ENIId, "err", err)
return fmt.Errorf("setup tap device: %w", err)
}
tapName := spec.Name
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("tap,id=net0,ifname=%s,script=no,downscript=no", tapName),
})
instance.Config.Devices = append(instance.Config.Devices, NetDevice(instance.Config.MachineType, "net0", instance.ENIMac))
slog.Info("VPC networking configured", "tap", tapName, "eni", instance.ENIId, "mac", instance.ENIMac)
if err := m.setupExtraENINICs(instance); err != nil {
return err
}
if m.deps.DevNetworking {
m.appendDevHostfwdNIC(instance)
}
} else {
sshDebugAddr, err := viperblock.FindFreePort()
if err != nil {
slog.Error("Failed to find free port", "err", err)
return err
}
_, sshDebugPort, err := net.SplitHostPort(sshDebugAddr)
if err != nil {
slog.Error("Failed to parse port from address", "addr", sshDebugAddr, "err", err)
return fmt.Errorf("parse port from %s: %w", sshDebugAddr, err)
}
bindIP := m.deps.BindHost
if bindIP == "" || bindIP == "0.0.0.0" {
bindIP = "127.0.0.1"
}
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("user,id=net0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
})
instance.Config.Devices = append(instance.Config.Devices, NetDevice(instance.Config.MachineType, "net0", ""))
}
bindIP := m.deps.BindHost
if bindIP == "" || bindIP == "0.0.0.0" {
bindIP = "127.0.0.1"
if instance.MgmtMAC != "" {
mgmtTap := MgmtTapName(instance.ID)
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("tap,id=mgmt0,ifname=%s,script=no,downscript=no", mgmtTap),
})
instance.Config.Devices = append(instance.Config.Devices, NetDevice(instance.Config.MachineType, "mgmt0", instance.MgmtMAC))
slog.Info("Management NIC configured", "tap", mgmtTap, "mac", instance.MgmtMAC, "ip", instance.MgmtIP, "instanceId", instance.ID)
}
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("user,id=net0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
})
instance.Config.Devices = append(instance.Config.Devices, Device{
Value: "virtio-net-pci,netdev=net0",
})
instance.Config.Devices = append(instance.Config.Devices, RngDevice(instance.Config.MachineType))
}
if instance.MgmtMAC != "" {
mgmtTap := MgmtTapName(instance.ID)
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("tap,id=mgmt0,ifname=%s,script=no,downscript=no", mgmtTap),
})
instance.Config.Devices = append(instance.Config.Devices, Device{
Value: fmt.Sprintf("virtio-net-pci,netdev=mgmt0,mac=%s", instance.MgmtMAC),
})
slog.Info("Management NIC configured", "tap", mgmtTap, "mac", instance.MgmtMAC, "ip", instance.MgmtIP, "instanceId", instance.ID)
}
instance.Config.Devices = append(instance.Config.Devices, Device{Value: "virtio-rng-pci"})
for i, addr := range instance.GPUPCIAddresses {
xvga := "off"
if instance.GPUXVGAEnabled {
@@ -284,7 +340,7 @@ func (m *Manager) startQEMU(instance *VM) error {
// Wait briefly for nbdkit to start.
// TODO: Improve, confirm nbdkit started for each volume.
time.Sleep(2 * time.Second)
time.Sleep(nbdkitPreExecWait(instance.DirectBoot))
processChan := make(chan int, 1)
exitChan := make(chan int, 1)
@@ -368,7 +424,9 @@ func (m *Manager) startQEMU(instance *VM) error {
return fmt.Errorf("failed to start qemu")
}
time.Sleep(1 * time.Second)
// Catch immediate-start crashes (bad cmdline, missing kernel, etc.) before
// attempting QMP.
time.Sleep(qemuStartSettleWait(instance.DirectBoot))
select {
case exitErr := <-exitChan:
@@ -416,7 +474,8 @@ func (m *Manager) appendDevHostfwdNIC(instance *VM) {
if bindIP == "" || bindIP == "0.0.0.0" {
bindIP = "127.0.0.1"
}
netdevVal := fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort)
var nb strings.Builder
fmt.Fprintf(&nb, "user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort)
if instance.ExtraHostfwd != nil {
for guestPort := range instance.ExtraHostfwd {
@@ -435,17 +494,15 @@ func (m *Manager) appendDevHostfwdNIC(instance *VM) {
slog.Warn("DEV_NETWORKING: failed to convert extra hostfwd port", "hostPort", hostPort, "err", convErr)
continue
}
netdevVal += fmt.Sprintf(",hostfwd=tcp:%s:%s-:%d", bindIP, hostPort, guestPort)
fmt.Fprintf(&nb, ",hostfwd=tcp:%s:%s-:%d", bindIP, hostPort, guestPort)
instance.ExtraHostfwd[guestPort] = hostPortInt
slog.Info("DEV_NETWORKING: extra hostfwd", "guestPort", guestPort, "hostPort", hostPort, "instanceId", instance.ID)
}
}
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{Value: netdevVal})
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{Value: nb.String()})
devMac := GenerateDevMAC(instance.ID)
instance.Config.Devices = append(instance.Config.Devices, Device{
Value: fmt.Sprintf("virtio-net-pci,netdev=dev0,mac=%s", devMac),
})
instance.Config.Devices = append(instance.Config.Devices, NetDevice(instance.Config.MachineType, "dev0", devMac))
slog.Info("DEV_NETWORKING: added dev NIC with SSH hostfwd",
"bindIP", bindIP, "port", sshDebugPort, "mac", devMac, "instanceId", instance.ID)
}
@@ -586,7 +643,7 @@ func buildBaseVMConfig(instanceID, pidFile, consoleLogPath, serialSocket, archit
// buildDrives converts EBS volume requests into QEMU drive, iothread, and
// device configurations. Returns an error if any non-EFI volume is missing
// its NBDURI.
func buildDrives(requests []types.EBSRequest, cpuCount int) ([]Drive, []IOThread, []Device, error) {
func buildDrives(requests []types.EBSRequest, cpuCount int, machineType string) ([]Drive, []IOThread, []Device, error) {
var drives []Drive
var iothreads []IOThread
var devices []Device
@@ -611,10 +668,7 @@ func buildDrives(requests []types.EBSRequest, cpuCount int) ([]Drive, []IOThread
iothreadID := "ioth-os"
iothreads = append(iothreads, IOThread{ID: iothreadID})
devices = append(devices, Device{
Value: fmt.Sprintf("virtio-blk-pci,drive=%s,iothread=%s,num-queues=%d,bootindex=1",
drive.ID, iothreadID, cpuCount),
})
devices = append(devices, BlkDevice(machineType, drive.ID, iothreadID, cpuCount, 1))
}
if v.CloudInit {
+174 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/stretchr/testify/assert"
@@ -42,6 +43,7 @@ func TestBuildDrives(t *testing.T) {
name string
requests []types.EBSRequest
cpuCount int
machineType string
wantDrives []Drive
wantIOThreads []IOThread
wantDevices []Device
@@ -112,7 +114,11 @@ func TestBuildDrives(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
drives, iothreads, devices, err := buildDrives(tt.requests, tt.cpuCount)
machineType := tt.machineType
if machineType == "" {
machineType = "q35"
}
drives, iothreads, devices, err := buildDrives(tt.requests, tt.cpuCount, machineType)
if tt.wantErr != "" {
require.Error(t, err)
@@ -605,3 +611,170 @@ func TestAppendDevHostfwdNIC_ExtraHostfwd_HappyPath(t *testing.T) {
require.Len(t, instance.Config.Devices, 1)
assert.Equal(t, fmt.Sprintf("virtio-net-pci,netdev=dev0,mac=%s", GenerateDevMAC("i-extra-ok")), instance.Config.Devices[0].Value)
}
// ---------------------------------------------------------------------------
// startQEMU — DirectBoot branch
//
// We call startQEMU directly (same package) and force early returns via tap
// errors so we cover the DirectBoot config-assignment lines and the tap setup
// paths without spawning a real QEMU process or sleeping 2 s.
// ---------------------------------------------------------------------------
// directBootManager wires the minimum Deps for startQEMU to reach the
// DirectBoot tap-setup block: InstanceTypes resolver + NetworkPlumber.
func directBootManager(t *testing.T, plumber NetworkPlumber) *Manager {
t.Helper()
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
resolver := fakeInstanceTypeResolver{
"t3.nano": {Architecture: "x86_64", VCPUs: 1, MemoryMiB: 512},
}
return NewManagerWithDeps(Deps{
InstanceTypes: resolver,
NetworkPlumber: plumber,
})
}
// TestStartQEMU_DirectBoot_PrimaryTapError verifies that when the
// NetworkPlumber fails to set up the primary ENI tap, startQEMU returns a
// wrapped error. This also drives the DirectBoot config-assignment block
// (PIDFile / ConsoleLogPath / SerialSocket) before the early return.
func TestStartQEMU_DirectBoot_PrimaryTapError(t *testing.T) {
tapErr := errors.New("tap setup failed")
plumber := &fakeNetworkPlumber{setupErr: tapErr}
m := directBootManager(t, plumber)
instance := &VM{
ID: "i-db-tap-err",
InstanceType: "t3.nano",
DirectBoot: true,
ENIId: "eni-000000000000aaaa",
ENIMac: "02:aa:bb:cc:dd:01",
}
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "setup tap device")
// PIDFile and ConsoleLogPath are set before the tap call.
// SerialSocket is intentionally empty for direct-boot (file chardev used instead).
assert.NotEmpty(t, instance.Config.PIDFile)
assert.NotEmpty(t, instance.Config.ConsoleLogPath)
assert.Empty(t, instance.Config.SerialSocket)
// Exactly one SetupTap call (the primary ENI tap).
require.Len(t, plumber.setupCalls, 1)
}
// TestStartQEMU_DirectBoot_ExtraENITapError verifies that a tap failure on a
// secondary ENI returns an error without starting QEMU.
func TestStartQEMU_DirectBoot_ExtraENITapError(t *testing.T) {
callCount := 0
plumber := &fakeNetworkPlumber{}
// Override SetupTap to fail only on the second call (extra ENI).
realSetupCalls := &plumber.setupCalls
_ = realSetupCalls
// Use a scripted plumber: success for primary, error for extra.
scriptedPlumber := &scriptedNetworkPlumber{
errs: []error{nil, errors.New("extra tap failed")},
}
m := directBootManager(t, scriptedPlumber)
_ = callCount
instance := &VM{
ID: "i-db-extra-tap-err",
InstanceType: "t3.nano",
DirectBoot: true,
ENIId: "eni-000000000000bbbb",
ENIMac: "02:aa:bb:cc:dd:02",
ExtraENIs: []ExtraENI{
{ENIID: "eni-000000000000cccc", ENIMac: "02:aa:bb:cc:dd:03"},
},
}
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "extra ENI")
assert.Len(t, scriptedPlumber.calls, 2)
}
// TestStartQEMU_DirectBoot_MgmtTapError verifies that a tap failure on the
// management NIC returns a wrapped error.
func TestStartQEMU_DirectBoot_MgmtTapError(t *testing.T) {
// No primary ENI → skips the ENI tap block; MgmtMAC set → enters mgmt block.
plumber := &fakeNetworkPlumber{setupErr: errors.New("mgmt tap failed")}
m := directBootManager(t, plumber)
instance := &VM{
ID: "i-db-mgmt-err",
InstanceType: "t3.nano",
DirectBoot: true,
MgmtMAC: "02:aa:bb:cc:dd:ff",
}
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "mgmt tap")
require.Len(t, plumber.setupCalls, 1)
assert.Equal(t, MgmtTapName("i-db-mgmt-err"), plumber.setupCalls[0].Name)
assert.Equal(t, "br-mgmt", plumber.setupCalls[0].Bridge)
}
// TestStartQEMU_DirectBoot_NoENI_NoMgmt_InstanceTypeNotFound verifies that
// startQEMU returns an error when the instance type is absent from the resolver,
// exercising the early-return before any DirectBoot config is written.
func TestStartQEMU_DirectBoot_InstanceTypeNotFound(t *testing.T) {
plumber := &fakeNetworkPlumber{}
m := directBootManager(t, plumber)
instance := &VM{
ID: "i-db-unknown-type",
InstanceType: "x9.enormous",
DirectBoot: true,
}
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "x9.enormous")
assert.Empty(t, plumber.setupCalls)
}
// scriptedNetworkPlumber returns errors from a pre-loaded slice, one per call.
type scriptedNetworkPlumber struct {
calls []TapSpec
errs []error
idx int
}
func (p *scriptedNetworkPlumber) SetupTap(spec TapSpec) error {
p.calls = append(p.calls, spec)
if p.idx < len(p.errs) {
err := p.errs[p.idx]
p.idx++
return err
}
return nil
}
func (p *scriptedNetworkPlumber) CleanupTap(_ string) error { return nil }
var _ NetworkPlumber = (*scriptedNetworkPlumber)(nil)
// TestNbdkitPreExecWait locks in the per-mode pre-exec sleep budget: direct-boot
// is zero (no drives, no nbdkit) and the PC-machine path keeps the 2 s wait that
// the rest of the launch flow depends on.
func TestNbdkitPreExecWait(t *testing.T) {
assert.Equal(t, time.Duration(0), nbdkitPreExecWait(true))
assert.Equal(t, 2*time.Second, nbdkitPreExecWait(false))
}
// TestQemuStartSettleWait locks in the per-mode post-fork settle: direct-boot
// trims to 50 ms (QMP socket binds within a few ms when there is no firmware,
// ROM, or block layer setup) and the PC-machine path retains the historical
// 1 s buffer that catches bad-cmdline crashes before the QMP dial.
func TestQemuStartSettleWait(t *testing.T) {
assert.Equal(t, 50*time.Millisecond, qemuStartSettleWait(true))
assert.Equal(t, time.Second, qemuStartSettleWait(false))
}
+47
View File
@@ -0,0 +1,47 @@
package vm
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMicroVMDirectBoot asserts that a microvm Config with direct-boot fields
// set produces the expected QEMU args and does not include PCI-specific flags
// such as -bios or pcie-root-port.
func TestMicroVMDirectBoot(t *testing.T) {
cfg := Config{
CPUCount: 2,
Memory: 512,
Architecture: "x86_64",
MachineType: "microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on",
KernelImage: "/path/vmlinuz",
Initrd: "/path/initramfs.cpio.gz",
KernelCmdline: "console=ttyS0 panic=1",
FwCfg: []FwCfgEntry{
{Name: "opt/spinifex/netcfg", File: "/tmp/nc.tmp"},
},
}
cmd, err := cfg.Execute()
require.NoError(t, err)
require.NotNil(t, cmd)
args := cmd.Args[1:]
// Machine type must be set
assert.Equal(t, "microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on", argValue(args, "-M"))
// Direct-boot flags must be present
assert.Equal(t, "/path/vmlinuz", argValue(args, "-kernel"))
assert.Equal(t, "/path/initramfs.cpio.gz", argValue(args, "-initrd"))
assert.Equal(t, "console=ttyS0 panic=1", argValue(args, "-append"))
assert.Equal(t, "name=opt/spinifex/netcfg,file=/tmp/nc.tmp", argValue(args, "-fw_cfg"))
// PCI-specific flags must NOT appear
assert.False(t, argExists(args, "-bios"), "-bios must not appear for microvm direct-boot")
for _, a := range args {
assert.NotContains(t, a, "pcie-root-port", "pcie-root-port must not appear for microvm")
}
}
+1 -3
View File
@@ -86,9 +86,7 @@ func (m *Manager) setupExtraENINICs(instance *VM) error {
instance.Config.NetDevs = append(instance.Config.NetDevs, NetDev{
Value: fmt.Sprintf("tap,id=%s,ifname=%s,script=no,downscript=no", netID, extraTapName),
})
instance.Config.Devices = append(instance.Config.Devices, Device{
Value: fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s", netID, extra.ENIMac),
})
instance.Config.Devices = append(instance.Config.Devices, NetDevice(instance.Config.MachineType, netID, extra.ENIMac))
slog.Info("Extra VPC NIC configured",
"tap", extraTapName, "eni", extra.ENIID, "mac", extra.ENIMac, "subnet", extra.SubnetID)
}
+7
View File
@@ -3,6 +3,7 @@ package vm
import (
"fmt"
"log/slog"
"os"
"sync"
"time"
@@ -333,6 +334,12 @@ func (m *Manager) shutdownAndUnmount(instance *VM) {
slog.Error("Volume unmount failed", "id", instance.ID, "err", err)
}
}
for _, fw := range instance.Config.FwCfg {
if err := os.Remove(fw.File); err != nil && !os.IsNotExist(err) {
slog.Warn("Failed to remove fw_cfg temp file", "file", fw.File, "id", instance.ID, "err", err)
}
}
}
// cleanupTapDevices removes the primary VPC tap, every extra ENI tap, and
+46 -4
View File
@@ -119,6 +119,12 @@ type VM struct {
// filters out tagged VMs from customer-facing listings.
ManagedBy string `json:"managed_by,omitempty"`
// DirectBoot signals that this VM was configured with a pre-built
// vm.Config by the launcher (microvm direct-boot path). When true,
// startQEMU skips buildBaseVMConfig and uses the existing Config
// instead. False for all PC-machine VMs.
DirectBoot bool `json:"direct_boot,omitempty"`
// GPUPCIAddresses holds the PCI addresses of all GPUs bound to this instance via
// VFIO (e.g. ["0000:03:00.0"]). Empty for non-GPU instances. Single-GPU instances
// have one entry; multi-GPU instances have one per assigned GPU.
@@ -195,6 +201,17 @@ type Config struct {
// UseUEFI requests OVMF firmware instead of the default SeaBIOS for x86_64 VMs.
// Falls back silently to SeaBIOS if OVMF is not installed on the host.
UseUEFI bool `json:"use_uefi,omitempty"`
KernelImage string `json:"kernel_image,omitempty"` // path to vmlinuz; emits -kernel when set
Initrd string `json:"initrd,omitempty"` // path to initramfs; emits -initrd when set
KernelCmdline string `json:"kernel_cmdline,omitempty"` // emits -append when set
FwCfg []FwCfgEntry `json:"fw_cfg,omitempty"` // each emits -fw_cfg name=<name>,file=<path>
}
// FwCfgEntry describes a single QEMU firmware configuration file entry.
type FwCfgEntry struct {
Name string `json:"name"`
File string `json:"file"`
}
func (cfg *Config) Execute() (*exec.Cmd, error) {
@@ -230,10 +247,19 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-display", "none")
}
if cfg.SerialSocket != "" && cfg.ConsoleLogPath != "" {
if IsMMIO(cfg.MachineType) && cfg.ConsoleLogPath != "" {
// microvm with isa-serial=on: the machine creates the ISA serial device.
// Use -chardev file so output is captured without a socket client, then
// -serial chardev:console0 wires it to the existing device (not a new one).
// Do NOT use -device isa-serial here — that adds a second device (ttyS1)
// while the kernel talks to ttyS0.
args = append(args, "-chardev", fmt.Sprintf("file,id=console0,path=%s", cfg.ConsoleLogPath))
args = append(args, "-serial", "chardev:console0")
} else if cfg.SerialSocket != "" && cfg.ConsoleLogPath != "" {
chardevOpts := fmt.Sprintf("socket,id=console0,path=%s,server=on,wait=off,logfile=%s",
cfg.SerialSocket, cfg.ConsoleLogPath)
args = append(args, "-chardev", chardevOpts, "-serial", "chardev:console0")
args = append(args, "-chardev", chardevOpts)
args = append(args, "-serial", "chardev:console0")
}
if cfg.CPUCount > 0 {
@@ -252,8 +278,8 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-object", fmt.Sprintf("iothread,id=%s", iot.ID))
}
if len(cfg.Drives) == 0 {
return nil, fmt.Errorf("at least one drive is required")
if len(cfg.Drives) == 0 && cfg.KernelImage == "" {
return nil, fmt.Errorf("at least one drive or a kernel image is required")
}
for _, drive := range cfg.Drives {
@@ -296,6 +322,22 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
args = append(args, "-netdev", netdev.Value)
}
if cfg.KernelImage != "" {
args = append(args, "-kernel", cfg.KernelImage)
}
if cfg.Initrd != "" {
args = append(args, "-initrd", cfg.Initrd)
}
if cfg.KernelCmdline != "" {
args = append(args, "-append", cfg.KernelCmdline)
}
for _, fw := range cfg.FwCfg {
args = append(args, "-fw_cfg", fmt.Sprintf("name=%s,file=%s", fw.Name, fw.File))
}
var qemuArchitecture string
switch cfg.Architecture {
+70 -2
View File
@@ -3,6 +3,7 @@ package vm
import (
"os"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -34,9 +35,9 @@ func TestExecute(t *testing.T) {
cmd, err = cfg.Execute()
// Expect error, at least one drive required
// Expect error, at least one drive or kernel image required
assert.Error(t, err)
assert.ErrorContains(t, err, "at least one drive is required")
assert.ErrorContains(t, err, "at least one drive or a kernel image is required")
assert.Nil(t, cmd)
cfg.Drives = []Drive{
@@ -231,6 +232,49 @@ func argExists(args []string, flag string) bool {
return slices.Contains(args, flag)
}
// allArgValues returns all values following flag (every occurrence).
func allArgValues(args []string, flag string) []string {
var out []string
for i, a := range args {
if a == flag && i+1 < len(args) {
out = append(out, args[i+1])
}
}
return out
}
func TestExecute_DriveAndKernelInvariant(t *testing.T) {
base := Config{
CPUCount: 2,
Memory: 1024,
Architecture: "x86_64",
}
t.Run("drives-only is OK", func(t *testing.T) {
cfg := base
cfg.Drives = []Drive{{File: "disk.img", Format: "raw"}}
cmd, err := cfg.Execute()
assert.NoError(t, err)
assert.NotNil(t, cmd)
})
t.Run("kernel-only is OK", func(t *testing.T) {
cfg := base
cfg.KernelImage = "/boot/vmlinuz"
cmd, err := cfg.Execute()
assert.NoError(t, err)
assert.NotNil(t, cmd)
})
t.Run("both-empty returns error", func(t *testing.T) {
cfg := base
cmd, err := cfg.Execute()
assert.Error(t, err)
assert.ErrorContains(t, err, "at least one drive or a kernel image")
assert.Nil(t, cmd)
})
}
func TestResetNodeLocalState(t *testing.T) {
v := &VM{
ID: "i-abc123",
@@ -306,6 +350,30 @@ func TestExecute_SerialSocketAndConsoleLog(t *testing.T) {
})
}
func TestExecute_MicrovmFileChardev(t *testing.T) {
cfg := Config{
CPUCount: 1,
Memory: 512,
Architecture: "x86_64",
MachineType: "microvm,x-option-roms=off",
ConsoleLogPath: "/var/log/console.log",
KernelImage: "/boot/vmlinuz",
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
require.NotNil(t, cmd)
args := cmd.Args[1:]
chardev := argValue(args, "-chardev")
assert.Equal(t, "file,id=console0,path=/var/log/console.log", chardev)
// -serial chardev:console0 attaches to the isa-serial=on device (ttyS0).
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
// No explicit isa-serial device addition (would create ttyS1, not ttyS0).
devs := strings.Join(allArgValues(args, "-device"), " ")
assert.NotContains(t, devs, "isa-serial")
}
func TestExecute_ARM64_Q35(t *testing.T) {
const uefiPath = "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd"
if _, err := os.Stat(uefiPath); err != nil {
+33 -9
View File
@@ -12,6 +12,21 @@ set -e
# ./tests/e2e/run-lb-e2e.sh # internal-only (single-node)
# ./tests/e2e/run-lb-e2e.sh --peer <ip> # all 4 variants (multi-node, legacy)
# ./tests/e2e/run-lb-e2e.sh --nodes <ip1> <ip2> ... # all 4 variants (multi-node, preferred)
# ./tests/e2e/run-lb-e2e.sh --microvm # same as above but with microvm.elbv2_enabled=true
#
# CI dual-path: this script is run twice in CI for microvm validation:
# 1. Default (microvm.elbv2_enabled=false) — baseline PC-machine path
# 2. With --microvm flag — direct-boot microvm path
# Both runs must pass identical assertions.
#
# Density test (manual, not CI-automated):
# LB_COUNT=50 ./tests/e2e/run-lb-e2e.sh --microvm
# Launches 50 LBs; asserts all reach Active; total QEMU fleet RSS logged.
#
# Customer-VM regression: run-e2e.sh (RunInstances + SSH) is run separately
# with microvm.elbv2_enabled=true to confirm customer VMs are unaffected.
# LaunchSystemInstance with DirectBoot=true is only reached by the ELBv2
# service; RunInstances takes the PC-machine path unconditionally.
cd "$(dirname "$0")/../.."
@@ -31,13 +46,20 @@ fi
# ==========================================================================
PEER_NODE_IP=""
ALL_NODE_IPS=()
MICROVM_MODE=false
while [ $# -gt 0 ]; do
case "$1" in
--peer) PEER_NODE_IP="$2"; shift 2 ;;
--nodes) shift; while [ $# -gt 0 ] && [[ "$1" != --* ]]; do ALL_NODE_IPS+=("$1"); shift; done ;;
*) echo "Unknown arg: $1"; exit 1 ;;
--peer) PEER_NODE_IP="$2"; shift 2 ;;
--nodes) shift; while [ $# -gt 0 ] && [[ "$1" != --* ]]; do ALL_NODE_IPS+=("$1"); shift; done ;;
--microvm) MICROVM_MODE=true; shift ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
if [ "$MICROVM_MODE" = true ]; then
export SPINIFEX_MICROVM_ELBV2_ENABLED=true
echo "[run-lb-e2e] microvm mode: SPINIFEX_MICROVM_ELBV2_ENABLED=true"
fi
# --nodes supersedes --peer; fall back to --peer for backwards compat
if [ ${#ALL_NODE_IPS[@]} -gt 0 ] && [ -z "$PEER_NODE_IP" ]; then
PEER_NODE_IP="${ALL_NODE_IPS[1]:-}"
@@ -776,7 +798,7 @@ if [ "$PEER_AVAILABLE" = true ]; then
# test_http_from <node_ip> <url> <label>
test_http_from() {
local node_ip="$1" url="$2" label="$3"
echo "Testing $label at $url from $node_ip..."
echo "Testing $label at $url from $node_ip..." >&2
local ok=false
for attempt in $(seq 1 20); do
local result
@@ -788,17 +810,16 @@ if [ "$PEER_AVAILABLE" = true ]; then
if echo "$result" | grep -q "instance_id"; then
ok=true; break
fi
echo " Attempt $attempt/20: not yet responding..."
echo " Attempt $attempt/20: not yet responding..." >&2
sleep 5
done
if [ "$ok" = true ]; then pass "$label reachable"; else fail "$label unreachable at $url from $node_ip"; fi
echo "$ok"
}
# test_tcp_from <node_ip> <ip> <port> <label>
test_tcp_from() {
local node_ip="$1" target_ip="$2" port="$3" label="$4"
echo "Testing $label at ${target_ip}:${port} from $node_ip..."
echo "Testing $label at ${target_ip}:${port} from $node_ip..." >&2
local ok=false
for attempt in $(seq 1 20); do
local probe
@@ -808,16 +829,16 @@ if [ "$PEER_AVAILABLE" = true ]; then
probe=$(peer_ssh "$node_ip" "echo '' | nc -w5 '$target_ip' '$port'" 2>/dev/null || true)
fi
if [ -n "$probe" ]; then ok=true; break; fi
echo " Attempt $attempt/20..."
echo " Attempt $attempt/20..." >&2
sleep 5
done
if [ "$ok" = true ]; then pass "$label reachable"; else fail "$label unreachable at ${target_ip}:${port} from $node_ip"; fi
echo "$ok"
}
# --- ALB: local connectivity (from hosting node) ---
ALB_URL="http://${ALB_PUBLIC_IP}:80"
ALB_LOCAL_OK=$(test_http_from "$ALB_HOST" "$ALB_URL" "ALB inet (local)")
if [ "$ALB_LOCAL_OK" = "true" ]; then pass "ALB inet (local) reachable"; else fail "ALB inet (local) unreachable at $ALB_URL from $ALB_HOST"; fi
# ALB traffic test from hosting node
if [ "$ALB_LOCAL_OK" = "true" ]; then
@@ -845,6 +866,7 @@ if [ "$PEER_AVAILABLE" = true ]; then
# --- NLB: local connectivity (from hosting node) ---
if [ -n "$NLB_PUBLIC_IP" ] && [ "$NLB_PUBLIC_IP" != "null" ]; then
NLB_LOCAL_OK=$(test_tcp_from "$NLB_HOST" "$NLB_PUBLIC_IP" 9000 "NLB inet (local)")
if [ "$NLB_LOCAL_OK" = "true" ]; then pass "NLB inet (local) reachable"; else fail "NLB inet (local) unreachable at ${NLB_PUBLIC_IP}:9000 from $NLB_HOST"; fi
if [ "$NLB_LOCAL_OK" = "true" ]; then
if [ "$NLB_HOST" = "$LOCAL_IP" ]; then
run_tcp_traffic_test "$NLB_PUBLIC_IP" 9000 "NLB inet (local)"
@@ -870,6 +892,7 @@ if [ "$PEER_AVAILABLE" = true ]; then
# --- ALB: remote connectivity (from a different node) ---
if [ -n "$ALB_REMOTE" ]; then
ALB_REMOTE_OK=$(test_http_from "$ALB_REMOTE" "$ALB_URL" "ALB inet (remote)")
if [ "$ALB_REMOTE_OK" = "true" ]; then pass "ALB inet (remote) reachable"; else fail "ALB inet (remote) unreachable at $ALB_URL from $ALB_REMOTE"; fi
if [ "$ALB_REMOTE_OK" = "true" ]; then
declare -A ALB_REMOTE_COUNTS
ALB_REMOTE_TOTAL=0
@@ -891,6 +914,7 @@ if [ "$PEER_AVAILABLE" = true ]; then
# --- NLB: remote connectivity (from a different node) ---
if [ -n "$NLB_REMOTE" ] && [ -n "$NLB_PUBLIC_IP" ] && [ "$NLB_PUBLIC_IP" != "null" ]; then
NLB_REMOTE_OK=$(test_tcp_from "$NLB_REMOTE" "$NLB_PUBLIC_IP" 9000 "NLB inet (remote)")
if [ "$NLB_REMOTE_OK" = "true" ]; then pass "NLB inet (remote) reachable"; else fail "NLB inet (remote) unreachable at ${NLB_PUBLIC_IP}:9000 from $NLB_REMOTE"; fi
if [ "$NLB_REMOTE_OK" = "true" ]; then
declare -A NLB_REMOTE_COUNTS
NLB_REMOTE_TOTAL=0