Commit Graph

741 Commits

Author SHA1 Message Date
joshsiv-mulga 7eb388a2c2 feat(daemon): local-first instance state persistence (1a)
Make /var/lib/spinifex/state/instance-state.json the source of truth for a
node's VMs; JetStream KV becomes a best-effort cluster cache. The daemon now
survives KV unavailability without losing instance state.

- New local_state.go: atomic write (tmp+fsync+rename), schema_version gate,
  missing-file = fresh install, corrupt = refuse start.
- Daemon.WriteState writes the local file under Instances.Mu, then calls
  jsManager.WriteStateBestEffort with a 1s timeout. KV failures log and
  continue; clients never see "KV down" during partition.
- Daemon.LoadState reads from the local file only — no KV fallback (a fresh
  node has empty KV anyway, so silent fallback would mask bugs).
- restoreInstances now returns error and Start() aborts on corrupt local
  state rather than silently starting empty.
- jsManager.WriteState no longer self-locks (caller holds Mu); added
  WriteStateBestEffort and shared marshalInstanceState helper.

Reconnect-driven KV resync deferred to siv-16 (1c owns the callback wiring).

Bead: mulga-siv-15
Plan: docs/development/feature/daemon-local-state-persistence.md
2026-04-27 14:34:12 +10:00
joshsiv-mulga 719ac63cc9 refactor(daemon): extract Describe instance handlers to service (1b-pre phase 1)
Collapse the Describe family in daemon_handlers_instance.go to thin Pattern A
wrappers over a transport-neutral InstanceServiceImpl. This brings the read
path in line with the rest of the codebase and is the prerequisite for 1b's
local HTTP API (mulga-siv-14, daemon-local-autonomy plan).

Extracted: DescribeInstances, DescribeInstanceTypes, DescribeInstanceAttribute,
DescribeStoppedInstances, DescribeTerminatedInstances. Filter helpers
(instanceMatchesFilters, matchTagKey/Value, DescribeInstancesValidFilters,
IsInstanceVisible) move to the instance package alongside the service methods.

InstanceService gains the new methods and two dep interfaces
(ResourceCapacityProvider, StoppedInstanceStore) so the daemon's ResourceManager
and JetStreamManager can plug in without leaking concrete types. NATSInstanceService
returns a clear error for the fan-out describes (DescribeInstances,
DescribeInstanceTypes) since cluster-wide aggregation lives in the gateway's
scatter-gather helpers, not a single-node NATS request.

Behaviour changes: malformed JSON on DescribeInstanceAttribute now responds
with ValidationError (matching every other Pattern A handler) instead of
ServerInternal. Test updated.

Mutators (RunInstances, ModifyInstanceAttribute, StartStoppedInstance,
TerminateStoppedInstance, sub-handlers) remain Pattern B for now; follow-up
phases of mulga-siv-14 will collapse them.
2026-04-27 13:51:16 +10:00
Julian Sommer eeb7ebc5db chore: enable nightly nginx-alb workbook (#161) 2026-04-24 17:14:57 +10:00
Julian Sommer 5126a1df8d tweak: README.md link 2026-04-24 12:34:04 +10:00
Julian Sommer c7d4040dd3 feat: nightly e2e (#155) 2026-04-24 12:25:03 +10:00
Josh Siviour 068aef5589 feat: DDIL E2E test harness (mulga-siv-5) (#160)
* feat: scaffold DDIL E2E harness (cluster, SSH, daemon client)

Phase 1 of the DDIL E2E test harness (mulga-siv-5.1). Adds the
tests/e2e/ddil/ package tree with the three harness primitives required
by every sibling scenario/fault bead:

- harness/cluster.go — Cluster, Node, ClusterFromEnv, NodeFromEnv
- harness/ssh.go — typed SSH transport backed by golang.org/x/crypto/ssh,
  interface so dry-run and tests can stub it
- harness/daemon_client.go — thin HTTPS wrapper over /local/status,
  /local/instances (placeholder types until daemon-local-autonomy lands),
  plus /health for scaffolding validation

Also adds doc.go and README.md (run instructions + env-var contract). All
harness files carry //go:build e2e so default builds skip them.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §0, §1.

* feat: add DDIL harness fault injection and link profiles

Phase 2 of the DDIL E2E test harness (mulga-siv-5.2). Adds the fault
injection and netem primitives consumed by Scenarios A–F:

- harness/fault.go — PartitionNode (per-peer iptables DROP with
  orchestrator-SSH sanity check), HealNode, KillNATS, StartNATS,
  RestartDaemonOnly, WaitForMode (polls /local/status for typed
  DaemonMode enum)
- harness/netem.go — ApplyNetem (tc qdisc replace, idempotent),
  ClearNetem (idempotent clear)
- harness/profiles.go — LinkProfile struct plus LAN, WAN, LTEDegraded,
  SATCOM, HFData, Flapping constants (unvalidated approximations per
  plan §2 table; validation tracking lives in TEST_COVERAGE.md)

All files carry //go:build e2e. Flapping profile is defined but
ApplyNetem rejects it — flap orchestration lands with the scenarios
that need it.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §1, §2.

* feat: add DDIL harness witness VM helpers

LaunchWitnessVM launches a counter VM via the cluster's AWS gateway,
locates its QEMU host via ps, and captures a baseline counter reading.
ReadCounter/AssertProgressed tunnel SSH through the host's QEMU
hostfwd port to verify the workload progressed across a fault. README
documents the new DDIL_WITNESS_* and DDIL_GUEST_SSH_* env vars.

* feat: add DDIL harness snapshot and state assertions

TakeSnapshot / AssertPreserved compare /local/instances pre and post
fault, failing if IDs disappear or a running/pending instance regresses
to a terminal state. LocalStateRevision / AssertMonotonic read the
local state file revision from /local/status so scenarios can detect
the daemon rewinding its persisted state.

* feat: add DDIL harness reset, retry, and quarantine

ResetAllNodes brings every node back to a clean baseline (iptables -F,
tc netem clear on any interface carrying one, spinifex-nats and
spinifex-daemon started) then waits for cluster health — daemon /health
200 plus NATS routez peer count matching len(nodes)-1, mirroring the
shell suite's verify_nats_cluster predicate.

AssertCleanState is a pre-scenario baseline check that recovers a dirty
state via ResetAllNodes instead of failing outright, since a dirty
baseline usually means a previous scenario's cleanup was skipped.

Run wraps each scenario with quarantine handling (DDIL_QUARANTINED
skip) and a single reset-and-retry on failure. First-attempt failures
are not hidden; the retry is a diagnostic aid rather than a flake mask.

bd: mulga-siv-5.5

* feat: DDIL scenarios skeleton (6x t.Skip + TestMain + coverage drift)

Phase 1 of mulga-siv-5 (DDIL E2E harness): all six scenarios land as
TestScenario<L>_... stubs that t.Skipf on their hardening-epic dependency.
TestMain installs a SIGINT/SIGTERM trap that ResetAllNodes before
exiting; DDIL_DRY_RUN=1 short-circuits cluster setup and logs the planned
scenarios. TestCoverageDrift AST-parses sibling *_test.go files and
reconciles them against the DDIL table in TEST_COVERAGE.md so Phase 2/3
flips cannot silently diverge doc from code. TestHarnessSmoke covers the
AssertCleanState + ResetAllNodes round trip for the "full run on real
cluster" acceptance bullet.

* feat: add DDIL E2E CI workflow (workflow_dispatch only)

Phase 1 CI wiring for mulga-siv-5. Mirrors e2e_real_multi's
provision/bootstrap/teardown shape against the same ci-multi runner and
tofu-cluster, but runs the Go harness on the runner (outside the
cluster) so its SSH path survives the peer-traffic iptables rules that
Scenario C installs on a cluster node. Triggers are workflow_dispatch
only — nightly cron and on-main-merge come in mulga-siv-5.8 once ≥4
scenarios are asserting. results.json (go test -json) uploads as an
artifact so flake-rate visibility can be built on gh run view +
structured output, per the design doc.
2026-04-23 22:38:29 +10:00
Josh Siviour 604693ea0c fix: wrap missed NATS/S3 dial sites with admin.DialTarget (#159)
siv-8.5 migrated daemon + awsgw but missed the sidecar services and
the spx CLI. vpcd and viperblockd read cfg.NatsHost / cfg.S3Host raw
from spinifex.toml; when BindIP=0.0.0.0 the TLS handshake fails SAN
verification because 0.0.0.0 is not a cert SAN. Same for the spx get
CLI path.

Call sites:
  spinifex/services/viperblockd/viperblockd.go:173  (NATS)
  spinifex/services/viperblockd/viperblockd.go:407  (S3 s3.S3Config)
  spinifex/services/viperblockd/viperblockd.go:533  (S3 nbd.NBDKitConfig)
  spinifex/services/vpcd/vpcd.go:277                (NATS)
  cmd/spinifex/cmd/get.go:93                        (CLI NATS)

admin.DialTarget rewrites 0.0.0.0:N to 127.0.0.1:N — loopback is always
a SAN and in-process sidecars share the host with NATS/predastore.
2026-04-23 14:42:13 +10:00
Josh Siviour 75fb15be2b feat: spx admin init/join listen-vs-advertise split + br-mgmt OVS provisioning (#158)
* fix: provision br-mgmt as OVS bridge + loopback guard for LB gateway

Convert br-mgmt from an externally-provisioned Linux bridge to an OVS
bridge owned by scripts/setup-ovn.sh, giving every deployment type
(tofu-cluster, production single-node, production multi-node) a single
provisioning path.

setup-ovn.sh grows --mgmt-bridge / --mgmt-cidr / --mgmt-iface /
--no-mgmt-bridge flags. Default creates br-mgmt with fail-mode=standalone
(excluded from ovn-bridge-mappings so ovn-controller ignores it) and
assigns the mgmt IP via a systemd-networkd drop-in so it survives reboot.

SetupMgmtTapDevice / CleanupMgmtTapDevice switch from `ip link master`
to `ovs-vsctl add-port` / `del-port` to match the new bridge type.

Add a loopback guard to wireLBAgentConfig's fallback branch: on a
production single-node with awsgw bound to 127.0.0.1 and no mgmt bridge,
the fallback previously injected 127.0.0.1 into LB VM cloud-init —
silently unreachable because the VM's loopback is its own. The guard
leaves GatewayURL empty and the existing "LB agent gateway URL not
configured" error fires instead. Regression covered in
daemon_wire_lb_test.go.

* fix: auto-detect primary IP for admin init --bind default

Bare `spx admin init` used a 127.0.0.1 default that kept awsgw on loopback
only, so system VMs on br-mgmt could never reach it. Auto-detect the
default-route source IPv4 instead, which keeps TLS cert SANs specific
(still not a wildcard). Dev-install scripts now pass --bind 127.0.0.1
explicitly to preserve loopback-only behavior on laptops.

Closes mulga-siv-7.

* test: refresh wire lb loopback-bind comment for siv-7

* fix: dev-install scripts should let admin init auto-detect --bind

Pinning --bind to 127.0.0.1 on dev installs left awsgw loopback-only while
setup-ovn.sh still provisions br-mgmt, so LB VMs hit 10.15.8.1:9999 with
no listener. Drop the explicit --bind so auto-detect picks the primary IP
and the siv-6 mgmtRouteVia path routes LB traffic to a real listener.

* fix: bind awsgw and predastore on 0.0.0.0 in spinifex.toml template

PR #144 (nats TLS) made every service listen on --bind's specific IP. For
awsgw and predastore this broke localhost-reachability that the UI proxy
(spinifexui/spinifexui.go:193-194) and AWS CLI spinifex profile rely on —
latent since #144, surfaced by siv-7's auto-detect default on dev installs
(502 from the UI).

Hardcode awsgw and predastore host fields to 0.0.0.0 so both listen
wildcard; loopback clients (UI proxy, spinifex AWS CLI profile, same-host
daemon → predastore) keep working, LAN clients still reach them via the
LAN-IP SAN on the cert. daemon.host and nats.host stay as {{.BindIP}}
because peer-advertised values must not resolve to 0.0.0.0/loopback.
predastore.host normalization at config.go:207 rewrites 0.0.0.0 → 127.0.0.1
for local-node consumers, so existing callers see a usable connect address.

* revert: siv-7 --bind auto-detect + siv-6 loopback guard (prep mulga-siv-8)

Clears the deck before layering the listen-vs-advertise split
(mulga-siv-8). The plan restores --bind=0.0.0.0 as the single-node
default and introduces an explicit --advertise flag for off-host dial
targets; the reverted approach (auto-detect a specific IP at init,
hardcode 0.0.0.0 in the template, guard against loopback in
wireLBAgentConfig) becomes redundant once that lands.

Reverts:
- bb0ad3a fix: bind awsgw and predastore on 0.0.0.0 in spinifex.toml template
- f20a47b fix: dev-install scripts should let admin init auto-detect --bind
- 49b0b4e test: refresh wire lb loopback-bind comment for siv-7
- b88dfdf fix: auto-detect primary IP for admin init --bind default

Also drops the loopback guard from siv-6 (74b6e2d) wireLBAgentConfig
fallback and its regression test — subsumed by the GatewayURL
precedence rework in mulga-siv-8.5 (br-mgmt → AdvertiseIP → error,
no silent empty-string fallthrough). The br-mgmt OVS provisioning
from 74b6e2d is retained.

Plan: docs/development/bugs/spx-admin-init-bind.md

* feat: admin.DialTarget + AdvertiseHost helpers (mulga-siv-8.2)

Introduce the two small address-resolution helpers the listen-vs-
advertise split needs, with full unit coverage. No callers yet —
dial-site migration lands in mulga-siv-8.5.

- DialTarget(listenAddr): rewrites wildcard listen addresses
  (0.0.0.0:N, [::]:N, :N) to their loopback equivalents so in-process
  clients dial a target the daemon's cert already has in its SANs.
  Specific IPs, loopback, hostnames, and malformed input pass through.
- AdvertiseHost(listenAddr, advertiseIP): picks the host off-host
  clients should dial. Wildcard listen → advertiseIP; specific host →
  that host. Port is dropped so callers append the protocol-appropriate
  port.

Plan: docs/development/bugs/spx-admin-init-bind.md §Proposed Changes/1

* feat: plumb AdvertiseIP through config/formation (mulga-siv-8.3)

Pure data-model change. Add AdvertiseIP to:
- admin.ConfigSettings (local node, populated by init).
- config.Config (per-node; mapstructure "advertise").
- formation.NodeInfo (json "advertise_ip,omitempty") so the join handshake
  carries it between peers.

buildRemoteNodes now prefers n.AdvertiseIP and falls back to n.BindIP so
joined nodes from older clusters / pre-siv-8 joiners keep working.

No behaviour change yet — templates, CLI flag, and dial-site migration
land in siv-8.4 and siv-8.5.

* feat: --advertise flag + resolveAdvertiseIP + templates (mulga-siv-8.4)

Introduce the listen-vs-advertise split at the CLI boundary.

admin init/join:
- --bind default for init is 0.0.0.0 (was 127.0.0.1 under siv-7 revert).
- New --advertise <IP> flag on both init and join; optional, auto-detected
  from DetectNetwork().WAN.IP when --bind is wildcard or loopback.
- resolveAdvertiseIP(bindIP, advertiseFlag, detected) implements
  precedence: flag > specific bindIP > WAN auto-detect > 127.0.0.1 +
  warning.
- Join resolves advertise before building the JoinRequest so peers record
  a reachable dial target (not 0.0.0.0).
- isMultiNode: trigger on --nodes >= 2 regardless of bind literal; operator
  can now run "init --nodes 3" without typing a specific --bind.
- Init success message prints bind / advertise / loopback three-way split.

Templates:
- spinifex.toml: [nodes.X] gains advertise = "{{.AdvertiseIP}}" rendered
  only when AdvertiseIP is set.
- predastore-multinode.toml: iam.nats_url host wrapped in the same
  {{if eq .BindIP "0.0.0.0"}}localhost{{...}}{{end}} shim that
  predastore.toml already uses, so co-resident predastore dials its local
  NATS via loopback instead of 0.0.0.0.

Tests cover the resolveAdvertiseIP matrix, the template advertise field
(present + omitted), and the predastore nats_url loopback shim.

Dial-site migration and GatewayURL rework land in siv-8.5.

* feat: dial-site migration + GatewayURL AdvertiseIP fallback (mulga-siv-8.5)

Switch every in-process dial site to admin.DialTarget so the wildcard
listen address (0.0.0.0) is rewritten to loopback before Dial(): daemon
→ NATS and → predastore (boot, reconnect, status probe, S3 store, service
manifest write), awsgw → NATS. The TLS cert already SANs 127.0.0.1, so
loopback-dial a wildcard-listen service is always valid; the old
0.0.0.0 literal was tripping TLS SAN verification.

Rework wireLBAgentConfig GatewayURL precedence (spinifex/daemon/daemon.go):

  1. AWSGW on specific WAN IP + br-mgmt present → WAN IP (unchanged)
  2. br-mgmt present, AWSGW on 0.0.0.0     → br-mgmt IP (unchanged)
  3. d.config.AdvertiseIP (single-node default install) → AdvertiseIP (NEW)
  4. DevNetworking shim                    → 10.0.2.2 (unchanged)
  5. AWSGW on specific IP, no bridge       → that IP (unchanged)
  6. Else                                  → log error, skip assignment

Kills the pre-siv-8 silent empty-URL failure mode that made
CreateLoadBalancer fail with 'missing system credentials: gatewayURL='
on every fresh single-node install. Supersedes siv-6's loopback guard.

Extend SetupAWSCredentials with a wanIP parameter (reserved for a
future --operator-endpoint flag) and thread AdvertiseIP through
finalizeNodeSetup's three call sites (single-node init, multi-node
init, join). Print 'Bind / Advertise / Loopback' on multi-node init
and join completion to match the single-node summary.

Plan: docs/development/bugs/spx-admin-init-bind.md §2, §3
2026-04-23 14:02:12 +10:00
Julian Sommer 3c5e09a937 chore: enable frontend lint rules 2026-04-23 12:43:53 +10:00
Julian Sommer fd17c35330 chore: disable frontend lint rules 2026-04-23 12:23:55 +10:00
Julian Sommer 0f89db1893 chore: extract validation logic from gateway function 2026-04-23 10:46:16 +10:00
Julian Sommer 3b0376c3a6 fix: Makefile 2026-04-23 10:04:11 +10:00
Julian Sommer 0a78324405 feat: missing image commands (#156) 2026-04-21 15:10:09 +10:00
Josh Siviour 1e3d82f978 fix: route elbv2 ALB public IP through EIP service (#157)
Internet-facing ALBs allocated their public IP by calling
externalIPAM.AllocateIP() directly from LaunchSystemInstance, bypassing
the EIP service entirely. No EIPRecord landed in the eips KV bucket, the
tag was "auto_assign" (EIP service uses "elastic_ip"), and
DescribeAddresses never returned the IP — so any OpenTofu flow that
expects an AllocateAddress-observable record hung waiting.

LaunchSystemInstance now calls eipService.AllocateAddress +
AssociateAddress when the EIP service is wired (falls back to the old
direct IPAM path otherwise). TerminateSystemInstance releases through
the service so the KV record is cleaned up and vpc.delete-nat is
published. The generic stopInstance teardown is left untouched so
customer RunInstances flows behave identically.

Also refreshes the nginx-alb terraform example to use public subnets
with auto-assigned public IPs (the configuration that actually exercises
this path end-to-end) and tightens the awsgwBindIP loopback guard in
wireLBAgentConfig so the gateway URL is usable by LB agents.
2026-04-21 13:52:48 +10:00
Julian Sommer 1ca8621e16 fix: allow mulga feat branch 2026-04-21 11:14:29 +10:00
Julian Sommer acefd1cfd4 chore: make README have go 1.26 2026-04-21 10:19:04 +10:00
Julian Sommer 1bad55ce54 chore: update frontend build 2026-04-21 09:20:51 +10:00
dependabot[bot] 869184efb5 chore: bump the go-dependencies group with 2 updates (#153) 2026-04-21 09:01:49 +10:00
dependabot[bot] b46fe2531a chore: bump the npm-dependencies group (#154) 2026-04-21 09:01:29 +10:00
joshsiv-mulga d6863f3d5e Change LB ISO download to Cloudflare host 2026-04-20 14:18:22 +10:00
Josh Siviour f97ff772a3 fix: sentinel TLS errors + shared NATS retry (#152)
* fix: replace stringly-typed TLS error detection with sentinel errors

Export ErrCACertRead and ErrCACertParse from utils/nats.go so callers can
detect permanent TLS configuration failures with errors.Is instead of
substring matching on error messages. Update daemon.go and awsgw.go retry
loops accordingly, and update tests to use assert.ErrorIs.

Refs: mulga-siv-1

* refactor: extract shared NATS connect-with-retry into utils

daemon.connectNATS() and awsgw.connectNATS() were near-identical retry
loops. Extract to utils.ConnectNATSWithRetry() with functional options
for test configurability.
2026-04-20 12:04:40 +10:00
Julian Sommer f00a376d0b tweak: docs 2026-04-17 16:09:06 +10:00
Julian Sommer c469eecfa1 chore: doc tweaks 2026-04-17 15:27:14 +10:00
Julian Sommer 4f991095ee feat: physical security guide 2026-04-17 14:28:43 +10:00
Julian Sommer 14efe44125 feat: flaw remediation policy doc (#151) 2026-04-17 14:15:44 +10:00
Julian Sommer e131caf9ec fix: doc links 2026-04-17 13:41:58 +10:00
Julian Sommer e46b427938 feat: network-connections docs (#150) 2026-04-17 12:44:48 +10:00
Julian Sommer 8d6f4df526 fix: setting up cluster image 2026-04-17 12:13:44 +10:00
Julian Sommer 1a41731ef5 chore: tweak malware-protection doc 2026-04-17 11:59:10 +10:00
Julian Sommer 6f58a3f190 feat(docs): malware protection guide (#149) 2026-04-17 11:17:16 +10:00
Julian Sommer 883fac980c fix: remove unused /join /config endpoints (#148) 2026-04-17 10:32:49 +10:00
Julian Sommer 084e93cfd0 feat: validate image checksums (#147) 2026-04-16 17:00:27 +10:00
Julian Sommer 6f02f6bc22 feat(vpcd): log=true on default-deny ACLs (#145) 2026-04-16 14:45:21 +10:00
Julian Sommer 3986549783 fix: log failed attempts at warn level for compliance 2026-04-16 13:51:46 +10:00
Julian Sommer ff9f9485d2 feat: nats TLS (#144) 2026-04-16 13:46:12 +10:00
Julian Sommer 29ba511b38 feat: disable public nats monitoring (#143) 2026-04-16 11:31:28 +10:00
Julian Sommer 8e9aff3299 chore: update old doc 2026-04-16 10:15:10 +10:00
Julian Sommer 911b36a696 refactor: extract shared helpers from admin init/join (#142) 2026-04-16 10:00:22 +10:00
Julian Sommer bb9cd8ea23 feat: formation join token (#141) 2026-04-15 16:55:55 +10:00
Ben Duncan c8429f98ae Update ALB doc 2026-04-15 15:25:48 +10:00
Julian Sommer 68ae17dcf1 chore: rename app-id to client-id 2026-04-15 15:03:16 +10:00
Julian Sommer 5b39efb078 fix: make e2e use env vars 2026-04-15 13:40:22 +10:00
Julian Sommer 7d6e0780b5 feat: action rate limiting (#140) 2026-04-15 12:19:30 +10:00
Julian Sommer 1ad03bb518 feat(gateway): per-IP auth failure rate limiting (#138) 2026-04-15 09:56:04 +10:00
Julian Sommer b7b639084b chore: use ts config files v1.1.0 2026-04-14 14:28:00 +10:00
Julian Sommer 7dcad2ba1d chore: remove unused e2e 2026-04-14 13:59:29 +10:00
Julian Sommer 7cf0783122 fix: set dev tags as prerelease 2026-04-14 13:29:05 +10:00
Josh Siviour 13eae4d40b fix(elbv2): tag-based LB AMI lookup with hard error on miss (#137)
Replace fragile image-name substring match (-lb-/-alb-) with a
DescribeImages tag filter on spinifex:managed-by=elbv2. Remove the
silent fallback that picks the first image in the store when no LB
image exists — CreateLoadBalancer now returns ServerInternal with a
remediation hint instead of persisting a record in StateFailed.

- daemon: tag filter + error return from the resolver closure
- service_impl: resolver signature is func() (string, error); caches
  only successful results so the next call retries after an error
- CreateLoadBalancer gate now requires launcher + networking; AMI is
  resolved inside the gate and failures short-circuit before persist
- admin images import: add --name+--file combo (catalog metadata with
  a local file source) and a --tag key=value flag for explicit tagging
  of file-only imports
- build-system-image.sh + lb.conf: propagate SYSTEM_TAG via --tag so
  locally built system AMIs get the right tag even when the catalog
  is bypassed

Closes mulga-969
2026-04-14 12:52:28 +10:00
Julian Sommer b32111102d fix: README.md 2026-04-14 12:06:33 +10:00
Ben Duncan 71d0f31997 Merge branch 'main' of github.com:mulgadc/spinifex 2026-04-14 11:45:46 +10:00