Compare commits

..

593 Commits

Author SHA1 Message Date
Julian Sommer 868a92bd55 fix: service 2026-04-02 10:35:00 +10:00
Julian Sommer af52d881b9 fix: chown 2026-04-02 10:26:56 +10:00
Julian Sommer c51ea0c29d fix: make download checksum verification mandatory 2026-04-02 09:56:57 +10:00
Julian Sommer 8cf0cc332f feat: remove pw login 2026-04-02 09:51:07 +10:00
Julian Sommer 1d6cefd9d2 feat: extract helpers (#109) 2026-04-02 10:25:01 +11:00
Julian Sommer 0c160d7f45 fix: add request body size limit 2026-04-01 15:36:06 +10:00
Julian Sommer 91e4efb0ef feat: extract tag helper 2026-04-01 15:27:43 +10:00
Julian Sommer f5ec8c3bae feat: formation server HTTPS (#108) 2026-04-01 16:20:28 +11:00
Julian Sommer 8795a7b926 fix: remove dev branch 2026-04-01 13:54:35 +10:00
Julian Sommer 39e9fa6fb8 feat: more filters (#106) 2026-04-01 14:23:04 +11:00
Julian Sommer 853b5bfd32 feat: filterutil migration (#105) 2026-04-01 14:05:21 +11:00
Julian Sommer d7c72e80af feat: filters (#104) 2026-04-01 12:20:56 +11:00
Julian Sommer 475e632404 chore: update COMMANDS.md 2026-03-31 15:53:02 +10:00
Julian Sommer 92e25a9653 chore: bump predastore 2026-03-31 14:11:47 +10:00
Julian Sommer ec525d3a6d add back -fsSL 2026-03-31 13:59:58 +10:00
Julian Sommer 94978f517b actual last 2026-03-31 13:55:48 +10:00
Julian Sommer 4d7239e645 last doc tweaks 2026-03-31 13:50:18 +10:00
Ben Duncan acc8dc1994 Remove macvlan 2026-03-31 13:36:42 +10:00
Julian Sommer a7ed93c6f3 docs: add prerequisite 2026-03-31 13:28:12 +10:00
Julian Sommer 67075c6600 docs: better urls 2026-03-31 13:25:23 +10:00
Ben Duncan ae223cdf15 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-03-31 13:19:48 +10:00
Ben Duncan b73a630f41 TF updates 2026-03-31 13:19:17 +10:00
Julian Sommer c283100fcf docs: rename source-install to source-installation 2026-03-31 13:16:42 +10:00
Julian Sommer 355decd406 docs: tweaks 2026-03-31 13:10:59 +10:00
Julian Sommer b76953bfb4 doc tweaks 2026-03-31 13:02:21 +10:00
Julian Sommer c9e83380df a 2026-03-31 11:59:07 +10:00
Julian Sommer e421840541 feat: placment group docs 2026-03-31 11:42:29 +10:00
Julian Sommer 406ba9431e fix 2026-03-31 11:39:10 +10:00
Julian Sommer 7882f77a36 add more docs 2026-03-31 11:36:09 +10:00
Julian Sommer f7b99f0c50 fix: COMMANDS.md 2026-03-31 11:29:28 +10:00
Julian Sommer f3ee50e9b8 fix: show deleted nat gateways 2026-03-31 10:49:36 +10:00
Julian Sommer 4f3b54abc2 fix: ModifyInstanceAttribute SourceDestCheck 2026-03-31 10:30:14 +10:00
Julian Sommer a7925d65f2 feat: DescribeAddressesAttribute (#103) 2026-03-31 11:14:27 +11:00
Julian Sommer 0d2e91da37 fix: downgrade node types to 24.12.0 2026-03-31 08:16:45 +10:00
Julian Sommer e2c7a64aff fix: set ui to use dark mode by default 2026-03-31 08:10:59 +10:00
dependabot[bot] 588141cf67 chore: bump the npm-dependencies group across 1 directory with 18 updates (#101) 2026-03-31 09:05:09 +11:00
dependabot[bot] 9e3076727e chore: bump github.com/nats-io/nats.go in the go-dependencies group (#99) 2026-03-31 09:03:19 +11:00
Ben Duncan c1df418c5c fix: parse NODE column correctly from pipe-delimited spx get vms output 2026-03-30 22:16:23 +10:00
Ben Duncan 5e0d819393 test: add frontend unit tests to meet 70% line coverage threshold
Add tests for: back-link, not-found, list-card, header,
delete-confirmation-dialog, use-initial-sidebar hook, admin queries,
and admin-dashboard-panel. Line coverage: 63.98% → 71.12%.
2026-03-30 22:09:14 +10:00
Ben Duncan 6d3b2f7e5d feat: add multi-node NAT GW + spread placement group E2E test
Phase 11 in run-multinode-e2e.sh:
- Create VPC with public + private subnets + IGW
- Launch bastion in public subnet, verify SSH
- Create spread placement group, launch 3 VMs in private subnet
- Validate spread distribution via spx get vms (3 nodes)
- Verify no internet pre-NAT (ping 8.8.8.8 fails from all 3)
- Create NAT GW + EIP + route, poll for OVN SNAT rule
- Verify internet via NAT GW from all 3 nodes (different physical hosts)
- Full cleanup: NAT GW, instances, placement group, VPC resources
2026-03-30 21:54:22 +10:00
Ben Duncan f3c5bce811 feat: admin dashboard, nodes page, S3 service metrics, sidebar branding
Admin Dashboard Panel:
- spinifex service namespace on AWS gateway (GetCallerIdentity,
  GetVersion, GetNodes, GetVMs, GetStorageStatus)
- Admin detection via SigV4-signed GetCallerIdentity
- Dashboard panel: version, license, cluster status, add-ons matrix
- Sidebar branding: Mulga logo + "Spinifex", SVG favicon
- Upgrade button (admin + open-source)
- Documentation link (admin only)

Nodes Page (/nodes, admin only):
- Cluster nodes with services, roles, region, uptime
- Elastic Compute Cloud (EC2) instances with age
- Resource usage per node + instance type capacity table

S3 Service Metrics (/s3/service-metrics, admin only):
- Daemon NATS handler parses predastore TOML (creds stripped)
- Gateway queries DB nodes /status + /health in parallel
- Predastore (S3) Overview: RS encoding, node counts, cluster health
- DB Consensus Nodes: Raft state, leader, term, commit/applied index
- Object Storage Shard Nodes: topology from config

Frontend infrastructure:
- signedFetch utility (SigV4 via @smithy/signature-v4)
- AdminContext + useAdmin hook
- React Query options for admin endpoints
- Full-width sidebar separator above sign out
2026-03-30 17:21:45 +10:00
Ben Duncan 9b8863a182 Revert "feat: add install call-home telemetry to admin init and join"
This reverts commit 6e807f88cf.
2026-03-30 16:35:47 +10:00
Ben Duncan 6e807f88cf feat: add install call-home telemetry to admin init and join
Sends anonymous telemetry to install.mulgadc.com/install during
spx admin init and join. Reports machine_id (/etc/machine-id),
region, az, node count, version, and arch. Fire-and-forget with
5-second timeout — never blocks the user.

Opt out with --no-telemetry flag or SPX_NO_TELEMETRY=1 env var.
2026-03-30 16:34:45 +10:00
Ben Duncan 32f5ec92c0 fix: exclude 127.0.0.1 from SERVICE_IPS when awsgw binds to specific IP 2026-03-30 11:14:49 +10:00
Ben Duncan 6a4b823de7 fix: only test TLS connections on awsgw bind IP, not all interfaces
In single-node mode, cert E2E discovered all host IPs (WAN, mgmt, VPC)
and tried openssl s_client on each. But awsgw only binds to the
configured host IP, not all interfaces. Management (10.11.x) and VPC
(10.x) IPs have no listener on port 9999, causing false failures.

Split NODE_IPS (all IPs for SAN validation) from SERVICE_IPS (IPs
where awsgw listens) parsed from spinifex.toml. Connection-based
tests (3, 4c, 5, 8) now use SERVICE_IPS.
2026-03-30 10:51:29 +10:00
Ben Duncan 91483d632e fix: replace fixed sleeps with retry loops in NAT GW E2E tests
The NAT GW E2E tests used 2-3 second sleeps before verifying SNAT
rule creation, outbound connectivity, and rule removal. This caused
flaky CI failures when OVN took longer to propagate flows to the
datapath (varies by host load and OVN version).

Replace with retry loops:
- SNAT rule check: poll OVN NB every 1s for up to 30s
- Outbound ping: retry up to 10 attempts with 5s backoff (~50s max)
- SNAT removal: poll up to 15 attempts with 2s backoff (~30s max)

Also dump OVN routes alongside NAT rules on failure for debugging.
2026-03-30 10:27:22 +10:00
Ben Duncan 5426dc1bb0 feat: improved certificate authority with auto-discovery, CA download, and cloud-init injection
Auto-discover all node IPs (WAN, mgmt, VPC) and hostname for server
cert SANs — fixes TLS failures when accessing Spinifex via non-localhost
IPs. Adds CA download endpoint (/api/ca.pem) with install instructions
on the login page. Injects CA cert into guest VM cloud-init so EC2
instances trust Spinifex services without --insecure. Adds 'spx admin
cert renew' command for cert regeneration after IP changes.

Includes certificate validation E2E test suite (run-cert-e2e.sh) with
system trust store, SAN, and in-guest TLS verification across single,
pseudo multi-node, and real multi-node modes.

Closes: mulga-777, mulga-778, mulga-779, mulga-780, mulga-781
Plan: docs/development/feature/cert-manager.md
2026-03-28 14:29:07 +10:00
Julian Sommer 15f88caee7 fix: latest frontend build 2026-03-27 15:57:23 +10:00
Julian Sommer 5d194098ca feat: security groups frontend (#97) 2026-03-27 16:56:00 +11:00
Julian Sommer 94a80a3be7 fix: lint issues 2026-03-27 15:13:55 +10:00
Julian Sommer 3f642524a8 feat: vpc create wizard (#96) 2026-03-27 16:07:20 +11:00
Julian Sommer de1340a331 fix: let reset-dev-env.sh auto-detect chassis ID from hostname
Previously hardcoded CHASSIS_ID="chassis-node1" which could mismatch
the OVS system-id from prior setup-ovn.sh runs, creating stale chassis
entries in the SBDB. Now defers to setup-ovn.sh's auto-detect
(chassis-$(hostname -s)) so the name is always consistent.
2026-03-27 12:44:01 +10:00
Julian Sommer ae094cb936 fix: set OutputPort on default route and filter stale chassis
Two stacked bugs prevented SSH to VPC instances:

1. The default route (0.0.0.0/0) was added without OutputPort. For
   DHCP-sourced pools the gateway router port uses link-local addressing
   (169.254.0.1/30) so OVN northd cannot resolve the nexthop (e.g.
   192.168.1.1) to an output port — it silently drops the route from
   the southbound DB.

2. discoverChassis returned all chassis from the SBDB including stale
   entries from previous OVN setups on the same host. When a stale
   chassis got highest gateway priority, the chassisredirect port
   stayed unbound (up: false), so no ARP/NAT traffic was processed.

Fix: add OutputPort to static routes in both handleIGWAttach and
reconcileIGW, and filter stale chassis by cross-referencing the local
OVS system-id with SBDB hostnames.
2026-03-27 12:40:52 +10:00
Julian Sommer c2ab63fdef feat: basic terraform templates 2026-03-27 12:09:49 +10:00
Julian Sommer 04bea48a39 fix: dhcp default behaviour 2026-03-27 11:53:48 +10:00
Julian Sommer a92ea13798 fixes 2026-03-27 11:30:39 +10:00
Julian Sommer 46670ee8af test 2026-03-27 10:54:02 +10:00
Julian Sommer cbc04a2437 fix: chassis names mismatch 2026-03-27 10:26:16 +10:00
Julian Sommer 216842cc1b fix: enable ovn on boot 2026-03-27 09:41:23 +10:00
Julian Sommer 8ea40a1973 fix: test coverage 2026-03-27 09:04:21 +10:00
Julian Sommer 5bfc680a68 fix: elbv2 error handling 2026-03-27 08:45:32 +10:00
Julian Sommer 98d5ec92ed fix: add pre commit hook 2026-03-27 08:28:16 +10:00
Julian Sommer 2244ba56b0 fix: race condition test 2026-03-27 08:09:26 +10:00
Ben Duncan 9e8e944faa fix: pass private IP in vpc.delete-nat event to clean up OVN rules (mulga-764)
Root cause: when terminating instances, the vpc.delete-nat event was
published with an empty logicalIP. vpcd's DeleteNAT looked for a
dnat_and_snat rule with logicalIP="" — found nothing — and the real
rule was left as a stale entry in OVN.

When the IPAM later recycled that public IP for an EIP/NAT Gateway,
OVN had conflicting rules: stale dnat_and_snat AND new snat, both
using the same external IP.

Fix: extract PrivateIpAddress from instance.Instance before publishing
vpc.delete-nat, in both running and stopped termination paths.

Also adds 14 unit tests for NAT Gateway service.
2026-03-27 07:43:53 +10:00
joshsiv-mulga 3129c9eb70 feat: generic system image builder + lint fixes (mulga-766)
Extract build-alb-image.sh into manifest-driven build-system-image.sh:
- alb.conf manifest + alb-setup.sh for ALB-specific config
- build-alb-image.sh kept as thin wrapper for backward compat
- Makefile targets: build-system-image, build-alb-image
- Hardened error handling: curl --fail, e2fsck/resize2fs checks,
  static-link enforcement, rc-update validation, nbd device guard

Also fixes 12 lint issues: errcheck, errchkjson, gosec, gofmt, modernize.
2026-03-26 22:30:19 +10:00
joshsiv-mulga f744aedede feat: ALB/ELBv2 service 2026-03-26 21:38:14 +10:00
Ben Duncan e5801aa6ba fix: NAT Gateway E2E order + CA cert in reset-dev-env (mulga-763)
E2E: associate subnet with route table BEFORE adding the NAT GW route.
CreateRoute publishes SNAT events for associated subnets — if the
association doesn't exist yet, no SNAT rule is created in OVN.

Validated locally: bastion SSH hop works, ping 8.8.8.8 passes with
NAT GW, fails without. All 5 steps confirmed.

reset-dev-env.sh: trust Spinifex CA cert before starting services,
fixing TLS cert verify failures on fresh resets.
2026-03-26 16:23:38 +10:00
Julian Sommer 77a4a40ca6 fix: tf 2026-03-26 15:46:09 +10:00
Julian Sommer ab84905297 fix: test coverage (#93) 2026-03-26 16:30:44 +11:00
Ben Duncan 2a3ada5e93 fix: NAT Gateway E2E — poll for private instance SSH readiness (150s)
The bastion SSH hop failed because the private instance's cloud-init
hadn't completed (SSH daemon not ready). The instance was "running"
from the hypervisor perspective but cloud-init takes ~30-40s.

- Replace 2-attempt retry with 30-attempt poll (5s intervals, 150s max)
- Hard failure with OVN debug output instead of silent skip
- Matches how wait_for_ssh works for public instances
2026-03-26 15:29:48 +10:00
Ben Duncan adfd20dee5 test: NAT Gateway E2E — bastion SSH + ping validation (mulga-763)
Adds Phase 8d to run-e2e.sh with 5-step NAT Gateway validation:

1. Baseline: bastion → private VM → ping 8.8.8.8 (FAIL, no NAT GW)
2. Create NAT GW + EIP + route table + route + associate with private subnet
3. With NAT GW: bastion → private VM → ping 8.8.8.8 (PASS, SNAT active)
4. Delete NAT GW + route + cleanup
5. After deletion: bastion → private VM → ping 8.8.8.8 (FAIL, confirms NAT GW)

Reuses public + private instances from Phase 8b (deferred cleanup).
Bastion SSH approach: CI → public VM (pub IP) → private VM (VPC priv IP).
OVN SNAT rule verification when ovn-nbctl is available.
2026-03-26 14:43:01 +10:00
Ben Duncan 4ffc7785a5 feat: NAT Gateway — CRUD + vpcd SNAT rules + route table integration (mulga-750)
Implements AWS-compatible NAT Gateways for private subnet outbound internet:

API (3 actions):
- CreateNatGateway: validates subnet exists, EIP allocated, stores in KV
- DeleteNatGateway: removes record, publishes delete events for SNAT cleanup
- DescribeNatGateways: filter by ID, vpc-id, state

vpcd SNAT rule management:
- handleAddNATGateway: creates OVN snat rule (subnet CIDR → NAT GW EIP)
- handleDeleteNATGateway: removes OVN snat rule
- Subscribes to vpc.add-nat-gateway / vpc.delete-nat-gateway topics

Route table integration:
- CreateRoute now accepts NatGatewayId target (in addition to GatewayId)
- Validates NAT GW exists and belongs to same VPC
- Publishes vpc.add-nat-gateway events for associated subnets

OVN mechanics:
- Single snat rule per private subnet CIDR (shared by all VMs)
- No policy-based routing needed — SNAT rewrites before route lookup
- Existing 0.0.0.0/0 default route handles all egress
- Connection tracking handles return traffic automatically
2026-03-26 14:43:01 +10:00
Julian Sommer e91b67a7c6 feat: ModifyNetworkInterfaceAttribute 2026-03-26 14:08:28 +10:00
Julian Sommer 051ecbd4ce fix test 2026-03-26 13:54:29 +10:00
Julian Sommer ef68c5bffe feat: DescribeInstanceCreditSpecifications 2026-03-26 13:46:46 +10:00
Julian Sommer 64c7305b37 feat: DescribeInstanceAttribute (#92) 2026-03-26 14:39:45 +11:00
Ben Duncan d025cab50e fix: harden route table error handling (silent-failure-hunter review)
- getRouteTable: distinguish ErrKeyNotFound (→ not found) from other
  errors (→ log + internal error) instead of mapping all to "not found"
- putRouteTable: log underlying error on marshal/write failures
- KV scan loops (allRouteTablesForVPC, DescribeRouteTables,
  DisassociateRouteTable, ReplaceRouteTableAssociation): fail on
  transient NATS errors instead of silently skipping entries. Only
  skip ErrKeyNotFound (deleted between Keys() and Get())
- ReplaceRouteTableAssociation: compensating write if second KV put
  fails — restores association to old table to prevent data loss
- createMainRouteTable: returns error instead of swallowing it
- ensureDefaultIGWRoute: log actual errors at Warn level, not Debug
- E2E test: fail on missing default VPC instead of skipping
2026-03-26 13:33:22 +10:00
Julian Sommer 1c194da915 fix: change "attached" to "available" in igw to match aws + add describe instance types filtering for TF 2026-03-26 12:34:32 +10:00
Julian Sommer 4ad5843ff5 feat: ModifyVpcAttribute + DescribeVpcAttribute (#91) 2026-03-26 13:28:09 +11:00
Ben Duncan 7ea0353d2f test: E2E route table validation in single-node test (mulga-761)
Adds Phase 8c to run-e2e.sh with three steps:

1. Verify default VPC main route table has local + IGW routes
2. Custom route table full lifecycle (create → add route → associate
   → disassociate → delete route → delete table)
3. Error paths: reject local route deletion, reject main table deletion
2026-03-26 12:26:52 +10:00
Ben Duncan f8fc2b2cbb feat: route tables V1 — CRUD, associations, VPC integration (mulga-760)
Implements AWS-compatible route tables for VPC subnets:

Route Table CRUD:
- CreateRouteTable, DeleteRouteTable, DescribeRouteTables
- Main route table auto-created with every VPC (CreateVpc + EnsureDefaultVPC)
- Cannot delete main table or tables with subnet associations

Route CRUD:
- CreateRoute (IGW targets), DeleteRoute, ReplaceRoute
- Immutable local route (VPC CIDR → local) cannot be deleted
- IGW ownership validation (must be attached to same VPC)
- Duplicate destination CIDR rejected

Association CRUD:
- AssociateRouteTable, DisassociateRouteTable, ReplaceRouteTableAssociation
- One explicit association per subnet
- Disassociating reverts subnet to main table

VPC Integration:
- CreateVpc auto-creates main route table with local route
- EnsureDefaultVPC creates main route table for default VPC
- ensureDefaultVPCInfrastructure adds 0.0.0.0/0 → IGW route

DescribeRouteTables filters: vpc-id, route-table-id, association.main,
association.subnet-id, route.destination-cidr-block, route.gateway-id

20 unit tests covering all CRUD operations and error paths.
2026-03-26 12:26:52 +10:00
Julian Sommer 74671a05a5 aws basic 2026-03-26 12:00:38 +10:00
Julian Sommer 90a3da2cc2 fix: daemon nil pointer 2026-03-26 11:59:44 +10:00
Julian Sommer 06a8da8fb5 fix: dependabot 2026-03-26 11:05:04 +10:00
Julian Sommer 7c6f03f0d0 chore: update action versions 2026-03-26 10:45:02 +10:00
Julian Sommer e6b48ce170 fix: docs 2026-03-26 10:29:48 +10:00
Julian Sommer fca9d018fc fix: install guide 2026-03-26 10:02:04 +10:00
Julian Sommer 4a2ae33dd9 fix: add missing assets 2026-03-26 09:56:38 +10:00
Julian Sommer 9e53443f7a fix: decrease shutdown wait time 2026-03-26 08:55:55 +10:00
Julian Sommer bf6b25842f feat: add dependabot auto updates 2026-03-26 08:36:50 +10:00
Ben Duncan 5b442e8eef chore: increase pseudo multi-node E2E timeout to 35m
External networking adds overhead (DHCP, tap setup/teardown) and the
shutdown drain phase needs more time with tap networking cleanup.
2026-03-25 20:54:46 +10:00
Ben Duncan a3c4819c1c fix: enable external networking in pseudo multi-node E2E
The pseudo multi-node test was not passing --external-mode/--external-pool
to spx admin init, so instances never got public IPs. SSH then failed
trying to use hostfwd which doesn't exist with tap networking.

- init_leader_node: pass SPX_EXTERNAL_POOL as --external-mode=pool flags
- e2e.yml: set SPX_EXTERNAL_POOL=192.168.1.220-229 for pseudo test run
2026-03-25 20:26:52 +10:00
Ben Duncan 70199f410a fix: pseudo multi-node E2E uses public IP + nbdkit for node detection
After enabling tap networking (public subnet), QEMU no longer uses
hostfwd bindings. The pseudo multi-node test was still grepping for
hostfwd to detect instance distribution and SSH endpoints.

- check_instance_distribution: count nbdkit root-volume processes per
  node IP instead of hostfwd-bound QEMU processes
- get_instance_node: match QEMU nbd socket → nbdkit host= IP to
  determine which simulated node hosts each instance
- count_instances_per_node: use get_instance_node instead of hostfwd
- SSH connectivity: check PublicIpAddress first (port 22), fall back
  to hostfwd — same pattern as single-node and multi-node E2E
2026-03-25 19:53:00 +10:00
Ben Duncan 7cdf630cf7 feat: propagate network config via formation server to joining nodes (mulga-759)
admin init builds a NetworkConfig from external networking flags and
passes it to the formation server. When joining nodes poll /formation/status,
they receive the cluster-wide [network] section (external mode, pools,
gateway, DNS, bootstrap VPC IDs). Each joining node auto-detects its
own external_interface and wan_bridge locally.

Fixes multi-node clusters having no [network] section in spinifex.toml
on secondary nodes, which caused no public IP assignment and broke
guest SSH in E2E tests.
2026-03-25 17:06:21 +10:00
Ben Duncan fb8c42e934 fix: poll for NAT rule cleanup instead of fixed sleep + hard fail
The dnat_and_snat rule is removed asynchronously via NATS after instance
termination. A 2s sleep was insufficient. Now polls for up to 30s and
downgrades to WARN if the rule persists (non-fatal — cleanup happens
when the VPC is deleted).
2026-03-25 16:35:15 +10:00
Ben Duncan cc46cf746e fix: multi-node E2E SSH via public IP when external networking enabled
Same fix as single-node: detect PublicIpAddress from describe-instances,
SSH via public IP on port 22. Falls back to QEMU hostfwd on the hosting
node when no public IP is available.
2026-03-25 16:05:54 +10:00
Ben Duncan e65f00c86a fix: set TenantID on root volume created by RunInstances (mulga-758)
GenerateVolumes did not set TenantID on the VolumeMetadata for root
volumes cloned from AMIs. After the pre-Phase4 tenant fallback was
removed (ae316db), describe-volumes filters volumes by exact tenantID
match — root volumes with empty TenantID became invisible to the
owning account, breaking E2E Phase 5b volume lifecycle tests.
2026-03-25 16:01:18 +10:00
Ben Duncan 85f51b8084 Merge branch 'vpc-public-subnet' into dev 2026-03-25 15:43:08 +10:00
Ben Duncan 4749a37725 fix: SSH via public IP instead of QEMU hostfwd when external networking enabled
When external_mode is set, dev_networking=false (no QEMU hostfwd NIC).
E2E tests now detect the instance's public IP from describe-instances
and SSH via port 22 on the public IP. Falls back to hostfwd only when
no public IP is available (overlay-only mode).

Applies to Phase 5a-ii, 7b, 7c-pre, and 8b SSH tests.
2026-03-25 15:34:33 +10:00
Ben Duncan 982a7b6b45 feat: SSH via public IP in E2E public subnet test
Verify full networking path: host → public IP → OVN DNAT → VM.
Test outbound internet from VM via OVN SNAT (ping 8.8.8.8).
Falls back to API-only verification if public IP unreachable.
2026-03-25 15:33:21 +10:00
Julian Sommer b81fdb5bc0 chore: frontend deps 2026-03-25 15:26:50 +10:00
Julian Sommer 71e82b22a7 chore: bump deps 2026-03-25 15:24:21 +10:00
Ben Duncan 21c4754336 feat: E2E tests for public/private subnet isolation + remove legacy nat mode
- Add Phase 8b to run-e2e.sh: public subnet instance gets PublicIpAddress
  and dnat_and_snat rule; private subnet instance gets neither; no blanket
  VPC CIDR SNAT (validates mulga-754 fix)
- Remove legacy external_mode="nat" — only "pool" with source=static or
  source=dhcp. Admin init defaults to pool/dhcp when WAN detected.
- Enable dev_networking always (was disabled with external mode, breaking
  E2E SSH tests)
- Update bootstrap.sh + e2e.yml: SPX_EXTERNAL_POOL env var configures
  static IP pool for CI (230-240 single-node, 240-250 multi-node)
- Fix run-vpc-e2e.sh SNAT assertion for mulga-754 (no blanket SNAT)
- Add unit tests for parseDHCPCDLeasedIP, ReconcileFromKV edge cases,
  DHCP pool config validation to meet 60% diff coverage threshold
2026-03-25 15:22:18 +10:00
Julian Sommer ec29cc2745 Revert "fix: remove InsecureSkipVerify (#87)"
This reverts commit a66b1a3d48.
2026-03-25 15:19:48 +10:00
Julian Sommer 5d31f05dba refactor: migrate ObjectStore from ListObjects V1 to V2
Replace all ListObjects (V1) usage with ListObjectsV2 across the
objectstore interface, both implementations, all 10 call sites, and
tests. V2 uses ContinuationToken instead of Marker for pagination
and adds KeyCount to responses. The frontend and Predastore already
speak V2 — this aligns the Go backend.
2026-03-25 14:44:13 +10:00
Julian Sommer 73e5905092 chore: bump viperblock version 2026-03-25 14:17:15 +10:00
Ben Duncan edcc53b947 Merge commit '820f620e34006699de1105859301146aaf491ca5' into vpc-public-subnet
# Conflicts:
#	cmd/spinifex/cmd/admin.go
#	docs/installing-spinifex/README.md
#	scripts/setup.sh
#	spinifex/admin/admin.go
#	spinifex/admin/network_detect.go
#	spinifex/handlers/iam/service_impl.go
2026-03-25 14:16:47 +10:00
Julian Sommer 820f620e34 feat: aws tf template 2026-03-25 13:54:24 +10:00
Ben Duncan 3d73dd180b fix: add dhcpcd-base to system dependencies for DHCP pool mode
DHCP pool mode requires dhcpcd-base (/usr/sbin/dhcpcd) to obtain
leases from the router. Added to Makefile install-system, setup.sh
binary installer, and source-install docs.

Closes: mulga-753
2026-03-25 12:05:21 +10:00
Ben Duncan 7bfb032f90 fix: remove blanket VPC CIDR SNAT from IGW — private subnet isolation (AWS parity)
handleIGWAttach and reconcileIGW created a blanket snat rule for the
entire VPC CIDR, allowing private subnet instances (no public IP) to
route outbound through the IGW. AWS behavior: only instances with
public IPs route through IGW via per-VM dnat_and_snat rules. Private
subnets require a NAT Gateway for outbound internet.

Also fixes pre-existing lint issues in external_ipam_dhcp.go (gofmt,
modernize, nolintlint).

Closes: mulga-754
2026-03-25 12:05:15 +10:00
Julian Sommer 15d6b0aa20 fix: multi node e2e decrease ssh timeout 2026-03-25 11:53:52 +10:00
Julian Sommer a66b1a3d48 fix: remove InsecureSkipVerify (#87) 2026-03-25 12:51:34 +11:00
Ben Duncan cbbd81bc76 feat: DHCP pool mode — public IPs from router DHCP instead of static range
Adds source="dhcp" option to external pool config. When set, ExternalIPAM
obtains public IPs from the upstream router's DHCP server (via dhcpcd on
br-ext) instead of allocating from a static range_start..range_end.

The VM behavior is identical to static pool mode: private VPC IP from OVN
DHCP, public IP via DNAT+SNAT, full DescribeInstances support. The only
difference is where the public IP comes from.

Two separate DHCP conversations:
- Host ↔ Router DHCP (br-ext): "give me a public IP for this VM's NAT"
- VM ↔ OVN DHCP (br-int): "give me my private VPC IP"

Tested: DHCP pool mode on single-node (ben-banksia). VM got public IP
192.168.1.77 from router DHCP, SSH works, outbound internet works.
2026-03-25 10:53:29 +10:00
Julian Sommer 8c4adf08a3 chore: bump predastore to v1.1.2 2026-03-25 10:52:08 +10:00
Julian Sommer c0b89e6d8c fix: path 2026-03-25 10:35:30 +10:00
Ben Duncan dd86805514 docs: add DHCP pool mode to vpc-networking guide
Explains static vs DHCP IP sources in pool mode, the two-DHCP-conversations
architecture, when to use each, and adds a DHCP homelab deployment example.
Also updates bridge setup section for veth pair approach.
2026-03-25 10:30:42 +10:00
Julian Sommer ad1339505d fix: dump vpcd log 2026-03-25 10:09:39 +10:00
Julian Sommer ba7e6a5c67 fix: redirect image import to dev null 2026-03-25 10:04:16 +10:00
Ben Duncan 4d56565c3f feat: veth pair bridge — link Linux bridge to OVS without conversion
setup-ovn.sh now detects whether the default route is on a Linux bridge
or OVS bridge. When it's a Linux bridge (e.g. br-wan from cloud-init),
instead of destructively converting it, the script creates an OVS bridge
(br-ext) and links them with a veth pair. The Linux bridge keeps its IP,
routes, and ports untouched — no WAN interruption.

Also fixes all pre-existing lint issues (gofmt, ineffassign, errchkjson,
gosec, modernize, whitespace) to bring golangci-lint to 0 issues.

Tested: pool mode with static IP range on single-node (ben-banksia).
VM gets public IP 192.168.1.201, SSH works, outbound internet works.
2026-03-25 10:01:37 +10:00
Julian Sommer ae316dba47 fix: remove pre-Phase4 volume tenant fallback
Simplify all 6 tenant-check sites in volume handlers from
`if accountID != "" && tenantID != "" && tenantID != accountID`
to `if tenantID != accountID`. No pre-Phase4 volumes with empty
TenantID exist in any environment — this code was unreachable.
Remove vol-legacy test fixture and empty-accountID-sees-all assertion.
2026-03-25 09:59:42 +10:00
Julian Sommer bdf0522895 feat: admin init auto install cert 2026-03-25 09:06:50 +10:00
dependabot[bot] e347f0a75e chore: bump github.com/nats-io/nats-server/v2 (#86) 2026-03-25 08:14:15 +10:00
Ben Duncan c2992ebe5e fix: reset-dev-env.sh — pass ADMIN_INIT_ARGS to second admin init --force
The second admin init --force was called without external networking
flags, overwriting the pool/nat config from the first call with
auto-detected NAT+DHCP defaults.
2026-03-24 17:09:48 +10:00
Ben Duncan ffca7330c5 fix: skip external IPAM init in NAT mode — only pool mode needs per-VM allocation
In NAT mode, all VMs share a single gateway IP for SNAT. No per-VM
public IPs are allocated. Previously, external IPAM was initialized
for any external_mode != "", causing noisy AllocateIP failures (empty
pool range) on every instance launch.

Now IPAM is only initialized when external_mode == "pool". In NAT mode,
d.externalIPAM stays nil, cleanly skipping the auto-assign public IP
path. SNAT topology is still created by vpcd via the pool's gateway_ip.

Part of mulga-737 (Phase 16).
2026-03-24 16:27:28 +10:00
Ben Duncan a21a275776 docs: update vpc-networking README for br-wan + new setup-ovn.sh flags
Replace all br-external references with br-wan. Update setup-ovn.sh
command examples to use --wan-bridge/--wan-iface/--macvlan instead of
--external-bridge/--external-iface/--direct.

Part of mulga-742.
2026-03-24 16:19:19 +10:00
Ben Duncan 78bc0d989d docs: update install guides + setup.sh for new setup-ovn.sh flags
- All guides: OVN setup before admin init (consistent ordering)
- Single-node + source-install: Replace --external-bridge/--external-iface
  with auto-detection + --wan-bridge/--wan-iface/--macvlan options
- Multi-node: Simplify examples (auto-detect WAN bridge), show override
  options in prose
- setup.sh: Update "next steps" to match single-node guide, add
  --wan-bridge/--macvlan guidance for physical NIC setups

Part of mulga-742.
2026-03-24 16:17:35 +10:00
Ben Duncan 8c6f66670a feat: add wan_bridge config field, update vpcd detection + network_detect
- config.go: Add WanBridge field to VPCDConfig (default "br-wan")
- spinifex.toml template: Add wan_bridge under [nodes.X.vpcd]
- admin.go: Auto-detect WAN bridge name from default route interface,
  add detectedWanBridge helper
- service.go: Wire WanBridge from cluster config to vpcd Config
- vpcd.go: Add WanBridge to Config, log it on startup, update comments
- network_detect.go: Fix isVirtualInterface to accept the default route
  device as a parameter instead of hardcoding br-external. Bridges like
  br-wan are now correctly treated as physical when they carry the
  default route.

Part of mulga-741.
2026-03-24 16:02:22 +10:00
Ben Duncan 1fca43c9f4 refactor: reset-dev-env.sh + stop-dev.sh — use new setup-ovn.sh flags
reset-dev-env.sh: Replace --external-bridge/--external-iface/--direct
with --wan-bridge/--wan-iface/--macvlan. Auto-detect when WAN interface
is already a bridge (tofu-cluster) vs physical NIC.

stop-dev.sh: Replace hardcoded br-external check with dynamic scan of
all OVS bridges — any non-br-int bridge with an IP means OVS is
infrastructure and should not be stopped.

Part of mulga-740.
2026-03-24 15:58:49 +10:00
Ben Duncan 34b50b11fb refactor: setup-ovn.sh — replace br-external with configurable WAN bridge
Replace --external-bridge/--external-iface/--direct with:
- --wan-bridge=NAME: OVS bridge for WAN traffic (auto-detected from
  default route if it's already a bridge)
- --wan-iface=NAME: physical NIC to add to the WAN bridge
- --macvlan: SSH-safe macvlan mode for single-NIC hosts

Auto-detection logic:
- If default route goes through a bridge (br-wan) → use it directly
- If default route is a physical NIC → stop with guidance (never
  auto-move a NIC into OVS — may drop SSH)

Bridge-mappings updated: external:$WAN_BRIDGE (was external:br-external).
OVN logical network name stays "external" (no topology.go change needed).

Encap IP now prefers br-vpc IP when available (dedicated VPC data plane).

Part of mulga-739. Blocks VPC E2E work.
2026-03-24 15:46:49 +10:00
Julian Sommer 8f81ac29a4 chore: update license references 2026-03-24 15:43:11 +10:00
Julian Sommer eefed70658 docs: add iam 2026-03-24 15:16:33 +10:00
Ben Duncan 216ca9e710 feat: Phase 12b + Phase 16 — KV reconcile on startup + NAT mode support
Phase 12b: vpcd now reconciles OVN topology from NATS KV on startup
(Pass 2). After subscribing to topics, it reads all VPCs, subnets,
IGW attachments, and ENIs from KV and ensures matching OVN objects
exist. Handles reboots, OVN DB loss, and missed events. All operations
are idempotent.

Phase 16 prep: Add --gateway-ip flag to admin init for NAT mode
(specifies the OVN gateway router's external IP without DHCP).
Update reset-dev-env.sh to support EXTERNAL_MODE=nat env var for
NAT/DHCP mode testing alongside the default pool mode.
2026-03-24 15:03:04 +10:00
Julian Sommer fefd5ed9e8 chore: update to typescript 6 2026-03-24 14:29:55 +10:00
Julian Sommer d47f2ab4fd fix: a11y 2026-03-24 14:02:26 +10:00
Julian Sommer 58a9d5741f fix: missing ui tests 2026-03-24 13:45:48 +10:00
Julian Sommer 625a78c529 feat: placement groups frontend 2026-03-24 13:39:04 +10:00
Ben Duncan 88baf5e202 feat: direct bridge mode — prefer over macvlan when WAN NIC != SSH NIC
Auto-detect bridge topology: if a dedicated WAN NIC exists (not used for
SSH), add it directly to br-external instead of creating a macvlan.
Direct bridge enables distributed NAT (DNAT on VM's own chassis) and
eliminates macvlan workarounds (MAC alignment, gratuitous ARPs,
centralized NAT bottleneck).

- topology.go: bridge-mode-aware NAT and localnet port options
- vpcd.go: auto-detect bridge mode, skip MAC alignment for direct bridge
- setup-ovn.sh: --direct flag for physical NIC on br-external
- reset-dev-env.sh: SSH NIC detection via SSH_CONNECTION
2026-03-24 01:57:54 +00:00
Julian Sommer 6379a2098a fix: lint errors 2026-03-24 11:55:42 +10:00
Julian Sommer 638828f636 feat: add leaders to get nodes command 2026-03-24 11:50:28 +10:00
Julian Sommer 0fbf4537c0 chore: bump viperblock 2026-03-24 11:24:40 +10:00
Julian Sommer 1b5108d379 chore: bump frontend deps 2026-03-24 10:45:01 +10:00
Julian Sommer bab7b1dea3 fix: enable last rules 2026-03-24 10:28:20 +10:00
Julian Sommer 30b08ca984 fix: use nullish coalescing 2026-03-24 10:20:11 +10:00
Julian Sommer 3467e0cf22 feat: oxlint type checked rules 2026-03-24 10:14:31 +10:00
Julian Sommer 35d0df5b56 feat: placement groups (#85) 2026-03-24 10:52:00 +11:00
Julian Sommer 648b36d672 fix: add agent browser 2026-03-24 09:25:48 +10:00
Ben Duncan d6062ed550 docs: document direct bridge vs macvlan modes for br-external
Direct bridge (WAN NIC added directly to OVS) is preferred when a
dedicated WAN NIC exists. Enables distributed NAT, no MAC workarounds,
host can reach VMs. Macvlan is fallback for single-NIC hosts only.
2026-03-23 11:36:15 +00:00
Ben Duncan 93aaf0f991 fix: single-node E2E — OVN SB cleanup, chassis naming, macvlan NAT, DNS
Three blockers fixed for VPC public subnet E2E on single-node:

1. OVN SB stale data: reset-dev-env.sh now deletes both NB+SB DB files
   instead of individual NB cleanup. Eliminates ovn-controller commit
   failure loop from stale chassis/port binding entries.

2. Chassis name mismatch: reset-dev-env.sh passes --chassis-id=chassis-node1
   to setup-ovn.sh, aligning OVS system-id with vpcd's chassis-{nodeName}.

3. Macvlan unicast filtering: switched to centralized NAT (no external_mac
   on DNAT rules), set nat-addresses=router on localnet ports, and vpcd
   aligns macvlan MAC with OVN gateway router MAC on startup. Macvlan only
   delivers unicast matching its own MAC — this ensures inbound traffic
   from the LAN reaches OVS/OVN.

Also fixed: DNS for VMs. admin init auto-detects host DNS servers via
resolvectl, stores them in spinifex.toml dns_servers field, and vpcd
propagates them to OVN DHCP options. Fallback: 8.8.8.8 + 1.1.1.1.

Validated E2E: inbound ping, SSH, outbound ping, DNS, curl, apt update.
2026-03-23 10:59:08 +00:00
Ben Duncan f34452883c refactor: move bootstrap config to admin init, add IGW ID, eliminate race
- admin init now pre-generates VPC/subnet/IGW IDs and writes [bootstrap]
  to spinifex.toml before services ever start. No race condition.
- EnsureDefaultVPC accepts optional BootstrapIDs to use pre-determined
  IDs instead of generating random ones.
- CreateInternetGatewayWithID allows daemon to use the bootstrap IGW ID.
- Removed writeBootstrapConfig from daemon (no longer needed).
- Fixed bootstrap account_id: uses admin account (000000000001) not
  system account (000000000000).
- reconcileIGW now sets spinifex:igw_id in OVN external IDs.
2026-03-23 06:57:25 +00:00
Ben Duncan c21f3dc188 feat: vpcd OVN reconciliation on startup + default subnet resolution
Phase 11b: RunInstances now auto-resolves the default subnet when no
SubnetId is specified, matching AWS behavior. Previously fell back to
SLIRP networking.

Phase 12: vpcd reconciles OVN topology from [bootstrap] config on startup.
The daemon writes a [bootstrap] section to spinifex.toml with the default
VPC/subnet IDs on first run. vpcd reads this on startup and creates any
missing OVN objects (router, switch, DHCP, IGW topology, SNAT, gateway
chassis). All operations are idempotent — safe on every restart.

This solves the bootstrap ordering problem where admin init creates VPC
records before vpcd is running, so NATS events are missed.

EnsureDefaultVPC now returns DefaultVPCInfo with VPC/subnet IDs.
2026-03-23 06:40:19 +00:00
Ben Duncan daae6ca6b8 refactor: remove single-nic bridge mode, standardize on macvlan
The single-nic IP migration path was fragile and caused connectivity
issues during setup. macvlan is now the only bridge strategy — always
creates spx-ext-{nic} on the WAN NIC. Removed --single-nic flag,
SingleNIC config/detection fields, and updated all docs.
2026-03-23 01:12:22 +00:00
Ben Duncan 32276c7cc7 feat: wire EIP + SG gateway actions, fix gateway chassis scheduling, default VPC infra
Phase 8a: Wire all 5 EIP gateway actions (AllocateAddress, ReleaseAddress,
AssociateAddress, DisassociateAddress, DescribeAddresses) end-to-end through
gateway → NATS → daemon → EIP service.

Phase 8b: Wire all 7 SG gateway actions (Create/Delete/Describe SecurityGroup,
Authorize/Revoke Ingress/Egress) through the same pipeline.

Critical fix: handleIGWAttach was not calling SetGatewayChassis on the gateway
router port. Without this, OVN has no chassis binding for external traffic —
SNAT/default route exist logically but packets have nowhere to go physically.
This was the root cause of outbound routing failure (can't ping 8.8.8.8).

Default VPC now matches AWS behavior: auto-creates IGW (attached),
MapPublicIpOnLaunch=true on default subnet, and a default security group
with intra-SG inbound allow + all outbound allow.

Comprehensive VPC networking docs rewrite covering modes, bridge setup,
configuration, security groups, EIPs, OVN reference, and troubleshooting.
2026-03-21 10:26:59 +00:00
Ben Duncan 30394b735b Removed legacy dir 2026-03-21 09:36:28 +00:00
Ben Duncan a25a4cec34 Merge commit '9b64c1911fc7e1a2442c376fb2cba0f5ece9a913' into vpc-public-subnet 2026-03-20 07:28:15 +00:00
Ben Duncan f6c6660f15 fix: create default VPC for admin account, not just system account
EnsureDefaultVPC was only called for GlobalAccountID (000000000000).
The admin account (000000000001) from admin init never got a default
VPC because seedAdminAccount didn't publish iam.account.created.

Fix: daemon startup now ensures default VPC for both system and admin
accounts. Also publish iam.account.created from seedAdminAccount so
any future account-creation listeners fire correctly.
2026-03-20 07:19:05 +00:00
Ben Duncan 704874c0c7 docs: restructure install guides + new common setup guide
All 3 install guides (binary, multi-node, source) now end at the same
verification point: aws ec2 describe-instance-types. Installation is
complete when that command returns instance types.

New docs/setting-up-your-cluster/README.md is the common guide all 3
install paths point to. Covers: AMI import, SSH key creation, VPC with
public subnet (IGW + modify-subnet-attribute), instance launch with
public IP, SSH via public IP, instance lifecycle management.

Removed SSH-via-hostfwd from all guides — public subnet is the default
connectivity path. Private subnet documented as an alternative.
2026-03-20 07:10:33 +00:00
Ben Duncan 665093343c fix: preserve DNS resolution after IP migration to br-external
When setup-ovn.sh migrates the WAN IP from a physical NIC to
br-external, systemd-resolved loses its DNS association (DNS servers
are per-interface). This breaks hostname resolution for everything
on the host — git, apt, curl, SSH all fail.

Now captures DNS config from the original interface before migration
and re-associates it with br-external via resolvectl. Falls back to
8.8.8.8 if no DNS was configured on the original interface.
2026-03-20 06:57:18 +00:00
Ben Duncan bb9e8db386 fix: stop-dev.sh must not stop OVS when br-external has WAN IP
When setup-ovn.sh migrates the host's WAN IP to br-external, stopping
openvswitch-switch destroys the bridge and the host loses all network
connectivity — unreachable via SSH, requires console reboot.

Now checks if br-external has an IP before stopping OVS. If it does,
skip the OVS stop and only stop ovn-controller + ovn-central. OVS is
infrastructure — bridges must survive service stop/start cycles.

Discovered during 3-node E2E testing: stop-dev.sh killed all 3 nodes'
WAN connectivity simultaneously.
2026-03-20 06:55:23 +00:00
Ben Duncan 0d4a70ef28 fix: remove verbose next-steps from admin init, disable dev_networking with external mode
Admin init output was duplicating install guide steps. Simplified to
just show completion message. Install guides already have all steps.

Set dev_networking=false when external_mode is configured — the hostfwd
NIC adds a confusing second interface inside VMs and is not needed when
public subnets provide WAN-routable IPs.
2026-03-20 06:45:11 +00:00
Ben Duncan 946429d792 feat: Phase 8c — daemon auto-assign public IP on RunInstances
Wire public IP auto-assignment end-to-end:
- RunInstances checks MapPublicIpOnLaunch on subnet, allocates external
  IP from ExternalIPAM, updates ENI record, publishes vpc.add-nat for
  dnat_and_snat rule, returns PublicIpAddress in response
- TerminateInstances releases public IP back to pool, publishes
  vpc.delete-nat to remove NAT rule before ENI deletion
- DescribeInstances populates PublicIpAddress from VM struct

Add ExternalIPAM initialization in daemon startup from cluster
NetworkConfig. Add GetSubnet and UpdateENIPublicIP helpers. Add
PublicIP/PublicIPPool fields to vm.VM struct. Fix network auto-detect
to recognize br-external as WAN interface.
2026-03-20 06:41:32 +00:00
Ben Duncan a554fe4ecf docs: rewrite single + multi-node install guides
Single-node:
- Add auto-detected network topology + external networking section
- Replace hostfwd SSH with VPC overlay private IP SSH
- Add setup-ovn.sh step with --dhcp flag
- Fix undefined variables ($SPINIFEX_AMI, $SPINIFEX_SUBNET)
- Use bash variable capture for VPC/subnet/AMI IDs
- Add three-tier external networking table
- Fix step headings (## → ###)

Multi-node:
- Move CA certificate trust to Step 5 (before starting services)
- Add --external-bridge --dhcp to all OVN setup commands
- Add AMI import step (was missing entirely)
- Add VPC + subnet creation step with variable capture
- Add SSH via private IP step
- Fix sudo inconsistency (all formation commands use sudo)
- Add external networking section with pool upgrade path
- Add DHCP troubleshooting section
- Add port requirements to overview
2026-03-20 05:58:38 +00:00
Ben Duncan 2af3bf5287 feat: auto-detect network topology + DHCP gateway in admin init
spx admin init now works with zero external networking flags:
- Auto-detects WAN interface, gateway, prefix from ip route
- Auto-detects single-NIC vs multi-NIC mode
- Defaults to nat mode with DHCP for gateway IP
- Prints detected topology with interface roles

setup-ovn.sh gains --dhcp flag: obtains a DHCP lease on the
macvlan/bridge interface for the OVN gateway SNAT address, then
writes gateway_ip into spinifex.toml. Tested on VM: macvlan gets
separate IP from router DHCP, host keeps its original IP.

Three tiers of external networking:
- Zero flags: auto nat+DHCP (VMs get outbound internet)
- --external-pool=start-end: pool mode (VMs get public IPs)
- --no-external: overlay only (no internet for VMs)
2026-03-20 05:45:30 +00:00
Ben Duncan 1f38213d4b feat: add external networking flags to spx admin init
Add --external-mode, --external-iface, --single-nic, --external-pool,
--external-gateway, --external-prefix-len flags to admin init. Config
is now generated with the correct [network] section and per-node vpcd
external_interface/single_nic — no manual spinifex.toml editing needed.

Single-node single-NIC:
  spx admin init --external-mode=pool --external-iface=eth0 --single-nic \
    --external-pool=192.168.1.150-192.168.1.250 --external-gateway=192.168.1.1

Single-node two-NIC:
  spx admin init --external-mode=pool --external-iface=eth1 \
    --external-pool=192.168.1.150-192.168.1.250 --external-gateway=192.168.1.1

Next steps output now shows the correct setup-ovn.sh command with
matching flags.
2026-03-20 05:24:06 +00:00
Ben Duncan 1ee7012ba9 feat: Phase 7 — single-NIC macvlan bridge strategy
Add --single-nic flag to setup-ovn.sh. When used with --external-bridge,
creates a macvlan sub-interface (spx-ext-{iface}) in bridge mode off the
host's NIC instead of adding the NIC directly to br-external. The host
keeps its IP — no migration, SSH-safe.

Strategy selection:
- Dedicated NIC: --external-iface=eth1 (add directly)
- Single NIC:    --external-iface=eth0 --single-nic (macvlan)
- IP migration:  --external-iface=eth0 (legacy, no --single-nic)

Add single_nic bool field to VPCDConfig for per-node config. Different
nodes in a cluster can use different strategies. Unit test verifies
single_nic parsing alongside external_interface.
2026-03-20 05:15:35 +00:00
Ben Duncan b6e99947e1 feat: Phase 5 — unit tests for ACL, security groups, public subnet, EIP
Add 18 unit tests covering:
- ACL match expression compiler (8 tests): TCP port/range, ICMP, UDP
  range, all-protocols, SG-ref source, egress, no-source filter
- Security group Port Group lifecycle (3 tests): create, delete, update
  with ACL generation via NATS events + MockOVNClient
- ModifySubnetAttribute + MapPublicIpOnLaunch (2 tests): default false,
  toggle to true via ModifySubnetAttribute
- Elastic IP lifecycle (5 tests): allocate, allocate from specific pool,
  release, release-while-associated error, describe
2026-03-20 04:35:52 +00:00
Ben Duncan 5481ef4361 feat: Phase 4 — security groups (OVN ACLs + Port Groups)
Add security group CRUD handlers: CreateSecurityGroup, DeleteSecurityGroup,
DescribeSecurityGroups, AuthorizeSecurityGroupIngress/Egress,
RevokeSecurityGroupIngress/Egress. SG records stored in NATS KV.

Add ACL match expression compiler (acl.go) translating AWS SG rules to
OVN ACL match strings for TCP/UDP port/ranges, ICMP, all-protocols, CIDR
filters, and intra-SG references.

Add vpcd security.go handlers for OVN Port Group lifecycle and ACL
management via vpc.create-sg/delete-sg/update-sg NATS topics. Extend
OVNClient interface with CreatePortGroup, DeletePortGroup,
SetPortGroupPorts, AddACL, ClearACLs. Add PortGroup and ACL to nbdb
model.
2026-03-20 04:24:49 +00:00
Ben Duncan ee06105c56 feat: Phase 3 — Elastic IPs (AllocateAddress, AssociateAddress)
Add EIP service with AllocateAddress, ReleaseAddress, AssociateAddress,
DisassociateAddress, and DescribeAddresses. Uses ExternalIPAM for IP
allocation from admin-defined pools. Publishes vpc.add-nat/delete-nat
events for OVN dnat_and_snat rules.

EIP records stored in spinifex-vpc-elastic-ips NATS KV bucket with
account-scoped keys. AssociateAddress replaces auto-assigned public IPs
when present.
2026-03-20 04:21:46 +00:00
Ben Duncan 150bf246a4 feat: Phase 2 — public subnets + auto-assign public IPs
Add MapPublicIpOnLaunch field to SubnetRecord with ModifySubnetAttribute
API handler. Add PublicIpAddress/PublicIpPool fields to ENIRecord for
tracking auto-assigned public IPs.

Add vpc.add-nat / vpc.delete-nat NATS topics and handlers in vpcd
topology for creating distributed dnat_and_snat NAT rules (with
LogicalPort + ExternalMAC for chassis-local NAT processing).

Wire ModifySubnetAttribute through gateway → daemon → VPC service.
2026-03-20 04:19:28 +00:00
Julian Sommer 9b64c1911f ci: add pull_request triggers to CI workflows
Enable PR-triggered CI so required status checks can gate merges.
Spinifex runs on all PRs (no path filter) to avoid blocking non-Go PRs.
UI workflow keeps path filters as it's not a required check.
2026-03-20 14:08:12 +10:00
Ben Duncan c1cdfc07c3 feat: Phase 1 completion — external IPAM, br-external setup, config template
Add external IPAM module (CAS-based IP allocation from admin-defined
pools in NATS KV). Supports multi-pool with region/AZ scoping, pool
fallback logic, gateway IP reservation, and concurrent allocation via
CAS retries. 12 unit tests covering allocation, release, exhaustion,
CAS conflict, multi-pool, fallback, validation, and idempotent init.

Add --external-bridge flag to setup-ovn.sh: creates OVS bridge
br-external, adds WAN NIC, migrates host IP to bridge, and sets
ovn-bridge-mappings=external:br-external for OVN localnet ports.

Add commented [network] section and external_interface to spinifex.toml
config template for discoverability.
2026-03-20 03:59:47 +00:00
Ben Duncan c6af189638 Merge commit '02931567ac207bd074d8b2efda6e523ab36540d9' into vpc-public-subnet 2026-03-20 03:47:50 +00:00
Julian Sommer 02931567ac release: update go.mod for v0.4.2 2026-03-20 13:09:48 +10:00
dependabot[bot] c4890c80d3 build(deps): bump fast-xml-parser (#83) 2026-03-20 12:48:53 +11:00
Julian Sommer d2d3e52522 feat: migrate to golangci-lint (#82) 2026-03-20 12:14:42 +11:00
Julian Sommer 42cb6dbed0 fix: add better error message 2026-03-20 11:12:32 +10:00
Julian Sommer 6cdeea3aa8 chore: more tests 2026-03-20 09:27:21 +10:00
Julian Sommer 4884b68b51 feat: version bootstrap file 2026-03-20 09:12:42 +10:00
Julian Sommer 726528bd9d feat: version jetstream buckets 2026-03-20 09:07:40 +10:00
Julian Sommer 593920ce7d feat: E2E test redistribution and dev-setup cleanup
Redistribute E2E tests between single-node and multi-node suites, slim
down pseudo multi-node suite, and remove directory creation from
dev-setup.sh so spx admin init is the single source of truth for
~/spinifex/ setup.
2026-03-20 08:24:15 +10:00
Ben Duncan 5e30d12f76 feat: Phase 1 — external network config + IGW gateway router fix
Add cluster-wide [network] config with external_mode and multi-pool
support (ExternalPool with region/AZ scoping). Per-node vpcd gets
external_interface for WAN NIC name.

Fix handleIGWAttach to use real external IPs from the configured pool
instead of link-local 169.254.0.1/30. Gateway router port now gets
the pool's gateway IP with correct prefix length, SNAT uses the real
external IP, and the default route points to the actual WAN gateway.

Includes pool selection logic (AZ-scoped → region-scoped → unscoped
fallback) and unit tests for config parsing, IGW with external pool,
explicit gateway IP, and pool selection.
2026-03-19 11:34:30 +00:00
Julian Sommer ea8700df99 fix: increase sleep time 2026-03-19 15:02:50 +10:00
Julian Sommer 857cbd75ac fix: use spx service stop instead of shell PID kill in stop-dev.sh 2026-03-19 14:50:50 +10:00
Julian Sommer 0fa38f7986 fix: write credentials to user even when running as sudo 2026-03-19 14:05:48 +10:00
Julian Sommer 1a4d590bf3 fix: nbd log level 2026-03-19 13:45:27 +10:00
Julian Sommer b7678ab7c5 feat: fix silent failures across spinifex (P2 audit H-25 through H-43)
- PID file failures now prevent service start instead of logging and continuing
- Status() methods use PID-based health checks instead of returning empty string
- NewResourceManager returns error instead of silently defaulting to 8GB
- Crash recovery re-allocates resources before relaunch
- restoreInstances stops after failed resource allocation
- Cluster manager bind errors propagated to Start()
- NATS disconnect/reconnect logged at Warn level
- VPC CIDR fallback logs warning about overbroad rule
- vpcd respond() handles json.Marshal failure
- Network cleanup failures logged instead of silently discarded
- fmt.Println replaced with slog.Debug in NATS service
- Signature mismatch no longer logs expected signature
- extractDiskImagePath uses errors.Is for EOF
- validateDiskImagePath checks file command error
- os.Stat uncommented in admin image import
- net.SplitHostPort errors checked
- Gateway response write errors logged
- vpcd OVN NB address uses leader IP on non-primary nodes
- Release workflow parallelized with native arm64 runner
2026-03-19 13:37:41 +10:00
Julian Sommer 11730be685 fix: locate release assets by filename instead of hardcoded path 2026-03-19 12:13:28 +10:00
Julian Sommer a4bc8bee74 fix: pending instance terminate (#80) 2026-03-19 12:47:00 +11:00
Julian Sommer fafc104c30 feat: add release verification job with draft-then-promote flow 2026-03-19 10:52:39 +10:00
dependabot[bot] efa6257850 build(deps): bump google.golang.org/grpc from 1.79.1 to 1.79.3 (#79) 2026-03-19 11:35:06 +11:00
Julian Sommer 7e46f0012b chore: bump more action versions 2026-03-19 10:23:52 +10:00
Julian Sommer 58e6817efe chore: bump docker build action ver 2026-03-19 10:19:02 +10:00
Julian Sommer 71ec2b7ee5 release: update go.mod for v0.4.0 2026-03-19 08:28:16 +10:00
Julian Sommer 9c430eea07 fix: release workflow 2026-03-19 08:17:30 +10:00
Julian Sommer 1be6c69e51 format 2026-03-19 08:03:23 +10:00
Ben Duncan c523f78403 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-03-18 05:08:30 +00:00
Ben Duncan 2918a02fac correct spx path 2026-03-18 05:08:15 +00:00
Julian Sommer 8e3210f023 fix 2026-03-18 14:36:12 +10:00
Julian Sommer 6ba9d3dbf0 remove trailing slash 2026-03-18 14:24:52 +10:00
Ben Duncan 27cdfc4653 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-03-18 04:14:22 +00:00
Ben Duncan adadb5ef4c Fixed noninteractive error on apt get 2026-03-18 04:13:53 +00:00
Julian Sommer d0fc2a2120 last fix 2026-03-18 13:40:27 +10:00
Julian Sommer a09a8efe3c build frontend with new branding 2026-03-18 13:38:47 +10:00
Julian Sommer 7d43d0f782 hopefully last rename 2026-03-18 13:24:45 +10:00
Julian Sommer ba0d8b67ff feat: e2e pipeline speedup (#78) 2026-03-18 13:50:45 +11:00
Ben Duncan 454f381826 Moved to GNU Affero General Public License v3 2026-03-18 02:04:41 +00:00
Ben Duncan b95ff42a2f feat: rename product from Hive to Spinifex
Trademark conflict — bulk rename across entire codebase:
- Go module: github.com/mulgadc/hive → github.com/mulgadc/spinifex
- Binary: hive → spx
- Directories: hive/ → spinifex/, hiveui → spinifexui
- Env vars: HIVE_* → SPINIFEX_*
- Config: hive.toml → spinifex.toml
- Systemd: hive-* → spinifex-*
- NATS: hive-workers → spinifex-workers
- All paths, docs, scripts, CI workflows updated
2026-03-18 01:42:53 +00:00
Ben Duncan 2064c338b3 chore: gofmt formatting after rename 2026-03-18 01:23:11 +00:00
Ben Duncan 4f00cf9e4c refactor: update Dockerfile and build artifacts for spinifex rename
- Dockerfile.distro: update all paths from hive → spinifex
- Binary output name: spx
- Logrotate: logrotate-spinifex
2026-03-18 01:20:27 +00:00
Ben Duncan 48012a4058 refactor: rename all documentation from hive to spinifex
- Rename .claude/rules/hive.md → spinifex.md
- Update CLAUDE.md component description
- Rename doc directories: hive-admin-cli → spinifex-admin-cli, etc.
- Rename doc files: hive-cluster-stats, hive-code-consolidation, etc.
- Bulk update all markdown files across docs/ tree
- Update internal docs: CLUSTER-INSTALL, TESTING, DEVNOTES, BRANCHES
2026-03-18 01:19:05 +00:00
Ben Duncan 55186d95f2 refactor: rename CI/CD workflows from hive to spinifex
- Rename hive.yml → spinifex.yml
- Update workflow names, path triggers, working directories
- Update release.yml tarball references
- Update e2e.yml test paths
- Update ui.yml frontend paths
2026-03-18 01:09:02 +00:00
Ben Duncan c11ac04892 refactor: rename frontend UI branding from hive to spinifex
- package.json: name → spinifex-ui
- index.html: page title → Spinifex Control Panel
- sidebar-layout: Mulga Hive → Mulga Spinifex
- auth.ts: localStorage key → spinifex:v1:aws-credentials
- main.tsx: theme storage key → spinifex-ui-theme
- spinifexui_test.go: update service name assertion
2026-03-18 01:06:55 +00:00
Ben Duncan 9e744e5be6 refactor: rename NATS queue group and topics from hive to spinifex
- hive-workers → spinifex-workers queue group (~16 files)
- hive.admin.* → spinifex.admin.* topics
- hive.nodes.* → spinifex.nodes.* topics
- hive.node.* → spinifex.node.* topics
- hive.cluster.* → spinifex.cluster.* topics
- spinifex-nats server_name already updated in Phase 5
2026-03-18 01:06:03 +00:00
Ben Duncan 5d93786995 refactor: rename hive to spinifex in all shell scripts
- Update scripts/: start-dev, stop-dev, dev-setup, setup, setup-ovn, etc.
- Update tests/e2e/: all E2E test scripts
- Update scripts/iac/: rename hive-test.sh → spinifex-test.sh, update TF files
- Update config/: rename hive.service and install script
- Binary refs use spx, env vars use SPINIFEX_, paths use spinifex
2026-03-18 01:04:12 +00:00
Ben Duncan b97a98e2b7 refactor: rename systemd units and logrotate from hive to spinifex
- Rename all build/systemd/hive-* → spinifex-*
- Rename build/logrotate/hive → spinifex
- Update unit contents: descriptions, dependencies, user/group, paths
- Binary path set to /usr/local/bin/spx
2026-03-18 00:53:34 +00:00
Ben Duncan ee0b92c192 refactor: rename config files, paths, and env vars from hive to spinifex
- Rename hive.toml template → spinifex.toml
- Update all default paths: /etc/hive → /etc/spinifex, ~/hive → ~/spinifex
- Rename all HIVE_* env vars → SPINIFEX_* (SetEnvPrefix, BindEnv calls)
- Update nats.conf template server_name
- Update admin.go init/join commands for new paths and names
- Update paths.go, paths_test.go, config.go, config_test.go
2026-03-18 00:47:01 +00:00
Ben Duncan 00f5b6590e refactor: rename CLI binary to spx and update all command names
- Makefile: GO_PROJECT_NAME → spx, update build/test/dist paths
- root.go: Use → spx, update branding to Spinifex
- service.go: hive/hive-ui commands → spinifex/spinifex-ui
- Service name constants: hive → spinifex, hive-ui → spinifex-ui
- Update service_test.go switch cases
2026-03-18 00:42:34 +00:00
Ben Duncan 06804f5775 refactor: rename directory structure hive → spinifex
- git mv hive/ → spinifex/
- git mv cmd/hive/ → cmd/spinifex/
- git mv services/hive/ → services/spinifex/
- git mv services/hiveui/ → services/spinifexui/
- Update package declarations and all internal import paths
- Update service.go switch cases for new service names
2026-03-18 00:37:30 +00:00
Ben Duncan 767ad0bf5c refactor: rename Go module path from hive to spinifex
Bulk rename github.com/mulgadc/hive → github.com/mulgadc/spinifex
across go.mod and all 178 Go files (~411 import occurrences).
2026-03-18 00:29:24 +00:00
Julian Sommer 4152e4422f chore: remove redundant x/net/http2 and automaxprocs deps
ForceAttemptHTTP2 + NextProtos ALPN already handle HTTP/2 on Go 1.22+.
Go 1.25+ runtime is container-aware for GOMAXPROCS by default.
2026-03-18 10:14:52 +10:00
Julian Sommer 48bbe423df fix: complete termination lifecycle for failed instances 2026-03-18 09:53:48 +10:00
Julian Sommer e6b5b3b135 chore: update vite react plugin 2026-03-18 08:54:57 +10:00
Julian Sommer f526972607 chore: go back to ultracite 2026-03-18 08:46:49 +10:00
Julian Sommer ddcd7f622f chore: bump deps 2026-03-18 08:20:18 +10:00
Ben Duncan 2459e5f7ac improved docs 2026-03-16 12:52:14 +00:00
Ben Duncan 120bfa8953 fix: generate go.work in Dockerfile instead of copying gitignored file 2026-03-16 12:36:56 +00:00
Ben Duncan d8d5fd7d2c New doc links 2026-03-16 12:32:44 +00:00
Ben Duncan b857c7982e Merge remote-tracking branch 'origin/dev' 2026-03-16 12:17:57 +00:00
Ben Duncan 965e65df3b Added test coverage 2026-03-16 12:14:46 +00:00
Ben Duncan 4c4683ba0c installer tweaks for sudo 2026-03-16 12:04:06 +00:00
Ben Duncan a458a28406 new binary mode and systemd process 2026-03-16 11:57:52 +00:00
Ben Duncan 2351d4ff81 New doc templates 2026-03-16 22:47:08 +11:00
Julian Sommer e7a3b5b712 fix: update test for explicit error handling 2026-03-13 16:07:49 +10:00
Julian Sommer 36f820c859 fix: error handling (#76) 2026-03-13 17:01:00 +11:00
Julian Sommer c60a691ac1 fix: start/stop race condition 2026-03-13 05:27:28 +00:00
Julian Sommer 21fd5a7081 fix: IAM replication 2026-03-13 04:33:19 +00:00
Julian Sommer 2b455e365e fix: dropped state 2026-03-13 03:53:38 +00:00
Julian Sommer e6a09d371c fix: multi node e2e retry 2026-03-13 03:37:41 +00:00
Julian Sommer 6f3f348287 chore: bump vite plugin react 2026-03-13 02:52:08 +00:00
Julian Sommer 8e8b17c599 chore: migrate to vite8 2026-03-13 02:39:28 +00:00
Julian Sommer 72aa3e7563 feat: migrate to oxlint (#75) 2026-03-13 13:24:01 +11:00
Julian Sommer 096b3380fc feat: default vpc account scoping (#74) 2026-03-13 13:18:58 +11:00
Julian Sommer 663805cd23 fix: race conditions on node failure 2026-03-13 01:53:00 +00:00
Julian Sommer 380ab2a870 fix: replicate all js buckets 2026-03-13 10:48:00 +10:00
Julian Sommer 98c75cbb83 fix: stop-dev.sh 2026-03-13 09:17:41 +10:00
Julian Sommer 3ffe67271f feat: multi node e2e (#73) 2026-03-13 10:11:26 +11:00
Julian Sommer 4466de04d3 fix: ovn logrotate 2026-03-12 08:24:52 +10:00
Julian Sommer fd91b80851 fix: hardcoded predastore nats url 2026-03-11 13:57:49 +10:00
Julian Sommer 0853eb5068 fix: return error from GenerateNATSToken instead of panicking
Same pattern as H-5 — crypto/rand failure caused a panic. Now returns
(string, error) so callers can handle it gracefully.
2026-03-11 12:08:44 +10:00
Julian Sommer 3d8c9f3812 fix: hive panics (#71) 2026-03-11 13:02:31 +11:00
Julian Sommer 2cac5e2c37 fix: cors 2026-03-11 11:07:44 +10:00
Julian Sommer 0ec13f33eb fix: lint 2026-03-11 10:58:03 +10:00
Julian Sommer 7af16b75b9 feat: 80% real frontend coverage 2026-03-11 10:56:42 +10:00
Julian Sommer 40e09f0886 feat: enforce frontend code coverage 2026-03-11 10:25:04 +10:00
Julian Sommer e26cd7fd82 feat: ui tests 2026-03-11 10:14:31 +10:00
Julian Sommer 8f223d35d3 feat: setup vitest 2026-03-11 09:47:46 +10:00
Julian Sommer b0dbc76b22 chore: frontend deps 2026-03-11 09:32:46 +10:00
Julian Sommer 506bced4fa fix: e2e single 2026-03-11 08:43:05 +10:00
Ben Duncan 438ec3c973 Merge branch 'dev' of github.com:mulgadc/hive into dev 2026-03-10 22:13:11 +10:00
Ben Duncan 11a1f18cb0 removed restrict on hostfwd, vpc issue to ssh in 2026-03-10 22:13:00 +10:00
Julian Sommer 1ff08ab80a fix: seperate system account (#70) 2026-03-10 16:30:34 +11:00
Ben Duncan d17eef2ba9 Merge branch 'dev' of github.com:mulgadc/hive into dev 2026-03-10 10:58:04 +10:00
Ben Duncan b1059899a2 dev networking, remote dhcp, due to double route bug 2026-03-10 10:57:55 +10:00
Julian Sommer 17da8b2eb2 fix: update INSTALL.md 2026-03-10 10:49:29 +10:00
Julian Sommer ee3df227a7 feat: reboot instances (#68) 2026-03-10 11:48:58 +11:00
Julian Sommer 015dbbc272 feat: frontend performance 2026-03-10 10:31:59 +10:00
Julian Sommer 404b3d6246 release: update go.mod for v0.2.3 2026-03-10 09:36:58 +10:00
Julian Sommer 021b0982f4 fix: bump go 1.26.1 references and fix gosec G104 in hiveui 2026-03-10 08:33:04 +10:00
Julian Sommer 7b590957e1 fix: bump go 1.26.1 2026-03-10 08:19:38 +10:00
Julian Sommer c9c3cda651 fix: redirect HTTP to HTTPS on same port for hive-ui
Uses a TLS-detecting listener that peeks the first byte of each
connection. TLS handshakes (0x16) proceed normally; plain HTTP
requests get a 301 redirect to the HTTPS URL. This handles
http://localhost:3000 gracefully instead of showing a cryptic error.
2026-03-10 08:14:19 +10:00
Julian Sommer 1efb4c4a3b fix: config 2026-03-06 14:51:21 +10:00
Julian Sommer 05cb4ac108 feat: frontend refactors (#66) 2026-03-06 15:42:01 +11:00
Julian Sommer 6eac4d6598 feat: IAM Frontend (#65) 2026-03-06 15:14:50 +11:00
Julian Sommer f03a2314c6 feat: terminated instances expiry (#63) 2026-03-06 12:51:27 +11:00
Julian Sommer 2c444b1eba chore: install tsgo 2026-03-06 11:46:21 +10:00
Julian Sommer aea2e2c05a ci: setup go work after 2026-03-06 10:41:45 +10:00
Julian Sommer d90bf53eaf fix: log rotate ovn 2026-03-06 10:10:34 +10:00
Julian Sommer 14c52366da fix: decouple gateway from qmp 2026-03-06 10:06:59 +10:00
Julian Sommer 6737c42601 refactor: consolidate NATS/API types into types/ package (phase 1)
Move EBS, cluster, and node message types from config/ to types/.
Remove duplicate NodeDiscoverResponse from gateway and daemon.
Delete dead EC2StartInstances types and tests from config.
Update all imports across source and test files.
2026-03-06 09:56:04 +10:00
Julian Sommer 19988af768 fix: gofmt formatting 2026-03-06 09:48:16 +10:00
Julian Sommer d20fcd2c38 fix merge 2026-03-06 09:45:34 +10:00
Julian Sommer 1c6950b89e release: update go.mod for v0.2.2 2026-03-06 09:45:34 +10:00
Julian Sommer 94ec56bb59 fix: extract shared type 2026-03-06 09:45:34 +10:00
Julian Sommer 0b2e613c6c fix: diff-coverage.sh 2026-03-06 08:22:30 +10:00
Julian Sommer 2d304528b8 chore: bump deps 2026-03-06 08:15:48 +10:00
Julian Sommer 35f2104325 feat: more tests 2026-03-05 15:09:24 +10:00
Julian Sommer c4027a0380 test: add gateway core routing, IAM auth, and handler test coverage
Squash of dev commits: gateway core routing/policy/middleware tests,
IAM auth and handler tests, awserrors tests, respondWithJSON helper,
utils refactor into focused files, and daemon test speedups.
2026-03-05 14:20:00 +10:00
Julian Sommer 77c6d05042 refactor: replace NYTimes/gziphandler with stdlib compress/gzip
Drop the external gziphandler dependency in hiveui. The replacement
uses a simple http.ResponseWriter wrapper that checks Content-Type
against an allowlist before enabling gzip compression. Same behavior,
zero external deps.
2026-03-05 12:53:46 +10:00
Julian Sommer 8af3d2e5fa feat: migrate off fiber (#62) 2026-03-05 12:53:46 +10:00
Julian Sommer 49c5807ef1 refactor: accounts 2026-03-05 11:41:51 +10:00
Julian Sommer 5fad038b87 feat: IAM account scoping (#61) 2026-03-05 09:06:35 +10:00
Julian Sommer 48c0565d47 fix: ovn shut down on stop-dev.sh 2026-03-03 15:12:44 +10:00
Julian Sommer 371193a832 feat: IAM v1 (#59) 2026-03-03 15:23:19 +11:00
Julian Sommer 9d66ef8233 fix: sanitize error codes to prevent leaking internal errors to clients
Add awserrors.ValidErrorCode() to validate error strings against
ErrorLookup before sending to clients. Replace hardcoded
"ValidationError" strings in utils.go with awserrors constants.
2026-02-27 08:49:18 +10:00
Julian Sommer 992111e809 fix: race condition + frontend refetching 2026-02-27 08:14:50 +10:00
Julian Sommer 12c2e2789e fix: skip diff coverage when instrumentable lines below minimum
The MIN_LOC=100 threshold checked total diff lines, not instrumentable
lines. A commit with many non-instrumentable lines (structs, comments)
but only 1-2 instrumentable lines would unfairly fail at 0% coverage.
Add a minimum instrumentable lines threshold (5) so the check skips
when the sample size is too small to be meaningful.
2026-02-26 11:35:22 +10:00
Julian Sommer 3720a5ba90 feat: blockdevicemappings fix (#55) 2026-02-26 11:47:37 +11:00
Julian Sommer de0e049b16 fix: e2e console output 2026-02-26 08:26:34 +10:00
Julian Sommer 6e3fe148af feat: frontend features 2026-02-26 08:22:55 +10:00
Julian Sommer 81b990a238 fix: e2e verify errored instance didnt restart on launch 2026-02-25 11:33:44 +10:00
Julian Sommer 52f2498699 fix: proper recovery checks e2e 2026-02-25 11:16:07 +10:00
Julian Sommer 61e8eb9d91 fix: reset LaunchTime on instance relaunch to prevent watchdog termination
During cluster restart, restoreInstances resets running instances (with
dead QEMU) to pending for relaunch, but didn't reset LaunchTime. The
pending watchdog (5min timeout) saw the stale LaunchTime from the
original launch and immediately marked instances as failed/terminated,
preventing any relaunch from succeeding.
2026-02-25 10:48:16 +10:00
Julian Sommer 1453beca6b Merge remote-tracking branch 'origin/main' into dev 2026-02-25 10:45:45 +10:00
Julian Sommer d0056ee925 fix: daemon-level retry with backoff for NATS/JetStream startup (#52) 2026-02-25 11:43:10 +11:00
Julian Sommer 55486490aa fix: kv stream recovery (#54) 2026-02-25 11:41:28 +11:00
Julian Sommer b370f6336e docs 2026-02-25 10:01:12 +10:00
Julian Sommer 1910347cce fix: make building on start-dev.sh opt in 2026-02-25 09:56:13 +10:00
dependabot[bot] e08b8140a6 chore: bump github.com/gofiber/fiber/v2 from 2.52.11 to 2.52.12 (#51) 2026-02-25 10:36:09 +11:00
dependabot[bot] a7e0cb6972 chore: bump github.com/nats-io/nats-server/v2 (#50) 2026-02-25 10:35:49 +11:00
Julian Sommer 6b9fc7b600 refactor: daemon 2026-02-25 08:22:46 +10:00
Julian Sommer c2e66e1ab4 refactor: extract respondWithError; fix nil panic in RunInstance
Extract duplicated respondWithError closures into a single package-level
function. Guard against nil ImageId in handleEC2RunInstances to prevent
panic under race conditions. Fix diff-coverage base ref for main branch.
2026-02-24 15:02:57 +10:00
Julian Sommer deae613a75 refactor: split daemon_handlers.go into per-domain files
Split the monolithic daemon_handlers.go into focused files:
  - daemon_handlers_account.go
  - daemon_handlers_console.go
  - daemon_handlers_igw.go
  - daemon_handlers_image.go
  - daemon_handlers_instance.go
  - daemon_handlers_key.go
  - daemon_handlers_snapshot.go
  - daemon_handlers_tag.go
  - daemon_handlers_volume.go
  - daemon_handlers_vpc.go

Add tests for ModifyVolume success, TerminateStoppedInstance with volumes,
DescribeInstanceTypes capacity filter, StartStoppedInstance type-not-available,
AttachVolume/DetachVolume validation paths, RunInstances error paths, and
DescribeInstances malformed ID.

Fix diff-coverage.sh to use git rename/copy detection (-M -C) so file
splits are not incorrectly flagged as all-new code.
2026-02-24 14:28:24 +10:00
Julian Sommer 8188707c91 feat: VPC networking v1 — OVN-based virtual networking for EC2 instances 2026-02-24 14:13:57 +10:00
Julian Sommer b9c528ce4c fix frontend vuln 2026-02-24 11:56:17 +10:00
Julian Sommer 5992d3f056 test: general test coverage improvements across hive packages
Coverage improvements:
- awsec2query: parser byte slice, invalid input, locationName fallback (73% → 83%)
- config: EC2StartInstancesResponse.Respond NATS round-trip, NodeBaseDir, ShardWAL
- daemon: 14 NATS delegate handlers, device allocation, canAllocate (count=0 boundary),
  resource management, shutdown ACK/progress, JetStream WriteServiceManifest,
  fix smallestAllocType to score by both vCPU and memory
- gateway: GenerateEC2ErrorResponse XML validation (fixed no-op assertion),
  DiscoverActiveNodes (5 scenarios), ParseAWSQueryArgs, ParseArgsToStruct (41.6% → 52.1%)
- utils: ConnectNATS (success, token, wrong token, bad address), NATSRequest
- vm: ResetNodeLocalState, PIDFile/QMP, NoGraphic, Serial+Console partial configs,
  NetDevs, ARM64, KVM, full config (68.1% → 91.7%)
2026-02-20 15:09:33 +10:00
Julian Sommer 6bc36455fc test coverage improvements: instance, hiveui, health, utils
Total hive coverage: 54.3% → 55.8% (+1.5%)

- instance/service_impl.go: new test file (generateHostname, RunInstance,
  cloud-init template, constructor)
- hiveui.go: new test file (security headers, gzip middleware, New(),
  Status(), Reload())
- daemon/health.go: extend existing tests (handleInstanceCrash core flow,
  first crash time, unknown instance type, maybeRestart scheduling)
- utils/utils.go: extend existing tests (pidPath all branches, RuntimeDir,
  extractDiskImagePath, WritePidFile error path)
- fix: use smallestAllocType in health tests to avoid CI resource failures
2026-02-20 13:46:31 +10:00
Julian Sommer db45c028be test: e2e + unit coverage for ModifyInstanceAttribute and key service
- Add Phase 7b to single-node E2E: stop instance, modify type (nano →
  xlarge), start, SSH in, verify vCPU count (nproc) and memory
  (/proc/meminfo) match the new instance type
- Add Phase 8q negative test: modify-instance-attribute on running
  instance expects InvalidInstanceID.NotFound
- Improve key service integration test coverage (mulga-580)
- Pin viperblock/predastore CI checkout to main (fixes feature branch CI)
- Update TEST_COVERAGE.md
2026-02-20 12:01:03 +10:00
Julian Sommer ff6ca8a057 feat: modify-instance-attribute (#48) 2026-02-20 12:19:20 +11:00
Julian Sommer df0e8a6459 ci: add race detection, fix test race conditions 2026-02-20 09:40:02 +10:00
Julian Sommer 3dc3d41d79 ci: enforce code coverage 2026-02-20 08:58:44 +10:00
Julian Sommer eba702b1f2 test: add 10 viperblockd coverage tests, fix daemon test sleep
viperblockd: add snapshot handler, sync/unmount/delete with VB instance,
invalid JSON, dual-publish, generic topic routing, and auxiliary volume
suffix tests. Coverage 43.1% → 53.6%.

daemon: write clean shutdown marker in restore tests to skip the 3s
crash-recovery sleep (no QEMU runs in tests). Suite 36s → 6s.
2026-02-20 08:42:30 +10:00
Julian Sommer e6ffecafca gofix: exclude stringsbuilder analyzer from modernize checks 2026-02-19 15:15:24 +10:00
Julian Sommer 27a27f660a feat: add go fix check in ci 2026-02-19 14:43:59 +10:00
Julian Sommer b4dcd3ead9 gofix: modernize codebase with go fix analyzers
Apply safe modernizations across the codebase:
- minmax: replace if/else clamping with min/max builtins
- fmtappendf: replace []byte(fmt.Sprintf) with fmt.Appendf
- forvar: remove redundant loop variable re-declarations
- inline: rename reflect.Ptr to reflect.Pointer
- mapsloop: replace explicit map loops with maps package
- omitzero: remove ineffective omitempty on time.Time fields
- rangeint: replace 3-clause for loops with range-over-int
- slicescontains: replace manual loops with slices.Contains
2026-02-19 14:40:16 +10:00
Julian Sommer 43acb57b9f feat: add e2e tests for serial console access and error compliance 2026-02-19 14:23:55 +10:00
Julian Sommer 17c011e9db fix 2026-02-19 13:17:20 +10:00
Julian Sommer a330e35d5f feat: account settings console access (#47) 2026-02-19 13:05:11 +10:00
Julian Sommer a5ff1ca5a7 feat: more awserrors (#46) 2026-02-19 12:15:45 +11:00
Julian Sommer 8573800914 fix: move instances with unavailable type to stopped on recovery (#45) 2026-02-19 10:31:13 +11:00
Julian Sommer cefbcea485 fix: README.md 2026-02-19 08:30:25 +10:00
Julian Sommer 1bf4a9cfab fix: ci branches 2026-02-18 15:05:28 +10:00
Julian Sommer 09a1e142eb release: update go.mod for v0.2.1 2026-02-18 15:01:36 +10:00
Julian Sommer ca14ecf2bc fix: port splitting (#44) 2026-02-18 15:20:40 +11:00
Julian Sommer 0c65d75371 feat: add coordinated cluster shutdown to multi-node E2E tests (#42) 2026-02-18 13:22:12 +11:00
Julian Sommer b5d2c22e68 chore: remove legacy security alias from Makefile (#43) 2026-02-18 12:23:22 +11:00
Julian Sommer 135f02c386 feat: make hive test use dev versions 2026-02-18 09:36:25 +10:00
Ben Duncan 4986e3fb87 fix: disable sharded WAL by default, bump viperblock v1.0.14
Sharded WAL causes incomplete write errors in E2E tests. Disable by
default in hive.toml template, service.go fallback, and viperblock
constructor. Can be re-enabled via hive.toml shardwal=true.
2026-02-18 07:08:11 +10:00
Ben Duncan 20e67051b0 feat: EC2 GetConsoleOutput via QEMU socket chardev + logfile capture
Replace -serial pty with socket chardev + logfile for serial output:
- Console log written to {RuntimeDir}/console-{instanceID}.log
- Serial socket at {RuntimeDir}/serial-{instanceID}.sock for admin access
- Eliminates PTS extraction deadlock (mulga-lfo) — no more ptsChan
- GetConsoleOutput API handler reads log, returns base64-encoded output
- Per-instance NATS topic ec2.{instanceID}.GetConsoleOutput (no fan-out)
- Admin debugging via: socat -,raw,echo=0 UNIX-CONNECT:serial-*.sock
2026-02-18 06:25:43 +10:00
Ben Duncan 5f370ab75e fix: bump viperblock v1.0.13, add local Docker E2E test docs
Update viperblock dependency to v1.0.13 which fixes CreateSnapshot
skipping WAL flush/consolidate when the VB instance doesn't own the
WAL files (viperblockd snapshot scenario).

Add local Docker E2E test instructions to TESTING.md.
2026-02-18 06:25:20 +10:00
Ben Duncan a1a801b26a fix: bump viperblock v1.0.12 — shard WAL snapshot corruption fix
Viperblock v1.0.12 fixes WriteShardedWALToChunk corrupting NBD plugin's
active WAL files when called from a VB instance that doesn't own the WAL
(viperblockd snapshot path). This caused CreateImage to fail with
"checksum mismatch in shard 0" on the second snapshot.
2026-02-17 22:57:36 +10:00
Ben Duncan c2265920eb fix: stop-dev.sh incorrectly detects single-node as multi-node in Docker E2E
is_multinode() counted all [nodes.*] TOML subsections (daemon, nats, awsgw,
etc.) instead of just top-level node entries. A single-node config has ~8
such sections, causing the single-node Docker E2E to use coordinated cluster
shutdown and exit with code 254.

Fix grep to match only [nodes.X] (not [nodes.X.Y]) and preserve the original
test exit code in the cleanup trap.
2026-02-17 22:15:49 +10:00
Ben Duncan bb1fe5e485 feat: add configurable shard WAL toggle, bump viperblock v1.0.11
Add [viperblock] section to hive.toml with shardwal config (default
true). The setting flows from config through viperblockd to nbdkit
plugin args, allowing operators to disable sharded WAL if needed.

Bumps viperblock dependency from v1.0.10 to v1.0.11 which includes
the sharded WAL implementation (UseShardedWAL/OpenShardedWAL), fixing
CI build failures.
2026-02-17 21:48:44 +10:00
Ben Duncan 1b431b29f7 Merge branch 'main' of github.com:mulgadc/hive 2026-02-17 16:14:49 +10:00
Ben Duncan c645fc46b1 fix: dispatch chunk WAL opening for EFI and cloud-init volumes
With UseShardedWAL now defaulting to true, the EFI and cloud-init
volume preparation must open sharded WAL files instead of always
opening the legacy WAL. Without this, Flush() dispatches to the
sharded path but shard DB files are nil, causing zero-byte writes.
2026-02-17 16:14:00 +10:00
Julian Sommer 83b1435c99 fix: refactor instance type detection (#41) 2026-02-17 15:54:45 +11:00
Julian Sommer 7128a22fdb feat: detect CPU generation instead of vendor for instance types (#40) 2026-02-17 14:59:29 +11:00
Julian Sommer b7396b97c0 fix: use available instance types in allocation test to prevent flaky CI
The FilterUnavailableTypesAfterAllocation test picked a random 2-vCPU
type from the raw instance type map, which could include memory-hungry
types (e.g. r5a.large at 16 GB) that exceed host RAM on CI runners
(~15.6 GB). Now picks from the already-filtered available types list.
2026-02-17 13:30:44 +10:00
Julian Sommer 1735a46f11 fix: e2e 2026-02-17 12:32:40 +10:00
Julian Sommer 8d9da69abb fix: e2e 2026-02-17 11:52:31 +10:00
Julian Sommer c6b89e873a feat: more testing 2026-02-17 11:47:56 +10:00
Ben Duncan 88ee6e3171 merge: resolve conflicts, accept remote changes 2026-02-17 11:43:38 +10:00
Julian Sommer dad0df0e33 fix: remove .vscode 2026-02-17 10:50:24 +10:00
Julian Sommer aeb802448b feat: more instance types 2026-02-17 10:39:59 +10:00
Julian Sommer b648fe7390 fix: merge 2026-02-17 10:32:35 +10:00
Ben Duncan 0e47a376c7 merge 2026-02-17 10:29:29 +10:00
Ben Duncan 81bf36bfc1 Moved .claude to internal repo for development 2026-02-17 10:28:21 +10:00
Ben Duncan 93c5b09ac5 Merge branch 'main' of github.com:mulgadc/hive 2026-02-17 09:42:48 +10:00
Ben Duncan dd7bce20e6 Move internal dev docs to private repo, trim CLAUDE.md for public use
Internal development plans, roadmap, and detailed dev process docs moved
to private mulga repo. CLAUDE.md trimmed to remove SSH credentials, node
IPs, and internal deployment workflows. Gosec fixes for file permissions
and unhandled error.
2026-02-17 09:38:28 +10:00
Julian Sommer 2e9ede516a fix: lint errors 2026-02-17 09:37:09 +10:00
Ben Duncan 244cb69715 Merge branch 'main' of github.com:mulgadc/hive 2026-02-17 09:11:37 +10:00
Ben Duncan bb533dfbb6 multi-server tweaks, ec2 health, IAC templates, misc cleanup 2026-02-17 09:10:13 +10:00
Julian Sommer dae9d1bb42 fix: add t3a family + fix ci 2026-02-17 08:56:06 +10:00
Julian Sommer e4db948910 feat: O(1) snapshot volume checks (#38) 2026-02-17 09:23:02 +11:00
Ben Duncan 962d85edf2 Improved IAC automation testing 2026-02-14 19:27:13 +10:00
Ben Duncan 24924be941 Cluster heartbeat support, improved cluster start/stop, coordinated shutdown in multi-server mode 2026-02-14 16:59:32 +10:00
Ben Duncan c5e561abbf Improved cluster stats and INSTALL.md doc 2026-02-14 16:19:25 +10:00
Ben Duncan 774655eff0 hive top, hive get cmds for cluster health and stats. improved docs for iac, cluster, multi-server restart 2026-02-14 15:38:03 +10:00
Ben Duncan 5863aeed13 Phase 0/1 multi-server-restart enhancements 2026-02-14 13:23:24 +10:00
Ben Duncan febeae8c47 preflight fixes for gh errors 2026-02-14 09:23:42 +10:00
Ben Duncan 3095a90090 e2e tests, multi server improvements, doc updates for workflow 2026-02-14 08:50:24 +10:00
Ben Duncan d80dd0996a Fixed gosec and added go skills 2026-02-14 06:54:00 +10:00
Ben Duncan 25d98dda7e Consolidating documentation 2026-02-13 21:00:22 +10:00
Ben Duncan 6df221309d Merge branch 'main' of github.com:mulgadc/hive 2026-02-13 20:32:08 +10:00
Ben Duncan db0d46ca5b Improved multi-server node testing and tweaks 2026-02-13 20:31:45 +10:00
Ben Duncan cf12a3272e IAC for Proxmox / AWS terraform examples 2026-02-13 11:42:43 +10:00
Julian Sommer 8774fb1e95 feat: distributed start instances (#37) 2026-02-12 15:15:05 +11:00
Julian Sommer fb361dfe49 fix: update instance status BEFORE calling it 2026-02-12 11:11:59 +10:00
Julian Sommer 9d3747bf26 feat: ssh tests in e2e (#36) 2026-02-12 11:57:31 +11:00
Ben Duncan 5b4bad3cb3 Merge branch 'main' of github.com:mulgadc/hive 2026-02-12 10:50:35 +10:00
Ben Duncan 6c4caec652 instance state 2026-02-12 10:37:45 +10:00
Julian Sommer 22af6c4a46 feat: more e2e (#35) 2026-02-12 11:00:00 +11:00
Julian Sommer 815e9a8c36 fix: duplicate subscription 2026-02-12 09:03:24 +10:00
Julian Sommer c7d832a823 fix: nats topic 2026-02-12 08:30:23 +10:00
Julian Sommer 32e2f7bf78 fix: daemon reboot 2026-02-11 16:05:21 +10:00
Julian Sommer 202fffc8db chore: utils tests 2026-02-11 15:21:29 +10:00
Julian Sommer 72709dda12 linter fix 2026-02-11 15:15:30 +10:00
Julian Sommer 0ff03be0dd fix 2026-02-11 15:10:55 +10:00
Julian Sommer 998649cbab feat: createImage (#34) 2026-02-11 16:07:19 +11:00
Julian Sommer efe07a9ed4 chore: bump go version 2026-02-11 12:59:32 +10:00
Julian Sommer 27530ba270 chore: daemon tests 2026-02-11 12:48:21 +10:00
Julian Sommer 467eb0252d chore: more gateway tests 2026-02-11 12:24:00 +10:00
Julian Sommer 2d30743ba5 chore: gateway tests 2026-02-11 12:12:40 +10:00
Julian Sommer 10e65ab66a fix: update ci triggers 2026-02-11 11:44:36 +10:00
Julian Sommer c514d6b937 format 2026-02-11 11:37:52 +10:00
Julian Sommer df70750f09 chore: more tests 2026-02-11 11:37:17 +10:00
Julian Sommer 210ca92b2c fix: dead code 2026-02-11 11:09:00 +10:00
Julian Sommer 5ce3773c42 fix: cryptographically secure ids 2026-02-11 10:51:41 +10:00
Julian Sommer 39b9e0dcd1 fix: split daemon ec2 paths 2026-02-11 10:32:32 +10:00
Julian Sommer b1762f445c fix: centralise nats connection logic 2026-02-11 10:18:30 +10:00
Julian Sommer 221a1ef258 fix: use typed aws errors 2026-02-11 10:11:37 +10:00
Julian Sommer e95c2536da feat: EIGW (#33) 2026-02-11 11:00:53 +11:00
Julian Sommer 2937c8f425 feat: account functionality (#27) 2026-02-11 10:58:52 +11:00
Julian Sommer 1b424928f4 fix: move matrix out of TODO.md 2026-02-11 09:49:22 +10:00
Julian Sommer 7a0d148e71 fix: update Makefile 2026-02-11 09:44:36 +10:00
Julian Sommer 6b4728e702 fix: update CLAUDE.md 2026-02-11 09:43:55 +10:00
Julian Sommer 63e476323d fix: url decode keys 2026-02-11 08:27:02 +10:00
Julian Sommer 6abae11122 feat: real snapshots (#32) 2026-02-10 16:57:50 +11:00
Julian Sommer 8d3808c364 fix: parallelize ci 2026-02-10 10:47:07 +10:00
Julian Sommer 51ffc4c3ab fix: add missing error handling 2026-02-10 10:38:36 +10:00
Julian Sommer b443bbd3e2 fix: permissions 2026-02-10 10:10:39 +10:00
Julian Sommer e545e81e59 fix: run modernize again 2026-02-10 10:02:06 +10:00
Julian Sommer 2491c6f2c2 feat: snapshot e2e (#31) 2026-02-10 10:54:48 +11:00
Julian Sommer 59c57cc074 chore: run modernize 2026-02-10 09:23:25 +10:00
Julian Sommer c5bfedcd98 chore: fmt 2026-02-10 09:10:42 +10:00
Julian Sommer 05a608855c fix: add compile time interface checks 2026-02-10 08:55:23 +10:00
Julian Sommer 513fd07ced feat: snapshots (#30) 2026-02-10 09:45:53 +11:00
Julian Sommer 2977b03cef feat: tags e2e (#29) 2026-02-09 16:28:58 +11:00
Julian Sommer 560e5cb58c feat: tags (#28) 2026-02-09 15:49:59 +11:00
Julian Sommer 09f78fe29b update TODO.md 2026-02-09 11:53:51 +10:00
Julian Sommer f0323bc081 fix: delete dead code (unused handlers) 2026-02-09 11:52:12 +10:00
Julian Sommer dcb08c0bd1 fix: e2e predastore multi node 2026-02-09 11:17:00 +10:00
Julian Sommer 9ba912b745 fix: duplicated unmarshal 2026-02-09 11:14:38 +10:00
Julian Sommer a554b9b884 fix: state change 2026-02-09 10:51:21 +10:00
Julian Sommer e15c077d3a fix: simplify nats handlers 2026-02-09 10:42:47 +10:00
Julian Sommer 88f07a9f3a simplify gateway more 2026-02-09 10:34:12 +10:00
Julian Sommer 45a466f71d fix: viperblock init 2026-02-09 10:03:06 +10:00
Julian Sommer e5038d4a35 fix: deduplicate nilchecks 2026-02-09 09:56:11 +10:00
Julian Sommer 25dd435bc6 fix: use object store instead of s3 client 2026-02-09 09:52:12 +10:00
Julian Sommer daf5f0c780 fix: refactor simple daemon handlers 2026-02-09 09:43:57 +10:00
Julian Sommer e0fea35b19 fix: refactor gateway 2026-02-09 09:35:07 +10:00
Julian Sommer e31e523380 feat: describe-account-attributes (#26) 2026-02-09 10:19:33 +11:00
Julian Sommer 0efa570cc5 fix: update to go 1.25.7 2026-02-09 08:52:32 +10:00
Julian Sommer ef87b41858 feat: describe-availbility-zones (#25) 2026-02-09 09:47:20 +11:00
Julian Sommer 1c2174a10f fix: refactor handlers to use ObjectStore interface (#24) 2026-02-04 16:32:11 +11:00
Julian Sommer 801f0f8135 cleanup dead code 2026-02-04 14:02:48 +10:00
Julian Sommer d0dfea56e8 cleanup comments 2026-02-04 13:54:43 +10:00
Julian Sommer 2566d9d528 make gateway consistent 2026-02-04 13:35:03 +10:00
Julian Sommer a3d7e20e31 feat: describe-volume-status (#23) 2026-02-04 12:46:06 +11:00
Julian Sommer ba990ffd97 feat: attach and detach volume e2e (#22) 2026-02-04 11:39:24 +11:00
Julian Sommer d81e417410 feat: detach-volumes (#21) 2026-02-04 10:54:02 +11:00
Julian Sommer 45ac7c4357 update docs 2026-02-04 08:48:10 +10:00
Julian Sommer fca82939f5 feat: attach-volume (#20) 2026-02-04 09:44:54 +11:00
Julian Sommer 65ad19e604 bump viperblock 2026-02-03 14:00:10 +10:00
Julian Sommer d14158a619 feat: delete on termination 2026-02-03 13:55:19 +10:00
Julian Sommer 3ab1a839db fix: fallback 2026-02-03 12:22:38 +10:00
Julian Sommer 10ded4608b fix: frontend error handling 2026-02-03 12:19:39 +10:00
Julian Sommer 2de2899724 fix: accessibility 2026-02-03 12:05:23 +10:00
Julian Sommer 130e4b9662 feat: create-bucket frontend 2026-02-03 11:54:14 +10:00
Julian Sommer 941aaa6504 move claude rules and skills to root 2026-02-03 11:31:11 +10:00
Julian Sommer 917b66beb9 feat: predastore multinode (#19) 2026-02-03 12:07:06 +11:00
Julian Sommer fdb5cca77e feat: delete-volumes (#18) 2026-02-03 10:47:00 +11:00
Julian Sommer b772b2e0bf feat: use state machine for daemon (#16) 2026-02-02 16:14:36 +11:00
Julian Sommer 875328cfef feat: create-volume (#17) 2026-02-02 15:47:46 +11:00
Julian Sommer 6baf49885b e2e: add modify-volumes (#15) 2026-02-02 14:22:55 +11:00
Julian Sommer 6067f0ccd4 tweak 2026-02-02 11:30:05 +10:00
Julian Sommer e4a2e2972a fix: split daemon handlers 2026-02-02 11:14:35 +10:00
Julian Sommer 731ba4fbe8 feat: refactor Start() daemon 2026-02-02 10:51:16 +10:00
Julian Sommer 7e7d358135 update viperblock version 2026-02-02 10:39:24 +10:00
Julian Sommer ad763eb41d feat: add attach time to volume 2026-02-02 10:34:10 +10:00
Julian Sommer 1a8e273ac6 fix: use slog instead of log 2026-02-02 10:18:04 +10:00
Julian Sommer 91384856ec fix: delete on termination false 2026-02-02 10:12:09 +10:00
Julian Sommer 0e2b715d60 fix: form error handling 2026-02-02 10:08:55 +10:00
Julian Sommer f45a106dda feat: resize volume frontend 2026-02-02 09:36:49 +10:00
Julian Sommer 7aa688e564 fix: table based NATS subscriptions 2026-02-02 08:59:30 +10:00
Julian Sommer 50a0a2e1f1 fix: dead code 2026-02-02 08:35:19 +10:00
Julian Sommer 7f39da8718 fix: state on launch 2026-02-02 08:14:40 +10:00
Julian Sommer d1d6e6d004 todo.md 2026-01-30 15:07:53 +10:00
Julian Sommer 6d24ec2111 feat: modify-volume (resize) (#14) 2026-01-30 16:06:45 +11:00
Julian Sommer 230335adca add rules for claude 2026-01-30 10:59:07 +10:00
Julian Sommer 0b261743d2 fix: use aws-sdk for uploads 2026-01-30 09:38:11 +10:00
Julian Sommer f04d553a02 fix: multipart upload frontend 2026-01-30 08:59:15 +10:00
Julian Sommer dfd36370b0 tweak 2026-01-30 08:18:57 +10:00
benduncan 2fae29d486 Go version bump 2026-01-29 20:23:58 +10:00
benduncan 98701e5208 Install.md tweaks 2026-01-29 20:18:33 +10:00
benduncan 3232d45ff8 Merge branch 'main' of github.com:mulgadc/hive 2026-01-29 20:13:00 +10:00
benduncan d6bfef02ac Latest todo 2026-01-29 20:12:55 +10:00
Julian Sommer 5b55e1d1e0 chore: cut CLAUDE.md down more 2026-01-29 15:55:25 +10:00
Julian Sommer 2aaed28404 chore: trim CLAUDE.md 2026-01-29 15:52:15 +10:00
Julian Sommer e92d67caf0 fix: add default values to run-instances 2026-01-29 15:18:06 +10:00
Julian Sommer 0b1c44d908 fix: volume naming 2026-01-29 15:03:43 +10:00
Julian Sommer 5e53ab1f7e fix: lint issue 2026-01-29 14:14:20 +10:00
Julian Sommer 3bca0e69df feat: block device mappings 2026-01-29 14:10:04 +10:00
Julian Sommer 20de0a7989 feat: count support for run-instances 2026-01-29 12:19:18 +10:00
Julian Sommer 118fb35a1e add hive-ui to start-dev 2026-01-29 11:00:08 +10:00
Julian Sommer dc77e2547b fix: INSTALL.md 2026-01-29 10:46:55 +10:00
Julian Sommer 936828ed53 fix: upgrade to go 1.25.6 to fix vuln 2026-01-29 09:26:27 +10:00
Julian Sommer 454e8dcace revert daemon.go (#13) 2026-01-29 10:22:56 +11:00
Julian Sommer 785d2d6c7b feat: commit frontend build so node isnt a dep for building hive 2026-01-29 09:15:09 +10:00
Julian 5cd5189b56 chore: add logs 2026-01-28 15:49:50 +10:00
Julian a4e7b71d6c fix: e2e verify ssl 2026-01-28 15:36:11 +10:00
Julian adfc54a7f0 fix: check nats sub 2026-01-28 15:26:11 +10:00
Julian 82fa85f992 fix: build first 2026-01-28 15:07:56 +10:00
Julian Sommer de0e9a915e fix: backoffs for jetstream (#12) 2026-01-28 16:00:54 +11:00
Julian 1d816721e8 feat: move hive-ui into main hive binary 2026-01-28 15:00:30 +10:00
Julian 84c0b3e715 fix: remove ssh tests 2026-01-28 14:36:10 +10:00
Julian a423de3380 fix: actual fix 2026-01-28 13:45:13 +10:00
Julian 21a8e2b004 fix: jetstream 2026-01-28 13:36:47 +10:00
Julian 04197d1adc fix: instance-state 2026-01-28 13:19:47 +10:00
Julian bd0c12d45e fix: add t3 and t4g back 2026-01-28 13:15:59 +10:00
Julian a3a32d37c3 fix: SigV4AuthMiddleware 2026-01-28 13:00:02 +10:00
Julian 07056f42a9 fix: pathnames in hive-ui 2026-01-28 11:06:08 +10:00
Julian 528cbc0acd fix: buckets on hive-ui 2026-01-28 10:36:54 +10:00
Julian Sommer 8f0e8d7ead feat: multi-node certs (#11) 2026-01-28 11:36:09 +11:00
Ben Duncan 772da5a181 Updated todo for volumes 2026-01-28 10:26:34 +10:00
Ben Duncan 3912a2a84d Merge branch 'main' of github.com:mulgadc/hive 2026-01-28 10:20:20 +10:00
Ben Duncan bf99270095 EC2 volume todo 2026-01-28 10:20:02 +10:00
Julian 11c1d74a79 embed frontend into binary 2026-01-28 10:15:34 +10:00
Julian 7dca292e67 fix: web-ui to use hive certs 2026-01-28 09:55:01 +10:00
Ben Duncan 1bfb07c643 Fresh install tweaks 2026-01-27 21:54:10 +10:00
Ben Duncan f57bc9f2e6 Added cert authority feature for init 2026-01-27 17:08:46 +10:00
Ben Duncan 2252764da7 Latest changes for fresh install 2026-01-27 16:16:03 +10:00
Ben Duncan 36847bbf54 Merge branch 'main' of github.com:mulgadc/hive 2026-01-27 16:11:02 +10:00
Ben Duncan 7f8bbf9b31 build tweak for quickinstall 2026-01-27 16:10:52 +10:00
Julian Sommer 52b8550788 feat: hive-ui (#10) 2026-01-27 17:05:08 +11:00
Ben Duncan fc52284ec5 Installer and Makefile tweaks for fresh install 2026-01-27 16:04:53 +10:00
Ben Duncan b7bcdcaf1c Bumped viperblock version 2026-01-27 15:29:48 +10:00
Ben Duncan bd73a602af Multi-node predastore default config 2026-01-27 15:06:30 +10:00
Ben Duncan 2156e5c591 Merging latest 2026-01-27 15:04:58 +10:00
Ben Duncan ed75a63e15 Viperblock update 2026-01-27 14:29:12 +10:00
Julian 6067450684 go mod tidy 2026-01-27 14:18:37 +10:00
Julian 67b9ffa423 predastore 1.0 2026-01-27 14:13:51 +10:00
Julian Sommer 5878669eb0 fix for predastore multi node (#9) 2026-01-27 13:43:01 +11:00
Julian Sommer 7b7be2c26b feat: multi-node e2e test v2 (#8) 2026-01-27 10:59:58 +11:00
Ben Duncan 6c6909ad1c multi-node predastore config 2026-01-27 08:19:29 +10:00
Ben Duncan 83fd425a60 Latest changes 2026-01-27 08:17:06 +10:00
Ben Duncan 8280cb7102 merge latest 2026-01-25 07:08:17 +10:00
Ben Duncan 7f5b53c5df v1 support for distributed backend 2026-01-25 07:06:53 +10:00
Julian 0138b7c970 move image download to before deps layer 2026-01-23 11:26:33 +10:00
Julian 6a87b62e06 feat: cache Ubuntu image in e2e Docker build 2026-01-23 11:23:07 +10:00
Julian b1ec9b2122 upgrade viperblock 2026-01-23 09:22:12 +10:00
Julian 0a4e0858f1 remove replace directive 2026-01-23 09:04:48 +10:00
Julian Sommer 31158932b8 fix: run-instances in e2e 2026-01-23 09:49:28 +11:00
Julian 31f180c144 fix: stop-instances edge case 2026-01-23 08:23:01 +10:00
Ben Duncan d64e03c2f9 Merge branch 'main' of github.com:mulgadc/hive 2026-01-22 19:53:00 +10:00
Julian Sommer 6677b741e1 fix: stop-instances 2026-01-22 04:46:25 +00:00
Julian Sommer bfb909b365 feat: jetstream kv for instance state 2026-01-22 04:08:46 +00:00
Julian Sommer ca3427d64b feat: E2E tests in GitHub Actions 2026-01-22 12:37:45 +11:00
Julian Sommer d29069db83 fix: match new viperblock err type 2026-01-21 04:02:20 +00:00
Julian Sommer 72b706a00d remove paths in action 2026-01-21 01:48:31 +00:00
Julian Sommer 217fcf221a chore: use go tool for linters 2026-01-21 01:34:04 +00:00
Julian Sommer e8e9b72cd3 feat: e2e test 2026-01-20 04:40:38 +00:00
Julian Sommer 896b70e508 feat: describe-volumes 2026-01-20 13:07:16 +11:00
Ben Duncan 302c893340 Merge branch 'main' of github.com:mulgadc/hive 2026-01-19 14:09:32 +10:00
Ben Duncan e3fae3d96f Latest predastore changes for multi-node 2026-01-19 14:08:18 +10:00
Julian Sommer 43fdb3f373 pin viperblock to v0.1.3 2026-01-16 05:44:06 +00:00
Julian Sommer 0c00a6ce99 add go.mod and go.sum to action 2026-01-16 05:34:51 +00:00
Julian Sommer 049ae6f66b pin viperblock to specific commit until we release 2026-01-16 05:32:34 +00:00
Julian Sommer f056dff083 fix 2026-01-16 05:25:04 +00:00
Julian Sommer 3bc8ee7538 fix type for new viperblock 2026-01-16 05:19:50 +00:00
Julian Sommer 824faa6202 safe int conversions 2026-01-16 04:00:06 +00:00
Julian Sommer 4a3f158977 run linters in ci 2026-01-16 03:45:28 +00:00
Julian Sommer a074cafc51 (feat) add expectedNodes to avoid nats timeout 2026-01-14 05:18:08 +00:00
Julian Sommer aeaaba2bca enable greentea gc 2026-01-14 04:33:47 +00:00
Julian Sommer 35db549d7e (feat) describe-regions 2026-01-14 03:48:43 +00:00
Julian Sommer 0c164a4807 (fix) Daemon resource allocation 2026-01-14 13:06:09 +11:00
Julian Sommer 4ae360acf0 Feat/describe instance types 2026-01-13 15:18:31 +11:00
Julian Sommer c37f7ea279 add cors 2026-01-12 02:05:30 +00:00
benduncan 92fd276d34 TODO updated for next ec2 implementation 2026-01-09 15:02:21 +10:00
Julian Sommer d5aa6908a7 unique key id 2026-01-09 02:53:20 +00:00
Julian Sommer e6476ba3b0 minify xml 2026-01-08 03:32:36 +00:00
Julian Sommer 907db178de fix typo 2026-01-08 09:58:29 +11:00
Julian Sommer b9b8c65e79 (fix) Linter fixes
* Fix go vet mutex copy issues and remove report files from git

* remove redundant nil checks (len of nil slice is 0)

* handle error

* add missing error handling

* run modernize to change interface{} to any

* run other modernise commands

* use crypto instead of rand.Intn
2026-01-06 14:20:56 +11:00
Ben Duncan 66ad247c34 Improved formatting 2026-01-06 12:07:07 +10:00
Ben Duncan c42a9d8321 Merge branch 'main' of github.com:mulgadc/hive 2026-01-06 11:57:54 +10:00
Ben Duncan 1c88eaf253 Updated latest todo 2026-01-06 11:57:05 +10:00
Julian Sommer 11983b8095 hide debug log 2026-01-06 00:47:36 +00:00
Julian Sommer 4b593029aa fix ubuntu version 2026-01-06 11:22:56 +11:00
Ben Duncan f5d954d86b Updated design doc 2026-01-06 10:00:07 +10:00
benduncan 52d17d61b7 go build tweaks 2025-12-15 16:08:58 +10:00
benduncan a5934ae2c6 go build tweaks 2025-12-15 15:54:08 +10:00
benduncan a23677604a go build tweaks 2025-12-15 15:49:07 +10:00
benduncan 3645523e59 Merge branch 'main' of github.com:mulgadc/hive 2025-12-15 15:40:56 +10:00
benduncan 36cc7b2515 Installer tweaks 2025-12-15 15:39:25 +10:00
Julian Sommer 49351d2fc2 upgrade go version to fix cert vuln 2025-12-09 11:27:20 +11:00
Julian Sommer 0b90abda64 add repo checkout false to govulncheck 2025-12-09 11:22:41 +11:00
benduncan 9f20664ece Makefile tweaks for security tests 2025-11-27 19:59:15 +11:00
benduncan 35769524e3 [SEC] Updated vul import libs. Adding automation for security checks on commit 2025-11-27 17:09:10 +11:00
benduncan 58d75102ca Added EFI for ARM, RunInstances tweak for provisining disks after instance response 2025-11-25 19:49:22 +11:00
benduncan 243463be2b Set correct machine type for ARM 2025-11-25 16:50:27 +11:00
benduncan 803a3d3b35 Fixed pts 0 issue, tweaked install docs, and unit tests on MacOS 2025-11-25 14:49:52 +11:00
Ben Duncan a2552c57f3 Merge pull request #1 from mulgadc/install-tweaks
docs: hive install tweaks
2025-11-25 13:36:41 +11:00
Julian Sommer 36e467fb8d feat: add workflow_dispatch to action 2025-11-25 11:21:38 +11:00
Julian Sommer d2f72a7a1d docs: hive install tweaks 2025-11-25 11:12:59 +11:00
benduncan fb4da120ea Installer tweaks 2025-11-25 09:10:52 +11:00
Ben Duncan f2a04dd4c2 Added multi-node support and updated INSTALLER. Fix vm.go bug on qemu exec with args error 2025-11-21 23:06:14 +11:00
Ben Duncan a64874e477 Tweaked install instructions 2025-11-18 11:28:04 +08:00
Ben Duncan 9fec764945 Trigger github action for fresh build 2025-11-18 10:12:08 +08:00
Ben Duncan b6faa2c8bf Multi-node details 2025-11-18 10:10:09 +08:00
Ben Duncan b79eaeeed2 Move go.mod replace to go.work 2025-11-18 10:06:54 +08:00
Ben Duncan 6c4953c72e Fixed UT mistakes 2025-11-18 10:02:09 +08:00
Ben Duncan ff22a08948 Added support for multi-node config settings 2025-11-16 21:20:08 +08:00
Ben Duncan bacd1f3ba2 Tweaked qemu launch for non KVM hosts for development 2025-11-15 11:15:03 +00:00
Ben Duncan 42b3ba7fa1 ImportKeyPair bug fix on URL encode base64 encoding 2025-11-15 17:58:27 +08:00
Ben Duncan f8d469220d Fresh install tweaks 2025-11-14 21:42:09 +08:00
892 changed files with 153168 additions and 17402 deletions
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
branch=$(git rev-parse --abbrev-ref HEAD)
# Only enforce preflight on dev and main
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
exit 0
fi
# Only run preflight if Go files were staged
if git diff --cached --name-only | grep -q '\.go$'; then
echo "Go files staged on $branch — running make preflight..."
make preflight
else
echo "No Go files staged, skipping preflight."
fi
+52
View File
@@ -0,0 +1,52 @@
version: 2
updates:
- package-ecosystem: gomod
target-branch: main
directory: /
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
groups:
go-dependencies:
patterns:
- "*"
- package-ecosystem: github-actions
target-branch: main
directory: /
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
ignore:
- dependency-name: "*"
update-types:
- "version-update:semver-minor"
- "version-update:semver-patch"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: npm
target-branch: main
directory: /spinifex/services/spinifexui/frontend
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
ignore:
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
groups:
npm-dependencies:
patterns:
- "*"
+250
View File
@@ -0,0 +1,250 @@
name: Spinifex E2E Tests
on:
push:
branches:
- main
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- tests/e2e/**
- .github/workflows/e2e.yml
workflow_dispatch:
inputs:
sha:
description: "PR head SHA to report status on"
required: false
permissions:
statuses: write
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
e2e_single:
name: Single-Node E2E
runs-on: [self-hosted, ci-single]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
SPX_EXTERNAL_POOL="192.168.1.230-192.168.1.240" \
./bootstrap.sh env3 >> "$TOFU_LOG" 2>&1
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh && ./tests/e2e/run-cert-e2e.sh'
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
e2e_pseudo_multi:
name: Pseudo Multi-Node E2E
runs-on: [self-hosted, ci-pseudo]
timeout-minutes: 30
env:
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env4 >> "$TOFU_LOG" 2>&1
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL="192.168.1.220-192.168.1.229" ./tests/e2e/run-pseudo-multinode-e2e.sh'
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
e2e_real_multi:
name: Real Multi-Node E2E
runs-on: [self-hosted, ci-multi]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
SPX_EXTERNAL_POOL="192.168.1.240-192.168.1.250" \
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
# Build node IP list
NODE_IPS=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
done
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS} && ./tests/e2e/run-cert-e2e.sh --multinode ${NODE_IPS}"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
e2e_status:
name: Report E2E Status
if: always() && inputs.sha
needs: [e2e_single, e2e_pseudo_multi, e2e_real_multi]
runs-on: [self-hosted, ci-single]
timeout-minutes: 5
steps:
- name: Report status to PR
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${{ needs.e2e_single.result }}" == "success" && \
"${{ needs.e2e_pseudo_multi.result }}" == "success" && \
"${{ needs.e2e_real_multi.result }}" == "success" ]]; then
STATE="success"
DESC="All E2E suites passed"
else
STATE="failure"
DESC="E2E failed: single=${{ needs.e2e_single.result }} pseudo=${{ needs.e2e_pseudo_multi.result }} multi=${{ needs.e2e_real_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${{ inputs.sha }}" \
-f state="$STATE" \
-f context=e2e \
-f description="$DESC" \
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-23
View File
@@ -1,23 +0,0 @@
name: Build and Test Hive
on:
push:
branches: ["main"]
paths: ["hive/**", ".github/workflows/**"]
jobs:
hive_test_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
cache-dependency-path: "go.sum"
- name: Run Tests
run: make test
+204
View File
@@ -0,0 +1,204 @@
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
distro-amd64:
runs-on: ubuntu-24.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build amd64 Tarball
env:
VERSION: ${{ github.ref_name }}
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
- name: Upload amd64 Artifacts
uses: actions/upload-artifact@v7
with:
name: distro-amd64
path: |
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz.sha256
spinifex/scripts/setup.sh
distro-arm64:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build arm64 Tarball
env:
VERSION: ${{ github.ref_name }}
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
- name: Upload arm64 Artifacts
uses: actions/upload-artifact@v7
with:
name: distro-arm64
path: |
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
release:
needs: [distro-amd64, distro-arm64]
runs-on: ubuntu-24.04
steps:
- name: Download amd64 Artifacts
uses: actions/download-artifact@v8
with:
name: distro-amd64
path: artifacts
- name: Download arm64 Artifacts
uses: actions/download-artifact@v8
with:
name: distro-arm64
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v2
with:
draft: true
files: |
artifacts/**/*.tar.gz
artifacts/**/*.sha256
artifacts/**/setup.sh
generate_release_notes: true
- name: Upload Tarball for Verify Job
uses: actions/upload-artifact@v7
with:
name: release-assets
path: artifacts/
verify:
needs: release
runs-on: [self-hosted, ci-single]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Download Release Assets
uses: actions/download-artifact@v8
with:
name: release-assets
path: release-assets
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
- name: Run Smoke Test
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" \
smoke-test-install.sh \
"tf-user@${WAN_IP}:/tmp/smoke-test.sh"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" "tf-user@${WAN_IP}" \
'bash /tmp/smoke-test.sh'
- name: Promote Release
if: success()
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "$TAG" --repo "$REPO" --draft=false
echo "Release ${TAG} promoted to latest"
- name: Mark Release Failed
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: |
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
NOTES_FILE=$(mktemp)
printf 'Verification failed — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
gh release edit "$TAG" --repo "$REPO" --notes-file "$NOTES_FILE"
rm -f "$NOTES_FILE"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
+132
View File
@@ -0,0 +1,132 @@
name: Spinifex Build and Test
on:
push:
branches:
- main
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- .github/workflows/spinifex.yml
pull_request:
branches:
- main
workflow_dispatch:
jobs:
build_and_test:
name: Spinifex Build and Test
runs-on: ubuntu-24.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
fetch-depth: 2
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Build Spinifex
run: make -C spinifex build
- name: Run Tests with Coverage
run: make -C spinifex test-cover
- name: Check Diff Coverage
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
race_detection:
name: Spinifex Race Detection
runs-on: ubuntu-24.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Run Tests with Race Detector
run: make -C spinifex test-race
lint_and_security:
name: Spinifex Lint and Security
runs-on: ubuntu-24.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.3
working-directory: spinifex
- name: Govulncheck
run: make -C spinifex govulncheck
+54
View File
@@ -0,0 +1,54 @@
name: UI Build and Test
on:
push:
branches:
- main
paths:
- spinifex/services/spinifexui/frontend/**
- .github/workflows/ui.yml
pull_request:
branches:
- main
paths:
- spinifex/services/spinifexui/frontend/**
- .github/workflows/ui.yml
workflow_dispatch:
jobs:
ui_build_and_test:
name: UI Build and Test
runs-on: ubuntu-24.04
defaults:
run:
working-directory: spinifex/services/spinifexui/frontend
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
sparse-checkout: spinifex/services/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
package_json_file: spinifex/services/spinifexui/frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: spinifex/services/spinifexui/frontend/pnpm-lock.yaml
node-version-file: spinifex/services/spinifexui/frontend/.nvmrc
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Check
run: pnpm check
- name: Run Tests with Coverage
run: pnpm test:coverage
- name: Build Frontend
run: pnpm build
+38 -2
View File
@@ -1,7 +1,43 @@
.vscode
# Terraform/OpenTofu
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfvars
!example.tfvars
scripts/iac/proxmox/clusters/
# Go workspace
go.work
go.work.sum
bin/hive
# Build outputs
bin/
dist/
!spinifex/services/spinifexui/frontend/dist/
tmp/
*.test
*.out
# Config
config/dev.yaml
# OS files
.DS_Store
Thumbs.db
# Environment
.env
# Test reports
*-report.txt
# Internal docs (moved to private mulga repo)
docs/INTERNAL.md
docs/PRIVATE.md
docs/DEVNOTES.md
docs/PLAN.md
docs/TODO.md
docs/development/
docs/internal/
scripts/iac/aws/spinifex-test.pem
+128
View File
@@ -0,0 +1,128 @@
version: "2"
formatters:
enable:
- gofmt
settings:
gofmt:
simplify: false # modernize linter covers gofmt -s simplifications (Go 1.23+)
linters:
enable:
- asasalint # catches pass []any as any in variadic func(...any)
- asciicheck # checks that code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode bidi character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header keys
- decorder # checks declaration order of types, consts, vars, and funcs
- durationcheck # checks for two durations multiplied together
- embeddedstructfieldcheck # checks embedded sync.Mutex/RWMutex in structs
- errcheck # checks for unchecked errors in type assertions and I/O
- errchkjson # checks types passed to json encoding functions, flags omittable error checks
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
- govet # reports suspicious constructs (Printf alignment, shadow, etc.)
- ineffassign # detects assignments to existing variables that are not used
- makezero # finds slice declarations with non-zero initial length followed by append
- mirror # catches wrong mirror patterns in bytes/strings usage
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
- nakedret # finds naked returns in functions
- nilerr # catches code that returns nil even after checking err != nil
- nilnesserr # catches returning a different nil error variable after err != nil check
- nolintlint # reports ill-formed or insufficient nolint directives
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency (pointer vs value)
- sloglint # enforces consistent log/slog usage (key-value pairs, no globals)
- staticcheck # extensive static analysis checks
- unconvert # removes unnecessary type conversions
- usestdlibvars # detects hardcoded values that should use stdlib constants
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
- wastedassign # finds assignments to variables that are never subsequently read
- whitespace # detects leading and trailing whitespace in functions
settings:
embeddedstructfieldcheck:
forbid-mutex: true
errcheck:
check-type-assertions: true
exclude-functions:
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
- (*github.com/kdomanski/iso9660.ImageWriter).Cleanup # deferred cleanup, errors not actionable
govet:
enable-all: true
disable:
- fieldalignment
- shadow # too noisy to retrofit — all 50 findings are err re-declarations
- unusedwrite # false positives on struct field assignments before marshal/send
staticcheck:
checks:
- all
- -ST1000 # package comment
- -ST1003 # naming conventions (Id→ID etc.) — too noisy to retrofit
- -ST1012 # error var naming — errname covers this + error type suffixes
- -ST1013 # HTTP status constants — usestdlibvars is broader
- -ST1016 # receiver name consistency (too noisy to retrofit)
- -ST1020 # godoc on exported types — not enforcing yet
- -ST1021 # godoc on exported type groups — not enforcing yet
- -ST1022 # godoc on exported vars/consts — not enforcing yet
- -SA1019 # deprecated usage (handle separately)
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
- -SA9005 # ineffective break (staticcheck false positives)
gosec:
excludes:
- G104 # unchecked errors — errcheck is stricter
- G116 # bidi chars — bidichk is more thorough
- G117 # use of net/http serve functions (we handle TLS separately)
- G703 # path traversal taint analysis — false positives on config file loading
- G705 # XSS taint analysis — false positives on internal error responses
- G706 # log injection taint analysis — false positives on structured slog usage
modernize:
disable:
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
nakedret:
max-func-lines: 0
nolintlint:
require-explanation: true
require-specific: true
reassign:
patterns:
- ".*"
exclusions:
presets:
- std-error-handling
- common-false-positives
rules:
- path: '_test\.go'
linters:
- bodyclose
- errcheck
- errchkjson
- gosec
# spinifex-specific: gosec exclusions
- path: "cmd/"
linters:
- errcheck # CLI glue code — viper/cobra/pterm errors not actionable
- gosec # matches current -exclude-dir=cmd
- text: "G204"
linters: [gosec] # subprocess execution — legitimate in VM orchestration
- text: "G304"
linters: [gosec] # file path from variable — necessary for config loading
- text: "G402"
linters: [gosec] # TLS config — we handle TLS ourselves
-214
View File
@@ -1,214 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the Hive platform - an AWS-compatible infrastructure stack for bare-metal, edge, and on-premise deployments. Hive orchestrates multiple independent components that work together to provide AWS-compatible services.
## Component Repositories
The Hive platform consists of these independent repositories:
- **[viperblock](../viperblock/)** - High-performance block storage service (EBS alternative) with WAL-backed storage
- **[predastore](../predastore/)** - S3-compatible object storage service with Reed-Solomon erasure coding
- **nbdkit/** - Network Block Device (NBD) server for exporting block devices (upstream project)
- **hive/** (this repo) - VM orchestration service and platform coordinator
Each component can be used independently or as part of the integrated Hive stack. See component-specific `CLAUDE.md` files for detailed development guidance:
- `../viperblock/CLAUDE.md` - Viperblock development guide
- `../predastore/CLAUDE.md` - Predastore development guide
## Cross-Repository Development Setup
For platform development, clone all repositories to the same parent directory:
```bash
mkdir mulga
cd mulga
git clone <hive-repo-url> hive
git clone <viperblock-repo-url> viperblock
git clone <predastore-repo-url> predastore
```
The `hive/go.mod` uses local replace directives for cross-component development:
```go
replace github.com/mulgadc/viperblock => ../viperblock
replace github.com/mulgadc/predastore => ../predastore
```
## Build Commands
### Hive (Platform Orchestrator)
```bash
make build # Build the hive binary
make test # Run Go tests
make bench # Run benchmarks
make clean # Clean build artifacts
```
### **MANDATORY: Unit Testing Requirements**
**⚠️ CRITICAL: Always run unit tests before any commit or push operation.**
```bash
# REQUIRED before any git commit or push
make test # Must pass with zero failures
# Example workflow:
make test # Verify all tests pass
git add . # Stage changes
git commit -m "..." # Only after tests pass
git push origin main # Only after tests pass
```
**Testing Policy:**
- **All unit tests MUST pass** before committing changes
- **No exceptions** - failing tests block commits
- Tests must complete without errors or panics
- Use `make test` command to run the full test suite
- If tests fail, fix issues before proceeding with git operations
### Component Build Commands
For detailed build instructions, see component-specific CLAUDE.md files:
**Viperblock** (`../viperblock/CLAUDE.md`):
```bash
cd ../viperblock && make build # Build sfs, vblock binaries + NBD plugin
```
**Predastore** (`../predastore/CLAUDE.md`):
```bash
cd ../predastore && make build # Build s3d binary
```
**NBDkit** (system dependency):
```bash
cd nbdkit && autoreconf -i && ./configure && make && make install
```
## Architecture Notes
### Message-Driven Microservices Architecture
Hive uses a **scalable message-driven architecture** that separates API handling from request processing:
```
AWS SDK (custom endpoint) → Hive API Gateway → NATS Topics → Specialized Daemons
↑ ↓
AWS-compatible response ← NATS Topics ← Processing Results
```
#### Core Components:
1. **Hive API Gateway Service** - TLS endpoint that handles AWS SDK requests
2. **Specialized Daemon Services** - Horizontally scalable workers for each AWS service type
3. **NATS Message Broker** - Topic-based routing with queue groups for load balancing
#### Current Implementation (daemon.go):
- **Gateway Logic**: `StartAWSGateway()` handles AWS API calls on port 9999
- **NATS Integration**: Uses request-response pattern: `d.natsConn.Request("ec2.runinstances", data, 30s)`
- **Queue Groups**: Load balancing via `"hive-workers"` queue group
- **Service Coordination**: All inter-service communication via NATS topics
### Inter-component Dependencies
- **Service Startup Order**: NATS → Predastore → Viperblock → NBDkit → Gateway → Daemons
- **Message Flow**: Gateway translates AWS calls to NATS messages, daemons process and respond
- **Scaling**: Multiple daemon instances can subscribe to same topics for horizontal scaling
- All Go projects use local module replacements for development
### Storage Backend Integration
- **Viperblock Integration**: EBS volumes mounted via NATS messaging (`ebs.mount` topic)
- **Predastore Integration**: S3 operations proxied through dedicated S3 daemons
- **NBDkit Integration**: Block device access for VM storage
- **Volume Lifecycle**: Coordinated through NATS between EC2 and EBS daemons
### Key Design Patterns
- **Message-Driven**: All service communication via NATS topics (ec2.*, ebs.*, s3.*, vpc.*)
- **Queue Groups**: Load balancing and fault tolerance via NATS queue groups
- **Request-Response**: 30-second timeout pattern for AWS API compatibility
- **Service Specialization**: Dedicated daemon types for each AWS service
- **Horizontal Scaling**: Multiple daemon instances handle same topic types
### NATS Topic Structure
The system uses structured NATS topics for service communication:
```bash
# EC2 Service Topics:
ec2.runinstances # Launch new instances
ec2.describeinstances # Query instance status
ec2.startinstances # Start stopped instances
ec2.stopinstances # Stop running instances
ec2.terminateinstances # Terminate instances
# EBS Service Topics:
ebs.createvolume # Create new EBS volume
ebs.attachvolume # Attach volume to instance
ebs.detachvolume # Detach volume
ebs.describevolumes # List volumes
ebs.mount # Mount volume (internal)
ebs.unmount # Unmount volume (internal)
# S3 Service Topics:
s3.createbucket # Create S3 bucket
s3.putobject # Store object
s3.getobject # Retrieve object
s3.listbuckets # List buckets
# VPC Service Topics:
vpc.createvpc # Create VPC
vpc.createsubnet # Create subnet
vpc.describevpcs # List VPCs
# Health and Discovery:
health.ec2.daemon.{id} # Daemon health heartbeats
discovery.services # Service registration
```
### Go Module Structure
All Go projects use:
- Go 1.23+ with module mode
- Local replace directives for cross-component development
- Standard build flags: `-ldflags "-s -w"` for optimized binaries
- Test environment variable: `LOG_IGNORE=1` to suppress logs during testing
### Development Workflow
#### Quick Setup (Recommended)
```bash
# 1. Clone dependencies (if not already done)
./scripts/clone-deps.sh
# 2. Setup development environment
./scripts/dev-setup.sh
# 3. Start all services
./scripts/start-dev.sh
# 4. Stop all services when done
./scripts/stop-dev.sh
```
#### Manual Development Process
1. **Cross-repo Setup**: Clone all repositories to same parent directory
2. **Service Dependencies**: Start services in order: NATS → Predastore → Viperblock → NBDkit → Gateway → Daemons
3. **Build Order**: Build components: predastore → viperblock → hive
4. **Testing**: Use `make test` in each component to verify changes
5. **Message-Driven Development**: Test NATS message flows between gateway and daemons
6. **Scaling**: Test with multiple daemon instances subscribing to same topics
#### Development Tools
- **Hot Reloading**: Use `air` for automatic restarts during development
- **TLS Certificates**: Auto-generated self-signed certificates for HTTPS endpoints
- **Log Monitoring**: Centralized logs in `data/logs/` directory
- **AWS CLI Testing**: Test endpoints with `aws --endpoint-url https://localhost:9999`
### Development Roadmap
For major feature development and architectural changes, refer to `HIVE_DEVELOPMENT_PLAN.md` which contains:
- Comprehensive roadmap for transforming Hive from proof-of-concept to beta release
- Detailed implementation phases with specific tasks and file structures
- AWS API compatibility implementation guidelines
- Development environment automation plans
- Testing and validation frameworks
When implementing new AWS services or enhancing existing functionality, follow the structured approach outlined in the development plan.
-74
View File
@@ -1,74 +0,0 @@
# Design ideas
### hivectl
Basic commands to setup a compute instance, block storage (viperblock), object storage (predastore)
`hivectl configure --endpoint-url https://localhost:8443/`
`hivectl auth --access_key X --secret_key Y --profile hivelocal --endpoint-url https://localhost:8443/`
Writes to ~/.aws/credentials
`hivectl vol add --size 128M --no-verify-ssl --endpoint-url https://localhost:8443/ --`
Authenticates using ~/.aws/credentials - Backend creates a unique volume id
Connects to S3, creates the /vol-uniquestr/, uploads `config.json`
```
{
"VolumeName": "vol-uniquestr",
"VolumeSize": 134217728,
"BlockSize": 4096,
"ObjBlockSize": 4194304,
"SeqNum": 0,
"ObjectNum": 0,
"WALNum": 0,
"BlockToObjectWALNum": 0,
"Version": 1
}
```
`hivectl ec2 import-key-pair --key-name my-key-pair --public-key-material fileb://~/.ssh/id_rsa.pub`
This will upload the users SSH public key to their S3 bucket, /bucket/ec2/my-key-pair.pub
`hivectl ec2 run-instances --image-id ami-X --instance-type t3.micro --key-name my-key-pair`
This will clone an existing image-id (vol-ID, e.g debian snapshot), copy to the users S3 bucket, and start the new volume from the fresh copy.
Note when starting an image additional volumes are required.
```
:~/qemu-host$ truncate -s 64m varstore.img
:~/qemu-host$ truncate -s 64m efi.img
:~/qemu-host$ dd if=/usr/share/qemu-efi-aarch64/QEMU_EFI.fd of=efi.img conv=notrunc
```
Sample launch
Require to start NBD (EFI)
./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-1-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb" host="https://127.0.0.1:8443"
Require to start Debian volume
./server/nbdkit -p 10809 --pidfile /tmp/vb-vol-1-root.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=4294967296 volume=vol-1-root bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb" host="https://127.0.0.1:8443"
```
:~/qemu-host$ ~/qemu/build/aarch64-softmmu/qemu-system-aarch64 -M virt \
-machine virtualization=true -machine virt,gic-version=3 \
-cpu max,pauth-impdef=on -smp 2 -m 4096 \
-drive if=pflash,format=raw,file=efi.img,readonly=on \
-drive if=pflash,format=raw,file=varstore.img \
-drive if=virtio,format=qcow2,file=disk.img \
-device virtio-scsi-pci,id=scsi0 \
-object rng-random,filename=/dev/urandom,id=rng0 \
-device virtio-rng-pci,rng=rng0 \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,hostfwd=tcp::8022-:22 \
-nographic \
-drive if=none,id=cd,file=debian-12.2.0-arm64-netinst.iso \
-device scsi-cd,drive=cd
```
File diff suppressed because it is too large Load Diff
+2 -434
View File
@@ -1,435 +1,3 @@
# Hive installation
# Spinifex Installation
## Dependencies
For running on Ubuntu 22.04
```bash
sudo add-apt-repository universe
sudo apt install nbdkit nbdkit-plugin-dev pkg-config qemu-system qemu-utils qemu-kvm libvirt-daemon-system libvirt-clients libvirt-dev
```
Create hive user
```bash
sudo adduser --disabled-password hive
```
Add hive to the libvirt group to manage VMs
```bash
sudo adduser hive libvirt
```
Hive provided AWS API/SDK layer functionality, which requires the AWS CLI tool to be installed to interface with the system.
```bash
sudo apt install awscli
```
## Build
Compile `hive` binary which will be used throughout the installation tutorial.
```
make
```
Confirm ./bin/hive exists and executable.
## Init
When running Hive for the first time, run the init function to create the default directories for data, config files and layout required.
```bash
./bin/hive admin init
```
Next, set the AWS profile to use `hive` which points to the local environment.
```bash
export AWS_PROFILE=hive
```
## Launch services
Start the core services
```bash
./scripts/start-dev.sh
```
## Create SSH key
For first install users, create or import an existing key pair which can be used to launch EC2 instances.
### Import existing key
Import an existing key pair, replace `~/.ssh/id_rsa.pub` with your specified key.
```bash
aws ec2 import-key-pair --key-name "hive-key" --public-key-material fileb://~/.ssh/id_rsa.pub
```
### Create new key pair
Alternatively, create a new key pair and store the JSON output of the AWS SDK using the `jq` command.
```bash
aws ec2 create-key-pair \
--key-name hive-key \
| jq -r '.KeyMaterial | rtrimstr("\n")' > ~/.ssh/hive-key
```
Update permissions to SSH will accept reading the file.
```bash
chmod 600 ~/.ssh/hive-key
```
Next, generate a public key from the specified private key pair.
```bash
ssh-keygen -y -f ~/.ssh/hive-key > ~/.ssh/hive-key.pub
```
Validate the new key is available
```bash
aws ec2 describe-key-pairs
```
```json
{
"KeyPairs": [
{
"KeyPairId": "key-3caa0a34f53d12a3",
"KeyType": "ed25519",
"Tags": [],
"CreateTime": "2025-10-28T13:39:23.458000+00:00",
"KeyName": "hive-key",
"KeyFingerprint": "SHA256:/g/A5OkeZeSydz9WUErXYVdCt00b0VbfN6RLn2YVFAY"
}
]
}
```
## Create AMI template (new method)
Use the hive CLI tool to import a selected OS image. Note, the source file can be compressed (e.g image.tar.gz, image.gz, image.tar.xz) and the tool will automatically extract and upload the raw OS image as an AMI.
Note, when downloading OS images, use supported platforms that support the `cloud-init` feature to automatically bootstrap when using the Hive EC2 functionality.
### Automatic import
Discover available images to automatically download and install. This will pull the images from the distro official mirror and simplify the process to bootstrap a Hive installation with AMIs that include common operating systems.
```bash
./bin/hive admin images list
```
```bash
NAME | DISTRO | VERSION | ARCH | BOOT
alpine-3.22.2-x86_64 | alpine | 3.22.2 | x86_64 | bios
debian-12-arm64 | debian | 12 | arm64 | bios
debian-12-x86_64 | debian | 12 | x86_64 | bios
ubuntu-24.04-arm64 | ubuntu | 24.04 | arm64 | bios
ubuntu-24.04-x86_64 | ubuntu | 24.04 | x86_64 | bios
```
Next, choose the image you would like to import as an AMI.
```bash
./bin/hive admin images import --name debian-12-arm64 --force
```
```bash
Downloading image https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-generic-arm64.tar.xz to /home/ben/hive/images/debian/12/arm64/debian-12-generic-amd64.tar.xz
Downloading local-debian-12-arm64 [283748988/283748988] ██████████████ 100% | 1s
Saved /home/ben/hive/images/debian/12/arm64/debian-12-generic-amd64.tar.xz (270.6 MiB)
Extracted image to: /home/ben/hive/images/debian/12/arm64/disk.raw
✅ Image import complete. Image-ID (AMI): ami-e29fcc65734aec9ea
```
Next, verify available disk images to confirm the import was successful
```bash
aws ec2 describe-images --image-ids ami-e29fcc65734aec9ea
```
### Manual import
Using this method you can import any OS disk image. For example, download the Debian 12 image from the repository [https://cloud.debian.org/images/cloud/bookworm/latest/](https://cloud.debian.org/images/cloud/bookworm/latest/)
Download the image:
```bash
wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.tar.xz -O ~/debian-12-genericcloud-arm64.tar.xz
```
Import as an AMI to the backend store:
```bash
./bin/hive admin images import --file ~/debian-12-genericcloud-arm64.tar.xz --arch arm64 --distro debian --version 12
```
Next, verify available disk images to confirm the import was successful
```bash
aws ec2 describe-images
```
## Run Instance
Once Hive is successfully installed and bootstrapped with a system AMI and SSH keys, proceed to run an instance. Replace `ami-XXX` with your imported ImageId above.
```bash
export HIVE_AMI="ami-XXX"
```
Launch a new instance, note `hive-key` is the SSH key specified in the previous stage.
```bash
aws ec2 run-instances \
--image-id $HIVE_AMI \
--instance-type t3.micro \
--key-name hive-key \
--security-group-ids sg-0123456789abcdef0 \
--subnet-id subnet-6e7f829e \
--count 1
```
A sample response is below from the `RunInstance` request, note the `InstanceId` attribute:
```json
{
"ReservationId": "r-f101157331e261a68",
"OwnerId": "123456789012",
"Instances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"ImageId": "ami-15c3dcfe607460f15",
"State": {
"Code": 0,
"Name": "pending"
},
"KeyName": "hive-key",
"InstanceType": "t3.micro",
"LaunchTime": "2025-11-12T13:07:47.548000+00:00"
}
]
}
```
Export the instance ID for following the rest of the tutorial
```bash
export INSTANCE_ID="i-XXX"
```
Next, validate the running instance is ready
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
Confirm the `State.Name` attribute is set as `running`
```json
{
"Reservations": [
{
"ReservationId": "r-f101157331e261a68",
"OwnerId": "123456789012",
"Instances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"ImageId": "ami-15c3dcfe607460f15",
"State": {
"Code": 16,
"Name": "running"
},
"KeyName": "hive-key",
"InstanceType": "t3.micro",
"LaunchTime": "2025-11-12T13:07:47.548000+00:00"
}
]
}
]
}
```
## SSH connection (development)
For a Hive development environment (toggled off for production), a local SSH port forwaring will be active to connect directly to the instance, regardless of the VPC and network settings.
Determine the SSH port allocated
```bash
ps auxw | grep $INSTANCE_ID
```
```bash
qemu-system-x86_64 -daemonize -pidfile /run/user/1000/i-36765eb0c6609e4d.pid -qmp unix:/run/user/1000/qmp-i-36765eb0c6609e4d2.sock,server,nowait -enable-kvm -M ubuntu -serial pty -cpu host -smp 2 -m 1024 -drive file=nbd://127.0.0.1:42653,format=raw,if=none,media=disk,id=os -drive file=nbd://127.0.0.1:44499,format=raw,if=virtio,media=cdrom,id=cloudinit -device virtio-blk-pci,drive=os,bootindex=1 -device virtio-rng-pci -device virtio-net-pci,netdev=net0 -netdev user,id=net0,hostfwd=tcp:127.0.0.1:33683-:22
```
Note the `hostfwd=tcp:127.0.0.1:33683-:22`, in this case the local port `33683` will connect to the new instance.
```bash
ssh -i ~/.ssh/hive-key ec2-user@127.0.0.1 -p 33683
...
Linux hive-vm-36765eb0 6.1.0-40-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.153-1 (2025-09-20) x86_64
ec2-user@hive-vm-36765eb0:~$
```
Congratulations, your first AMI image is imported, a new EC2 instance launched, and successfully connected via SSH for the configured SSH key, using the OS `cloud-init` procedure.
## Managing instances
### Stop instance
To stop a running instance gracefully:
```bash
aws ec2 stop-instances --instance-ids $INSTANCE_ID
```
```json
{
"StoppingInstances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"CurrentState": {
"Code": 64,
"Name": "stopping"
},
"PreviousState": {
"Code": 16,
"Name": "running"
}
}
]
```
Next, confirm the instance has stopped as requested
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
```json
{
"Reservations": [
{
"ReservationId": "r-f101157331e261a68",
"OwnerId": "123456789012",
"Instances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"ImageId": "ami-15c3dcfe607460f15",
"State": {
"Code": 80,
"Name": "stopped"
},
"KeyName": "hive-key",
"InstanceType": "t3.micro",
"LaunchTime": "2025-11-12T13:07:47.548000+00:00"
}
]
}
]
}
```
### Start Instance
To start a previously stopped instance:
```bash
aws ec2 start-instances --instance-ids $INSTANCE_ID
```
```json
{
"StartingInstances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"CurrentState": {
"Code": 0,
"Name": "pending"
},
"PreviousState": {
"Code": 80,
"Name": "stopped"
}
}
]
```
Next, validate the instance is running as expected
```bash
aws ec2 describe-instances --instance-ids "i-36765eb0c6609e4d2"
```
```json
{
"Reservations": [
{
"ReservationId": "r-f101157331e261a68",
"OwnerId": "123456789012",
"Instances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"ImageId": "ami-15c3dcfe607460f15",
"State": {
"Code": 16,
"Name": "running"
},
"KeyName": "hive-key",
"InstanceType": "t3.micro",
"LaunchTime": "2025-11-12T13:07:47.548000+00:00"
}
]
}
]
}
```
### Terminate instance
To terminate an instance, which will first stop the instance, and on success, remove the EBS volumes and permanately remove the instance data.
```bash
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
```
```json
{
"TerminatingInstances": [
{
"InstanceId": "i-36765eb0c6609e4d2",
"CurrentState": {
"Code": 16,
"Name": "running"
},
"PreviousState": {
"Code": 16,
"Name": "running"
}
}
]
}
```
Next, validate the instance is removed, this may take a few minutes depending the instance volume size.
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
On success no data will be returned, since the instance is no longer available.
For documentation on using and installing Spinifex see [https://docs.mulgadc.com](https://docs.mulgadc.com/)
+629 -169
View File
@@ -1,201 +1,661 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
1. Definitions.
Preamble
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
The precise terms and conditions for copying, distribution and
modification follow.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
TERMS AND CONDITIONS
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
0. Definitions.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
"This License" refers to version 3 of the GNU Affero General Public License.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
A "covered work" means either the unmodified Program or a work based
on the Program.
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
1. Source Code.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
END OF TERMS AND CONDITIONS
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
APPENDIX: How to apply the Apache License to your work.
The Corresponding Source for a work in source code form is that
same work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
2. Basic Permissions.
Copyright [yyyy] [name of copyright owner]
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
http://www.apache.org/licenses/LICENSE-2.0
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+191 -9
View File
@@ -1,23 +1,114 @@
GO_PROJECT_NAME := hive
GO_PROJECT_NAME := spx
SHELL := /bin/bash
# Detect architecture for cross-platform support
ARCH := $(shell uname -m)
ifeq ($(ARCH),x86_64)
GO_ARCH := amd64
AWS_ARCH := x86_64
# On x86, we can run ARM VMs via emulation
QEMU_PACKAGES := qemu-system-x86 qemu-system-arm
else ifeq ($(ARCH),aarch64)
GO_ARCH := arm64
AWS_ARCH := aarch64
# On ARM, we can run x86 VMs via emulation
QEMU_PACKAGES := qemu-system-aarch64 qemu-system-x86
else ifeq ($(ARCH),arm64)
GO_ARCH := arm64
AWS_ARCH := aarch64
QEMU_PACKAGES := qemu-system-aarch64 qemu-system-x86
else
$(error Unsupported architecture: $(ARCH). Only x86_64 and aarch64/arm64 are supported.)
endif
# Ask Go whether workspace mode is active
IN_WORKSPACE := $(shell go env GOWORK)
# Use -mod=mod unless Go reports an active workspace path
ifeq ($(IN_WORKSPACE),)
GO_BUILD_MOD := -mod=mod
else ifeq ($(IN_WORKSPACE),off)
GO_BUILD_MOD := -mod=mod
else
GO_BUILD_MOD :=
endif
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
ifdef QUIET
_Q = @
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
_RACEQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' || true; }; exit $${PIPESTATUS[0]}
_SECQ = >
else
_Q =
_COVQ =
_RACEQ =
_SECQ = 2>&1 | tee
endif
build:
$(MAKE) go_build
# Build spinifex-ui frontend (requires pnpm)
build-ui:
@echo -e "\n....Building spinifex-ui frontend...."
cd spinifex/services/spinifexui/frontend && pnpm build
# GO commands
VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT ?= $(shell git rev-parse --short HEAD)
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSION) -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=$(COMMIT)
go_build:
@echo "\n....Building $(GO_PROJECT_NAME)"
go build -ldflags "-s -w" -o ./bin/$(GO_PROJECT_NAME) cmd/hive/main.go
@echo -e "\n....Building $(GO_PROJECT_NAME)"
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
build-alb-agent:
@echo -e "\n....Building alb-agent (static)"
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/alb-agent cmd/alb-agent/main.go
build-system-image: ## Build a system image from manifest (use IMAGE=alb)
ifndef IMAGE
$(error IMAGE is required. Usage: make build-system-image IMAGE=alb)
endif
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
build-alb-image: ## Build ALB Alpine image
$(MAKE) build-system-image IMAGE=alb
go_run:
@echo "\n....Running $(GO_PROJECT_NAME)...."
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
$(GOPATH)/bin/$(GO_PROJECT_NAME)
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
preflight:
@$(MAKE) --no-print-directory QUIET=1 lint govulncheck test-cover diff-coverage test-race
@echo -e "\n ✅ Preflight passed — safe to commit."
# Run unit tests
test:
@echo "\n....Running tests for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -v -timeout 300s ./...
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -timeout 120s ./spinifex/...
# Run unit tests with coverage profile
COVERPROFILE ?= coverage.out
test-cover:
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -timeout 120s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... $(_COVQ)
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
# Run unit tests with race detector
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... $(_RACEQ)
# Check that new/changed code meets coverage threshold (runs tests first)
diff-coverage: test-cover
@QUIET=$(QUIET) scripts/diff-coverage.sh $(COVERPROFILE)
bench:
@echo "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
@echo -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
$(MAKE) easyjson
LOG_IGNORE=1 go test -benchmem -run=. -bench=. ./...
@@ -26,6 +117,97 @@ run:
$(MAKE) go_run
clean:
rm ./bin/$(GO_PROJECT_NAME)
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/services/spinifexui/frontend/dist
.PHONY: go_build go_run build run test clean
install-system:
@echo -e "\n....Installing system dependencies for $(ARCH)...."
@echo "QEMU packages: $(QEMU_PACKAGES)"
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
nbdkit nbdkit-plugin-dev pkg-config $(QEMU_PACKAGES) qemu-utils qemu-kvm \
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@echo "Go version: $$(go version)"
install-aws:
@echo -e "\n....Installing AWS CLI v2 for $(ARCH) ($(AWS_ARCH))...."
@if ! command -v aws >/dev/null 2>&1; then \
curl "https://awscli.amazonaws.com/awscli-exe-linux-$(AWS_ARCH).zip" -o "awscliv2.zip"; \
unzip -o awscliv2.zip; \
./aws/install; \
rm -rf awscliv2.zip aws/; \
else \
echo "AWS CLI already installed"; \
fi
quickinstall: install-system install-go install-aws
@echo -e "\n✅ Quickinstall complete for $(ARCH)."
@echo " Please ensure /usr/local/go/bin is in your PATH."
@echo " Installed: Go ($(GO_ARCH)), AWS CLI ($(AWS_ARCH)), QEMU ($(QEMU_PACKAGES))"
# Lint all Go code via golangci-lint (replaces check-format, vet, gosec, staticcheck)
lint:
@echo "Running golangci-lint..."
$(_Q)golangci-lint run ./...
@echo " golangci-lint ok"
# Auto-fix all linter issues that have fixers
fix:
golangci-lint run --fix ./...
# Govulncheck — dependency vulnerability scanning (not covered by golangci-lint)
govulncheck:
@echo "Running govulncheck..."
$(_Q)go tool govulncheck ./...
@echo " govulncheck ok"
# Build release tarballs — use distro-ARCH for single arch, distro for both
distro: distro-amd64 distro-arm64
@echo ""
@echo "Distribution tarballs:"
@ls -lh dist/*.tar.gz
@echo ""
@cat dist/*.sha256
distro-amd64:
@echo "Building spinifex $(VERSION) linux/amd64..."
@mkdir -p dist/
docker buildx build \
--platform linux/amd64 \
--build-arg VERSION=$(VERSION) \
-f build/Dockerfile.distro \
--output type=local,dest=dist/amd64/ \
../
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
distro-arm64:
@echo "Building spinifex $(VERSION) linux/arm64..."
@mkdir -p dist/
docker buildx build \
--platform linux/arm64 \
--build-arg VERSION=$(VERSION) \
-f build/Dockerfile.distro \
--output type=local,dest=dist/arm64/ \
../
tar -czf dist/spinifex-$(VERSION)-linux-arm64.tar.gz -C dist/arm64 .
sha256sum dist/spinifex-$(VERSION)-linux-arm64.tar.gz > dist/spinifex-$(VERSION)-linux-arm64.tar.gz.sha256
distro-clean:
rm -rf dist/
.PHONY: build build-ui build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean
+31 -35
View File
@@ -1,10 +1,10 @@
# Hive: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
(Developer Preview) - Hive developed by [Mulga Defense Corporation](https://mulgadc.com/) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Hive gives you AWS-style workflows on your own hardware.
(Developer Preview) - Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com/) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
## What is Hive?
## What is Spinifex?
Hive replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
It’s designed for developers and operators who need:
@@ -13,13 +13,13 @@ It’s designed for developers and operators who need:
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
- A secure cloud environment you control and own the entire hardware, network and software stack
You can run Hive on a few servers in a rack, a field site, or anywhere centralized cloud services aren’t feasible.
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren’t feasible.
## Core Components
### Hive (Compute Service – EC2 Alternative)
### Spinifex (Compute Service – EC2 Alternative)
Hive is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU’s QMP interface. Designed to be straightforward and scriptable, Hive lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Hive with underlying instances.
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU’s QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
- EC2-like VM management on bare metal
- Launches with cloud-init metadata support
@@ -50,46 +50,48 @@ AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, o
- Designed for Control – Run on your own terms, whether that’s in a datacenter, remote site, or sensitive environment.
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
- Works Offline – No reliance on centralized cloud services or external networks.
- Open Source – Licensed under Apache 2.0. Fork it, extend it, or deploy it as-is.
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
## Development Setup
## Installation
### For Developers
Installation requires an Ubuntu / Debian system. See the detailed documentation at [https://docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
Get started with Hive development in minutes:
### Single Node Install
The installation is straightforward to set up and running on a single node for testing purposes.
```bash
# Clone the repository
git clone https://github.com/mulgadc/hive.git hive
cd hive
curl https://install.mulgadc.com | bash
# Setup dependencies and development environment
./scripts/clone-deps.sh # Clone viperblock + predastore repositories
./scripts/dev-setup.sh # Setup complete development environment
sudo /usr/local/share/spinifex/setup-ovn.sh --management
sudo spx admin init
sudo systemctl start spinifex.target
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
Next, follow the detailed [Installation Guide](./INSTALL.md) to bootstrap a new environment with SSH keys, AMI (OS images) and how to spin up new EC2 instances using Hive.
### Development Setup
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/source-install/) documentation
### Component Repositories
Hive coordinates these independent components:
Spinifex coordinates these independent components:
- **[Predastore](https://github.com/mulgadc/predastore)** - S3-compatible object storage
- **[Viperblock](https://github.com/mulgadc/viperblock)** - EBS-compatible block storage
Each component can be developed independently. See component-specific documentation for focused development guides.
## Detailed Installation method
See [Installation Guide](INSTALL.md) on how to setup a Hive node to get started.
Then configure your AWS CLI to point to Hive's endpoints and start launching VMs, buckets, and volumes using familiar commands and Terraform scripts.
## Development Philosophy
### Built by Engineers, For Engineers
Hive is developed by experienced infrastructure engineers with deep AWS expertise, including former AWS team members who understand the intricacies of building production-grade cloud services. Our team brings decades of combined experience from AWS, enterprise infrastructure, and edge computing environments.
Spinifex is developed by experienced infrastructure engineers with deep AWS expertise, including former AWS team members who understand the intricacies of building production-grade cloud services. Our team brings decades of combined experience from AWS, enterprise infrastructure, and edge computing environments.
**Real-World Experience:**
@@ -100,7 +102,7 @@ Hive is developed by experienced infrastructure engineers with deep AWS expertis
### AI-Assisted Development
While Hive is architected and implemented by experienced engineers, we leverage **Claude Code** (Anthropic's AI coding assistant) to accelerate certain development tasks. This approach combines human expertise with AI efficiency:
While Spinifex is architected and implemented by experienced engineers, we leverage **Claude Code** (Anthropic's AI coding assistant) to accelerate certain development tasks. This approach combines human expertise with AI efficiency:
**How We Use Claude Code:**
@@ -116,14 +118,8 @@ While Hive is architected and implemented by experienced engineers, we leverage
- **Performance Optimization**: Real-world performance tuning and benchmarking
- **Production Operations**: Deployment strategies and operational procedures
**Development Artifacts:**
- `CLAUDE.md` - Instructions for Claude Code when working on this codebase
- `HIVE_DEVELOPMENT_PLAN.md` - Comprehensive roadmap and implementation strategy
- Component-specific guidance in `viperblock/CLAUDE.md` and `predastore/CLAUDE.md`
This hybrid approach ensures Hive benefits from both proven engineering expertise and modern development acceleration, while maintaining the quality and reliability standards required for production infrastructure.
This hybrid approach ensures Spinifex benefits from both proven engineering expertise and modern development acceleration, while maintaining the quality and reliability standards required for production infrastructure.
## License
Hive is open source under the Apache 2.0 License. You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
-509
View File
@@ -1,509 +0,0 @@
# Testing Implementation Summary
## ✅ Completed Work
### 1. Daemon Test Suite (`hive/daemon/daemon_test.go`)
Created comprehensive unit tests for the daemon's EC2 launch functionality:
**Test Components:**
- `startTestNATSServer()` - Embedded NATS server with auto port allocation
- `createTestDaemon()` - Test daemon factory with automatic cleanup
- `createValidRunInstancesInput()` - Test fixture for valid EC2 requests
**Test Suites:**
- ✅ `TestHandleEC2Launch_MessageParsing` - NATS message handling (5 test cases)
- ⏸️ `TestHandleEC2Launch_ResourceManagement` - Skipped (requires full infrastructure)
- ✅ `TestDaemon_Initialization` - Daemon creation and configuration
- ✅ `TestResourceManager` - Resource allocation logic
**Test Results:**
```bash
$ go test -v ./hive/daemon/
PASS: TestHandleEC2Launch_MessageParsing (0.17s)
✓ Valid_RunInstancesInput
✓ Invalid_Instance_Type
✓ Missing_Required_ImageId
✓ Invalid_MinCount_(zero)
✓ Malformed_JSON
SKIP: TestHandleEC2Launch_ResourceManagement
PASS: TestDaemon_Initialization (0.00s)
PASS: TestResourceManager (0.00s)
ok github.com/mulgadc/hive/hive/daemon 0.592s
```
### 2. Utils Test Suite (`hive/utils/utils_test.go`)
Added comprehensive tests for previously untested utility functions:
**New Tests:**
- ✅ `TestUnmarshalJsonPayload` - JSON unmarshaling (4 test cases)
- ✅ `TestMarshalJsonPayload` - JSON marshaling (2 test cases)
- ✅ `TestGenerateErrorPayload` - Error generation (3 test cases)
- ✅ `TestValidateErrorPayload` - Error validation (4 test cases)
- ✅ `TestMarshalToXML` - XML marshaling (3 test cases)
- ✅ `TestKillProcess` - Process termination
- ✅ `TestStopProcess` - Service stop with PID cleanup
**Test Results:**
```bash
$ go test -v ./hive/utils/
PASS: TestGeneratePidFile (0.00s)
PASS: TestGenerateSocketFile (0.00s)
PASS: TestExecProcessAndKill (1.71s)
PASS: TestUnmarshalJsonPayload (0.00s)
PASS: TestMarshalJsonPayload (0.00s)
PASS: TestGenerateErrorPayload (0.00s)
PASS: TestValidateErrorPayload (0.00s)
PASS: TestMarshalToXML (0.00s)
PASS: TestKillProcess (4.51s)
PASS: TestStopProcess (4.01s)
ok github.com/mulgadc/hive/hive/utils 10.844s
```
### 3. Bug Fixes
Fixed critical bugs that prevented tests from passing:
#### Bug 1: Pointer-to-Pointer in UnmarshalJsonPayload
**Location:** `hive/utils/utils.go:283`
**Before:**
```go
err := decoder.Decode(&input) // input already a pointer, creates **Type
```
**After:**
```go
err := decoder.Decode(input) // Correct: input is already a pointer
```
#### Bug 2: Nil Pointer in handleEC2Launch
**Location:** `hive/daemon/daemon.go:638-645`
**Before:**
```go
var runInstancesInput *ec2.RunInstancesInput // nil pointer
errResp = utils.UnmarshalJsonPayload(runInstancesInput, msg.Data) // crashes
```
**After:**
```go
runInstancesInput := &ec2.RunInstancesInput{} // Initialize before use
errResp = utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
```
#### Bug 3: Uninitialized Slice Elements in launchEC2Instance
**Location:** `hive/daemon/daemon.go:852-856`
**Before:**
```go
reservation.Instances = make([]*ec2.Instance, 1)
reservation.Instances[0].SetInstanceId(instance.ID) // nil pointer dereference
```
**After:**
```go
reservation.Instances = make([]*ec2.Instance, 1)
reservation.Instances[0] = &ec2.Instance{
State: &ec2.InstanceState{},
}
reservation.Instances[0].SetInstanceId(instance.ID)
```
#### Bug 4: Same Issue in MarshalJsonPayload
**Location:** `hive/utils/utils.go:299`
**Before:**
```go
err := decoder.Decode(&input)
```
**After:**
```go
err := decoder.Decode(input)
```
### 4. Documentation
Created comprehensive testing documentation:
**Files Created:**
1. `hive/daemon/TESTING.md` (423 lines)
- Test structure and components
- macOS limitations and workarounds
- Test execution instructions
- Mock service strategies
- Debugging guide
2. `hive/daemon/TEST_SUMMARY.md` (467 lines)
- Test architecture highlights
- Known issues and limitations
- Recommendations for different platforms
- Next steps and roadmap
- Current test results
3. `TESTING_COMPLETE.md` (this file)
- Summary of all completed work
- Bug fixes
- Test results
- Future enhancements
## 📊 Test Coverage
### Overall Test Results
```bash
$ make test
✓ hive/awsec2query - All tests passing
✓ hive/awserrors - All tests passing
✓ hive/daemon - 3 passing, 1 skipped (infrastructure required)
✓ hive/gateway/ec2/instance - All tests passing
✓ hive/service - All tests passing
✓ hive/services/predastore - All tests passing (integration skipped)
✓ hive/services/viperblockd - All tests passing
✓ hive/utils - All tests passing (10 tests, 26 cases)
✓ hive/vm - All tests passing
OVERALL: PASS
```
### Code Coverage by Package
| Package | Tests | Coverage | Notes |
|---------|-------|----------|-------|
| daemon | 4 | ~60% | Message handling, resource mgmt, initialization |
| utils | 10 | ~85% | All major functions tested |
| gateway/ec2/instance | 2 | ~75% | RunInstances validation |
| vm | 2 | ~40% | Config generation, ID creation |
| services/* | 8 | ~70% | Service lifecycle, NATS integration |
## 🎯 Test Framework Features
### Daemon Tests
**Strengths:**
- ✅ Isolated NATS server per test
- ✅ Automatic resource cleanup
- ✅ Table-driven test design
- ✅ Comprehensive error scenarios
- ✅ AWS SDK integration testing
**Limitations:**
- ⚠️ Requires mocks for viperblock/nbdkit on macOS
- ⚠️ Full VM launch needs Linux + infrastructure
- ⚠️ Cloud-init volume creation not tested
### Utils Tests
**Strengths:**
- ✅ Comprehensive coverage of JSON/XML marshaling
- ✅ Process management testing
- ✅ Error handling validation
- ✅ Platform-aware (macOS vs Linux)
**Coverage:**
- ✅ PID file operations
- ✅ Socket file generation
- ✅ Process lifecycle (start/stop/kill)
- ✅ JSON payload marshaling/unmarshaling
- ✅ XML marshaling
- ✅ Error payload generation/validation
## 🐛 Bugs Fixed
### Critical (Blocking Tests)
1. **Pointer-to-pointer bugs** (2 instances)
- Impact: Caused all JSON unmarshaling to fail
- Fix: Remove unnecessary `&` operator
- Files: `utils.go:283, 299`
2. **Nil pointer dereference in handleEC2Launch**
- Impact: All EC2 launch requests crashed
- Fix: Initialize struct before passing to unmarshal
- File: `daemon.go:638`
3. **Uninitialized slice elements**
- Impact: Crash when setting instance fields
- Fix: Initialize slice elements before use
- File: `daemon.go:852-856`
### Total Lines Changed
- **Modified:** ~15 lines across 3 files
- **Added:** ~550 lines of tests
- **Documentation:** ~1,200 lines
## 📝 Test Scenarios Covered
### Daemon Tests
**Message Parsing:**
- ✅ Valid RunInstancesInput with all fields
- ✅ Invalid instance type rejection
- ✅ Missing required ImageId
- ✅ Invalid MinCount (zero)
- ✅ Malformed JSON handling
**Resource Management:**
- ✅ Instance type lookup
- ✅ Resource allocation/deallocation
- ✅ Capacity checking
- ✅ System resource detection
### Utils Tests
**JSON Operations:**
- ✅ Valid JSON unmarshaling
- ✅ Malformed JSON error handling
- ✅ Unknown field rejection (DisallowUnknownFields)
- ✅ Empty JSON handling
**Error Handling:**
- ✅ Error payload generation
- ✅ Error payload validation
- ✅ Success vs error discrimination
**XML Operations:**
- ✅ Struct marshaling
- ✅ Pointer marshaling
- ✅ Invalid type error handling
**Process Management:**
- ✅ Process creation and termination
- ✅ PID file read/write/remove
- ✅ Signal handling
- ✅ Non-existent process error handling
## 🚀 Running Tests
### Quick Test
```bash
# Run all tests
make test
# Run specific package
go test -v ./hive/daemon/
go test -v ./hive/utils/
# Run with race detection
go test -race ./...
# Run with coverage
go test -cover ./...
```
### Platform-Specific
**macOS (Unit Tests Only):**
```bash
# All passing unit tests
go test -v ./hive/daemon/
go test -v ./hive/utils/
```
**Linux (Full Integration):**
```bash
# Requires: predastore, viperblock, nbdkit, QEMU
# Remove SKIP_INTEGRATION flag
unset SKIP_INTEGRATION
go test -v ./hive/daemon/
```
**Docker (Recommended for Full Tests):**
```bash
docker-compose up test-env
docker exec -it hive-test go test -v ./...
```
## 🔍 What's Tested vs Not Tested
### ✅ Fully Tested
- NATS message parsing and routing
- JSON marshaling/unmarshaling
- Error generation and validation
- Resource manager logic
- PID file operations
- Process lifecycle management
- XML marshaling
- Input validation
- Instance type lookup
### ⏸️ Partially Tested (Mocks Needed for macOS)
- EC2 launch flow (stops at viperblock creation)
- Volume mounting (requires nbdkit)
- Cloud-init generation (requires S3 backend)
### ❌ Not Yet Tested (Requires Full Infrastructure)
- Complete VM launch
- QEMU process management
- QMP socket communication
- NBD volume attachment
- EBS volume lifecycle
- VM state transitions
- Concurrent instance launches
- Resource exhaustion scenarios
## 🎯 Future Enhancements
### Short Term (Next Sprint)
1. **Mock Services**
```go
// Mock EBS mount service for macOS
func startMockEBSService(t *testing.T, nc *nats.Conn) {
nc.Subscribe("ebs.mount", func(msg *nats.Msg) {
// Return mock NBD URI
})
}
```
2. **Integration Tests**
- Add `//go:build linux` tags
- Full VM launch tests
- Volume lifecycle tests
3. **Benchmark Tests**
```go
func BenchmarkEC2Launch(b *testing.B) {
// Measure launch performance
}
```
### Medium Term
1. **CI/CD Integration**
- GitHub Actions workflow
- Docker-based test environment
- Coverage reporting (codecov.io)
2. **Load Testing**
- Concurrent launch stress tests
- Resource exhaustion tests
- NATS throughput tests
3. **Mock Framework**
- Reusable mock services
- Test helpers package
- Platform-aware test skipping
### Long Term
1. **E2E Test Suite**
- Complete AWS SDK compatibility tests
- Multi-instance orchestration
- Failure recovery scenarios
2. **Performance Suite**
- Latency measurements
- Throughput benchmarks
- Resource utilization profiling
3. **Chaos Engineering**
- Network partition tests
- Service failure simulation
- Recovery time validation
## 📚 Documentation Structure
```
hive/
├── hive/daemon/
│ ├── daemon.go [FIXED: 3 bugs]
│ ├── daemon_test.go [NEW: 4 test suites]
│ ├── TESTING.md [NEW: 423 lines]
│ └── TEST_SUMMARY.md [NEW: 467 lines]
├── hive/utils/
│ ├── utils.go [FIXED: 2 bugs]
│ └── utils_test.go [ENHANCED: +10 tests]
└── TESTING_COMPLETE.md [NEW: This file]
```
## 🎉 Success Metrics
### Before
- ❌ daemon tests: 0 tests, crashes on startup
- ❌ utils tests: 3 tests, missing coverage for 13+ functions
- ❌ Critical bugs: 4 blocking issues
- ❌ Documentation: None
### After
- ✅ daemon tests: 4 test suites, 5 test cases passing
- ✅ utils tests: 10 test suites, 26 test cases passing
- ✅ Critical bugs: All fixed
- ✅ Documentation: 1,200+ lines
- ✅ Total test cases: 50+ across all packages
- ✅ Overall: `make test` passes 100%
## 💡 Key Learnings
### Test Design
1. **Embedded NATS server is ideal for testing**
- No external dependencies
- Port auto-allocation prevents conflicts
- Fast startup/teardown
2. **Table-driven tests scale well**
- Easy to add new cases
- Clear test structure
- Minimal code duplication
3. **Platform differences matter**
- macOS vs Linux signal handling
- Process cleanup timing
- File system behavior
### Bug Patterns
1. **Pointer confusion is common**
- Double-check `&` usage
- Verify struct initialization
- Test nil pointer paths
2. **AWS SDK requires explicit initialization**
- Can't use SetX() on nil pointers
- Must initialize nested structs
- Slice make() != slice element initialization
3. **JSON unmarshaling needs initialized target**
- Can't unmarshal into nil pointer
- DisallowUnknownFields is strict
- Empty {} is valid but may be treated as error
## 🔗 Related Files
- `CLAUDE.md` - Project overview and development guide
- `HIVE_DEVELOPMENT_PLAN.md` - Roadmap and feature planning
- `hive/daemon/TESTING.md` - Detailed testing guide
- `hive/daemon/TEST_SUMMARY.md` - Test architecture and status
- `hive/utils/utils_test.go` - Utils test implementation
- `hive/daemon/daemon_test.go` - Daemon test implementation
## ✅ Acceptance Criteria Met
- [x] Created `daemon_test.go` with NATS server setup
- [x] Implemented `handleEC2Launch` message tests
- [x] Created sample RunInstancesInput test cases
- [x] Validated expected arguments and error handling
- [x] Documented macOS limitations (viperblock, nbdkit)
- [x] Provided suggestions for scaffolding
- [x] Fixed existing bugs (pointer-to-pointer issues)
- [x] Created comprehensive utils tests
- [x] All tests passing with `make test`
- [x] Detailed documentation created
## 🏁 Conclusion
The test framework is now production-ready for unit testing on both macOS and Linux. The daemon EC2 launch functionality has comprehensive message handling tests, and the utils package is thoroughly tested. All critical bugs have been fixed, and the codebase is ready for further development.
**Next Steps:**
1. Implement mock services for full macOS testing
2. Add Linux-specific integration tests
3. Set up CI/CD pipeline with Docker
4. Expand coverage to volume lifecycle operations
-88
View File
@@ -1,88 +0,0 @@
# Hive Development Roadmap
This roadmap has been integrated into the comprehensive [HIVE_DEVELOPMENT_PLAN.md](HIVE_DEVELOPMENT_PLAN.md).
## Update Nov 2025
* Implement multi-tenant support
* Move config settings from ~/hive/*.toml, to using Nats Jetstream for core config which can be synced between nodes.
* Implement a lightweight IAM using Nats Jetstream, vs current config files for auth settings.
* Implement Reed Solomon Encoding for Predastore (S3) objects and a WAL implementation, for storing multiple objects in a single shard (e.g 4MB), with the WAL referencing the location of each object (e.g 4kb min)
* Implement basic KY lookup, object key sha512 (bucket/key), location to shard on S3 (obj.0000124.bin), read WAL (e.g first 4096 bytes) to determine location of the object. e.g key-1234 => obj.0000124.bin, wal header (4096 bytes) > key-1234 == offset location (seek) 16384, len 32768.
* Read multiple nodes (e.g 5 predastore instances, k = 3 (data shards), n = 5 (total shards), n - k = 2 (parity shards) )
* Complete core scaffolding AWS SDK/API requirements (ec2 describe-instances, run-instances, etc)
* Implement UEFI support for image downloads and `qemu` exec in `vm.go`
* Confirm Alpine Linux, fails import image AMI > (run-instance) ""Failed to read block from AMI source" err="request out of range" - Block size correct?
* Improve shutdown gracefully, `./scripts/stop-dev.sh` waits 60 seconds, while qemu/nbd could still be shutting down.
* Add delete-volume support via EBS (s3 vol-*) for terminated instance
* Add default LRU cache support for viperblock, depending on the instance type / volume size and system memory available.
## Original TODO Items → Development Plan Integration
### ✅ **Completed Integration**
All original TODO items have been incorporated into the structured development phases:
| Original TODO | Status | Integrated Into | Phase |
|---------------|--------|-----------------|-------|
| #1: Binary compile and install.sh | ✅ Planned | Phase 7: Task 7.1 (Binary Compilation) | Phase 7 |
| #2: Move daemon.go to services/hived/ | ✅ Planned | Phase 3: Task 3.2 (Specialized Services) | Phase 3 |
| #3: VPC with openvs-switch as `vpcd` | ✅ Planned | Phase 5: Task 5.1 (VPC with Open vSwitch) | Phase 5 |
| #4: AWS HTTP gateway (`awsd`) | ✅ Planned | Phase 3: Task 3.1 (AWS Gateway Service) | Phase 3 |
| #5: AWS SDK v2 input/output | ✅ Planned | Phase 2: Task 2.1 & Phase 3: Task 3.1 | Phase 2-3 |
| #6: `hive.service` for systemd | ✅ Planned | Phase 7: Task 7.2 (System Service Integration) | Phase 7 |
| #7: Gossip and RAFT protocols | ✅ Planned | Phase 0: Task 0.1 (Service Registry) | Phase 0 |
| #8: etcd/KV for configuration sync | ✅ Planned | Phase 0: Task 0.1 (Distributed Config) | Phase 0 |
| #9: Smithy model code generation | ✅ Planned | Phase 2: Task 2.1 (Smithy Code Generation) | Phase 2 |
## Development Phase Overview
**Phase 0**: Distributed Systems Foundation (1-2 weeks)
- Gossip, RAFT, and distributed configuration (#7, #8)
**Phase 1**: Development Environment Automation (2-3 weeks)
- Multi-service orchestration and hot reloading
**Phase 2**: AWS API Model Implementation (3-4 weeks)
- Smithy-based code generation (#9)
- AWS SDK v2 integration (#5)
**Phase 3**: Scalable Gateway and Daemon Architecture (2-3 weeks)
- AWS gateway service `awsd` (#4)
- Service refactoring to `hived` (#2)
- VPC daemon `vpcd` foundation (#3)
**Phase 4**: Service Integration and Orchestration (3-4 weeks)
- NATS clustering and cross-service coordination
**Phase 5**: Infrastructure Services (4-5 weeks)
- VPC networking with Open vSwitch (#3)
- Advanced VM features
**Phase 6**: Testing and Validation Framework (2-3 weeks)
- AWS CLI compatibility testing
**Phase 7**: Production Deployment and Packaging (2-3 weeks)
- Binary compilation and distribution (#1)
- Systemd service integration (#6)
- Production configuration management
## Service Architecture
The development plan implements these services:
- **`awsd`** - AWS API Gateway (TODO #4)
- **`hived`** - Main Hive compute daemon (TODO #2)
- **`vpcd`** - VPC networking daemon (TODO #3)
- **Predastore** - S3 service (existing)
- **Viperblock** - EBS service (existing)
## Getting Started
For development setup:
```bash
./scripts/dev-setup.sh # Setup complete development environment
./scripts/start-dev.sh # Start all services
```
For detailed implementation guidance, see [HIVE_DEVELOPMENT_PLAN.md](HIVE_DEVELOPMENT_PLAN.md).
+48
View File
@@ -0,0 +1,48 @@
ARG VERSION=dev
FROM golang:1.26-bookworm AS builder
ARG VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
nbdkit nbdkit-plugin-dev pkg-config gcc make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy module files for dependency caching
COPY spinifex/go.mod spinifex/go.sum ./spinifex/
COPY predastore/go.mod predastore/go.sum ./predastore/
COPY viperblock/go.mod viperblock/go.sum ./viperblock/
COPY viperblock/nbd/go.mod viperblock/nbd/go.sum ./viperblock/nbd/
COPY viperblock/nbd/libguestfs.org/nbdkit/go.mod ./viperblock/nbd/libguestfs.org/nbdkit/
# Generate go.work (gitignored, so not in checkout)
RUN cd spinifex && go work init . ../predastore ../viperblock ../viperblock/nbd ../viperblock/nbd/libguestfs.org/nbdkit
# Download dependencies (cached layer)
RUN cd spinifex && go mod download
# Copy full source
COPY spinifex/ ./spinifex/
COPY predastore/ ./predastore/
COPY viperblock/ ./viperblock/
# Build spinifex binary (spx)
RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
cd spinifex && go build -ldflags "-s -w \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=${VERSION} \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=${COMMIT}" \
-o /out/spx cmd/spinifex/main.go
# Build nbdkit viperblock plugin (.so)
RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
# Copy setup scripts and systemd units
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
FROM scratch AS export
COPY --from=builder /out/ /
+9
View File
@@ -0,0 +1,9 @@
/var/log/spinifex/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Spinifex AWS Gateway
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service awsgw start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
[Install]
WantedBy=spinifex.target
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service
Requires=spinifex-nats.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
[Install]
WantedBy=spinifex.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Spinifex NATS Message Bus
PartOf=spinifex.target
Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service nats start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
[Install]
WantedBy=spinifex.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Spinifex Predastore (S3-compatible Object Storage)
PartOf=spinifex.target
After=spinifex-nats.service
Requires=spinifex-nats.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service predastore start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.toml
Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
[Install]
WantedBy=spinifex.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Spinifex Web UI
PartOf=spinifex.target
After=spinifex-awsgw.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex-ui start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
[Install]
WantedBy=spinifex.target
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Spinifex Viperblock (Block Storage)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-predastore.service
Requires=spinifex-nats.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service viperblock start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
[Install]
WantedBy=spinifex.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Spinifex VPC Daemon (OVN Networking)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
[Service]
Type=simple
User=spinifex
Group=spinifex
ExecStart=/usr/local/bin/spx service vpcd start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
[Install]
WantedBy=spinifex.target
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
// alb-agent runs inside ALB VMs and manages HAProxy configuration.
//
// It connects to NATS, subscribes to config updates for its load balancer,
// writes HAProxy configs to disk, and reloads HAProxy on changes. It also
// responds to health pings so the daemon can verify the agent is alive.
//
// Usage:
//
// alb-agent --lb-id=lb-xxxxx --nats=nats://10.0.0.1:4222
//
// The LB ID defaults to the hostname (set by cloud-init to the LB ID).
// The NATS URL defaults to nats://127.0.0.1:4222.
package main
import (
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/mulgadc/spinifex/spinifex/albagent"
)
func main() {
var (
lbID string
natsURL string
)
hostname, _ := os.Hostname()
flag.StringVar(&lbID, "lb-id", hostname, "Load balancer ID (defaults to hostname)")
flag.StringVar(&natsURL, "nats", "nats://127.0.0.1:4222", "NATS server URL")
flag.Parse()
if lbID == "" {
slog.Error("--lb-id is required (or set hostname to the LB ID)")
os.Exit(1)
}
slog.Info("Starting ALB agent", "lbId", lbID, "nats", natsURL)
agent, err := albagent.New(lbID, natsURL)
if err != nil {
slog.Error("Failed to create agent", "err", err)
os.Exit(1)
}
if err := agent.Start(); err != nil {
slog.Error("Failed to start agent", "err", err)
os.Exit(1)
}
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
slog.Info("Received signal, shutting down", "signal", sig)
agent.Stop()
}
-830
View File
@@ -1,830 +0,0 @@
/*
Copyright © 2025 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
_ "embed"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path"
"path/filepath"
"sort"
"text/template"
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/utils"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/s3"
vutils "github.com/mulgadc/viperblock/viperblock/utils"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"gopkg.in/ini.v1"
)
//go:embed templates/hive.toml
var hiveTomlTemplate string
//go:embed templates/awsgw.toml
var awsgwTomlTemplate string
//go:embed templates/predastore.toml
var predastoreTomlTemplate string
//go:embed templates/nats.conf
var natsConfTemplate string
var supportedArchs = map[string]bool{
"x86_64": true,
"aarch64": true, // alias for arm64
"arm64": true,
}
// TODO: Confirm suppported platform types
var supportedPlatforms = map[string]bool{
"Linux/UNIX": true,
"Windows": true,
}
var adminCmd = &cobra.Command{
Use: "admin",
Short: "Administrative commands for Hive platform management",
Long: `Administrative commands for initializing and managing the Hive platform.`,
}
var adminInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize Hive platform configuration",
Long: `Initialize Hive platform by creating configuration files, generating SSL certificates,
and setting up AWS credentials. This creates the necessary directory structure and
configuration files in ~/hive/config.`,
Run: runAdminInit,
}
var imagesCmd = &cobra.Command{
Use: "images",
Short: "Manage OS images",
Long: `Manage OS images for local storage and AMI creation.`,
}
var imagesImportCmd = &cobra.Command{
Use: "import",
Short: "Specify local file to import",
Long: `Create a new image from a local file`,
Run: runimagesImportCmd,
}
var imagesListCmd = &cobra.Command{
Use: "list",
Short: "List OS images to import or download",
Long: `Query the remote endpoint for common OS images available for import as AMI or locally download.`,
Run: runimagesListCmd,
}
/*
CLI ideas
hive admin images list
- fetches from remote endpoint for common/trusted images to bootstrap environment, or baked in from compile.
// If --name specified, download
hive admin images import --name debian-12-x86_64
// List available images
hive admin images list
// Manually import a path
hive admin images import --file /path/to/image --distro debian --version 12 --arch x86_64
-> x <-
*/
func init() {
rootCmd.AddCommand(adminCmd)
adminCmd.AddCommand(adminInitCmd)
adminCmd.AddCommand(imagesCmd)
imagesCmd.AddCommand(imagesImportCmd)
imagesCmd.AddCommand(imagesListCmd)
homeDir, _ := os.UserHomeDir()
configDir := fmt.Sprintf("%s/hive/config", homeDir)
hiveDir := fmt.Sprintf("%s/hive/", homeDir)
rootCmd.PersistentFlags().String("config-dir", configDir, "Configuration directory")
rootCmd.PersistentFlags().String("hive-dir", hiveDir, "Hive base directory")
// Flags for admin init
adminInitCmd.Flags().Bool("force", false, "Force re-initialization (overwrites existing config)")
adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create")
imagesImportCmd.Flags().String("tmp-dir", os.TempDir(), "Temporary directory for image import processing")
imagesImportCmd.Flags().String("name", "", "Import specified image by name")
imagesImportCmd.Flags().String("file", "", "Import file from specified path (raw, qcow2, compressed)")
imagesImportCmd.Flags().String("distro", "", "Specified distro name (e.g debian)")
imagesImportCmd.Flags().String("version", "", "Specified distro version (e.g 12)")
imagesImportCmd.Flags().String("arch", "", "Specified distro arch (e.g aarch64, arm64, x86_64)")
imagesImportCmd.Flags().String("platform", "Linux/UNIX", "Specified platform (e.g Linux/UNIX, Windows)")
imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)")
}
func runimagesImportCmd(cmd *cobra.Command, args []string) {
var image utils.Images
var imageFile string
var imageStat os.FileInfo
var err error
cfgFile, _ := cmd.Flags().GetString("config")
forceCmd, _ := cmd.Flags().GetBool("force")
ostmpDir, _ := cmd.Flags().GetString("tmp-dir")
// Use default config path
if cfgFile == "" {
homeDir, _ := os.UserHomeDir()
cfgFile = fmt.Sprintf("%s/hive/config/hive.toml", homeDir)
}
//configDir, _ := cmd.Flags().GetString("config-dir")
baseDir, _ := cmd.Flags().GetString("hive-dir")
// Strip trailing slash
baseDir = filepath.Clean(baseDir)
// Check the base dir has our images path, and correctlty init
imageDir := fmt.Sprintf("%s/images", baseDir)
if !fileExists(imageDir) {
fmt.Fprintf(os.Stderr, "Image directory does not exist. Base path specified correctly? %s", imageDir)
os.Exit(1)
}
// Determine, if name specified, or file
imageName, _ := cmd.Flags().GetString("name")
if imageName != "" {
var exists bool
// Confirm the image exists
image, exists = utils.AvailableImages[imageName]
if !exists {
fmt.Fprintf(os.Stderr, "Image name not found in available images")
os.Exit(1)
}
} else {
imageFile, _ = cmd.Flags().GetString("file")
if imageFile == "" {
fmt.Fprintf(os.Stderr, "File required to import image")
os.Exit(1)
}
//imageStat, err = os.Stat(imageFile)
if err != nil {
fmt.Fprintf(os.Stderr, "File could not be found %s", err)
os.Exit(1)
}
image.Distro, _ = cmd.Flags().GetString("distro")
image.Version, _ = cmd.Flags().GetString("version")
image.Arch, _ = cmd.Flags().GetString("arch")
image.Platform, _ = cmd.Flags().GetString("platform")
}
if image.Distro == "" {
fmt.Fprintf(os.Stderr, "Specify distro name")
os.Exit(1)
}
// Check version specified
if image.Version == "" {
fmt.Fprintf(os.Stderr, "Specify image version")
os.Exit(1)
}
if !supportedArchs[image.Arch] {
fmt.Fprintf(os.Stderr, "Unsupported architecture")
os.Exit(1)
}
if !supportedPlatforms[image.Platform] {
fmt.Fprintf(os.Stderr, "Unsupported platform")
os.Exit(1)
}
// Create the specified image directory
imagePath := fmt.Sprintf("%s/%s/%s/%s", imageDir, image.Distro, image.Version, image.Arch)
// Create config directory
if err := os.MkdirAll(imagePath, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Created config directory: %s\n", imagePath)
// Next, if the file is selected to download, fetch it, extract disk image, and save to path
if imageName != "" {
// Download the file to the image path
filename := path.Base(image.URL)
imageFile = fmt.Sprintf("%s/%s", imagePath, filename)
fmt.Printf("Downloading image %s to %s\n", image.URL, imageFile)
// If image path exists, skip
if fileExists(imageFile) && !forceCmd {
fmt.Printf("Image file already exists, skipping download, use --force to overwrite: %s\n", imageFile)
} else {
err := utils.DownloadFileWithProgress(image.URL, image.Name, imageFile, 0)
if err != nil {
fmt.Printf("Download failed: %v\n", err)
os.Exit(1)
}
}
// Update image file path for later extraction
//imagePath = imageFilePath
}
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
tmpDir, err := os.MkdirTemp(ostmpDir, "hive-image-tmp-*")
if err != nil {
fmt.Fprintf(os.Stderr, "Could not create temp dir: %v\n", err)
os.Exit(1)
}
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
fmt.Println("Extracted image to:", extractedImagePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err)
os.Exit(1)
}
imageStat, err = os.Stat(extractedImagePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not stat image: %v\n", err)
os.Exit(1)
}
// Create the specified manifest file to describe the image/AMI
manifest := viperblock.VolumeConfig{}
// Calculate the size
manifest.AMIMetadata.Name = fmt.Sprintf("ami-%s-%s-%s", image.Distro, image.Version, image.Arch)
volumeId := viperblock.GenerateVolumeID("ami", manifest.AMIMetadata.Name, appConfig.Predastore.Bucket, time.Now().Unix())
manifest.AMIMetadata.ImageID = volumeId
manifest.AMIMetadata.Description = fmt.Sprintf("%s cloud image prepared for Hive", manifest.AMIMetadata.Name)
manifest.AMIMetadata.Architecture = image.Arch
manifest.AMIMetadata.PlatformDetails = image.Platform
manifest.AMIMetadata.CreationDate = time.Now()
manifest.AMIMetadata.RootDeviceType = "ebs"
manifest.AMIMetadata.Virtualization = "hvm"
manifest.AMIMetadata.ImageOwnerAlias = "system"
manifest.AMIMetadata.VolumeSizeGiB = uint64(imageStat.Size() / 1024 / 1024 / 1024)
// Volume Data
manifest.VolumeMetadata.VolumeID = volumeId // TODO: Confirm if unique, e.g vol-, if ami- used
manifest.VolumeMetadata.VolumeName = manifest.AMIMetadata.Name
manifest.VolumeMetadata.TenantID = "system"
manifest.VolumeMetadata.SizeGiB = manifest.AMIMetadata.VolumeSizeGiB
manifest.VolumeMetadata.State = "available"
manifest.VolumeMetadata.AvailabilityZone = "" // TODO: Confirm
manifest.VolumeMetadata.CreatedAt = time.Now()
manifest.VolumeMetadata.VolumeType = "gp3"
manifest.VolumeMetadata.IOPS = 1000
// Write the manifest to disk
// Save as JSON
jsonData, err := json.Marshal(manifest)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not marshal manifest: %v\n", err)
os.Exit(1)
}
manifestFilename := fmt.Sprintf("%s/%s.json", imagePath, manifest.AMIMetadata.Name)
// Write to file
err = os.WriteFile(manifestFilename, jsonData, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not write manifest: %v\n", err)
os.Exit(1)
}
// Upload the image to S3 (predastore)
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
s3Config := s3.S3Config{
VolumeName: volumeId,
VolumeSize: uint64(imageStat.Size()),
Bucket: appConfig.Predastore.Bucket,
Region: appConfig.Predastore.Region,
AccessKey: appConfig.AccessKey,
SecretKey: appConfig.SecretKey,
Host: appConfig.Predastore.Host,
}
vbConfig := viperblock.VB{
VolumeName: volumeId,
VolumeSize: uint64(imageStat.Size()),
BaseDir: tmpDir,
Cache: viperblock.Cache{
Config: viperblock.CacheConfig{
Size: 0,
},
},
VolumeConfig: manifest,
}
err = vutils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not import image to predastore: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(tmpDir)
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
}
// List remote images available
func runimagesListCmd(cmd *cobra.Command, args []string) {
//fmt.Println(availableImages)
tableData := pterm.TableData{
{"NAME", "DISTRO", "VERSION", "ARCH", "BOOT"},
}
// Sort A .. Z
// 1. Collect keys
keys := make([]string, 0, len(utils.AvailableImages))
for k := range utils.AvailableImages {
keys = append(keys, k)
}
// 2. Sort keys alphabetically (A→Z)
sort.Strings(keys)
// 3. Iterate in sorted order
for _, k := range keys {
img := utils.AvailableImages[k]
//for _, img := range utils.AvailableImages {
tableData = append(tableData, []string{img.Name, img.Distro, img.Version, img.Arch, img.BootMode})
}
// Create a table with the defined data.
// The table has a header and the text in the cells is right-aligned.
// The Render() method is used to print the table to the console.
pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(tableData).Render()
pterm.Println("To install a selected image as an AMI use:")
pterm.Println("hive admin images import --name <image-name>")
}
func runAdminInit(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
configDir, _ := cmd.Flags().GetString("config-dir")
region, _ := cmd.Flags().GetString("region")
// Default config directory
if configDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
configDir = filepath.Join(homeDir, "hive", "config")
}
fmt.Println("🚀 Initializing Hive platform...")
fmt.Printf("Configuration directory: %s\n\n", configDir)
// Check if already initialized
hiveTomlPath := filepath.Join(configDir, "hive.toml")
if !force && fileExists(hiveTomlPath) {
fmt.Println("⚠️ Hive already initialized!")
fmt.Printf("Config file exists: %s\n", hiveTomlPath)
fmt.Println("\nTo re-initialize, run with --force flag:")
fmt.Println(" hive admin init --force")
os.Exit(0)
}
// Create config directory
if err := os.MkdirAll(configDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Created config directory: %s\n", configDir)
// Generate AWS credentials
accessKey := generateAWSAccessKey()
secretKey := generateAWSSecretKey()
accountID := generateAccountID()
fmt.Println("\n🔑 Generated AWS credentials:")
fmt.Printf(" Access Key: %s\n", accessKey)
fmt.Printf(" Secret Key: %s\n", secretKey)
fmt.Printf(" Account ID: %s\n", accountID)
// Generate SSL certificates
certPath := filepath.Join(configDir, "server.pem")
keyPath := filepath.Join(configDir, "server.key")
if force || !fileExists(certPath) || !fileExists(keyPath) {
fmt.Println("\n🔐 Generating SSL certificates...")
if err := generateSelfSignedCert(certPath, keyPath); err != nil {
fmt.Fprintf(os.Stderr, "Error generating SSL certificates: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ SSL certificates generated:\n")
fmt.Printf(" Certificate: %s\n", certPath)
fmt.Printf(" Key: %s\n", keyPath)
} else {
fmt.Println("\n✅ SSL certificates already exist")
}
// Generate NATS token
natsToken := generateNATSToken()
fmt.Println("\n🔒 Generated NATS authentication token")
// Get home directory for data path
homeDir, _ := os.UserHomeDir()
hiveRoot := filepath.Join(homeDir, "hive")
// Create config files from embedded templates
fmt.Println("\n📝 Creating configuration files...")
// Create subdirectories
awsgwDir := filepath.Join(configDir, "awsgw")
predastoreDir := filepath.Join(configDir, "predastore")
natsDir := filepath.Join(configDir, "nats")
hiveDir := filepath.Join(configDir, "hive")
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, hiveDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err)
os.Exit(1)
}
}
// Generate all config files
configs := []struct {
path string
template string
name string
}{
{hiveTomlPath, hiveTomlTemplate, "hive.toml"},
{filepath.Join(awsgwDir, "awsgw.toml"), awsgwTomlTemplate, "awsgw/awsgw.toml"},
{filepath.Join(predastoreDir, "predastore.toml"), predastoreTomlTemplate, "predastore/predastore.toml"},
{filepath.Join(natsDir, "nats.conf"), natsConfTemplate, "nats/nats.conf"},
}
for _, cfg := range configs {
if err := generateConfigFile(cfg.path, cfg.template, accessKey, secretKey, accountID, region, natsToken, hiveRoot); err != nil {
fmt.Fprintf(os.Stderr, "Error creating %s: %v\n", cfg.name, err)
os.Exit(1)
}
fmt.Printf("✅ Created: %s\n", cfg.name)
}
// Update ~/.aws/credentials and ~/.aws/config
fmt.Println("\n🔧 Configuring AWS credentials...")
if err := setupAWSCredentials(accessKey, secretKey, region, certPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err)
} else {
fmt.Println("✅ AWS credentials configured")
}
// Create additional directories
dirs := []string{
filepath.Join(hiveRoot, "images"),
filepath.Join(hiveRoot, "amis"),
filepath.Join(hiveRoot, "volumes"),
filepath.Join(hiveRoot, "state"),
filepath.Join(hiveRoot, "logs"),
filepath.Join(hiveRoot, "nats"),
filepath.Join(hiveRoot, "predastore"),
filepath.Join(hiveRoot, "viperblock"),
}
fmt.Println("\n📁 Creating directory structure...")
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not create %s: %v\n", dir, err)
}
}
fmt.Printf("✅ Directory structure created in %s\n", hiveRoot)
// Print success message
fmt.Println("\n🎉 Hive initialization complete!")
fmt.Println("\n📋 Next steps:")
fmt.Println(" 1. Start services:")
fmt.Println(" ./scripts/start-dev.sh")
fmt.Println()
fmt.Println(" 2. Test with AWS CLI:")
fmt.Println(" export AWS_PROFILE=hive")
fmt.Println(" aws ec2 describe-instances")
fmt.Println()
fmt.Println("🔗 Configuration:")
fmt.Printf(" Config file: %s\n", hiveTomlPath)
fmt.Printf(" Data directory: %s\n", hiveRoot)
fmt.Println()
}
// generateAWSAccessKey generates an AWS-style access key
// Format: AKIA + 16 random uppercase alphanumeric characters
func generateAWSAccessKey() string {
const prefix = "AKIA"
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const length = 16
result := make([]byte, length)
for i := range result {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
result[i] = charset[num.Int64()]
}
return prefix + string(result)
}
// generateAWSSecretKey generates an AWS-style secret key
// 40 character base64-encoded string
func generateAWSSecretKey() string {
bytes := make([]byte, 30) // 30 bytes = 40 chars in base64
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(bytes)
}
// generateAccountID generates a 12-digit AWS account ID
func generateAccountID() string {
// Generate random 12-digit number
num, _ := rand.Int(rand.Reader, big.NewInt(900000000000))
accountID := num.Int64() + 100000000000 // Ensure it's 12 digits
return fmt.Sprintf("%012d", accountID)
}
// generateNATSToken generates a secure random token for NATS
func generateNATSToken() string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return "nats_" + base64.URLEncoding.EncodeToString(bytes)[:32]
}
// generateSelfSignedCert generates a self-signed SSL certificate
func generateSelfSignedCert(certPath, keyPath string) error {
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
// Create certificate template
notBefore := time.Now()
notAfter := notBefore.Add(3650 * 24 * time.Hour) // 10 years
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "localhost",
Organization: []string{"Hive Platform"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certOut, err := os.Create(certPath)
if err != nil {
return fmt.Errorf("failed to create cert file: %w", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return fmt.Errorf("failed to write cert: %w", err)
}
// Write private key to file
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyOut.Close()
privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to marshal private key: %w", err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return fmt.Errorf("failed to write key: %w", err)
}
return nil
}
// generateConfigFile creates a configuration file from a template
func generateConfigFile(path, templateContent, accessKey, secretKey, accountID, region, natsToken, dataDir string) error {
// Parse the embedded template
tmpl, err := template.New("config").Parse(templateContent)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
// Template data
data := struct {
AccessKey string
SecretKey string
AccountID string
Region string
NatsToken string
DataDir string
}{
AccessKey: accessKey,
SecretKey: secretKey,
AccountID: accountID,
Region: region,
NatsToken: natsToken,
DataDir: dataDir,
}
// Create file with secure permissions
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer f.Close()
// Execute template
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}
// setupAWSCredentials updates ~/.aws/credentials and ~/.aws/config
func setupAWSCredentials(accessKey, secretKey, region, certPath string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
awsDir := filepath.Join(homeDir, ".aws")
if err := os.MkdirAll(awsDir, 0700); err != nil {
return err
}
credPath := filepath.Join(awsDir, "credentials")
configPath := filepath.Join(awsDir, "config")
// Determine profile name
profileName := "default"
if fileExists(credPath) {
// Check if default profile already exists
cfg, err := ini.Load(credPath)
if err == nil && cfg.HasSection("default") {
profileName = "hive"
}
}
// Update credentials file
if err := updateAWSINIFile(credPath, profileName, map[string]string{
"aws_access_key_id": accessKey,
"aws_secret_access_key": secretKey,
}); err != nil {
return err
}
// Update config file
configSection := profileName
if profileName != "default" {
configSection = "profile " + profileName
}
if err := updateAWSINIFile(configPath, configSection, map[string]string{
"region": region,
"endpoint_url": "https://localhost:9999",
"ca_bundle": certPath,
"output": "json",
}); err != nil {
return err
}
fmt.Printf(" Profile: %s\n", profileName)
if profileName != "default" {
fmt.Printf(" Use: export AWS_PROFILE=%s\n", profileName)
}
return nil
}
// Helper functions
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// updateAWSINIFile updates or creates an AWS INI file section with given key-value pairs
func updateAWSINIFile(path, section string, values map[string]string) error {
var cfg *ini.File
var err error
// Load existing file or create new one
if fileExists(path) {
cfg, err = ini.Load(path)
if err != nil {
return fmt.Errorf("failed to load INI file: %w", err)
}
} else {
cfg = ini.Empty()
}
// Get or create section
sec, err := cfg.NewSection(section)
if err != nil {
// Section already exists, get it
sec, err = cfg.GetSection(section)
if err != nil {
return fmt.Errorf("failed to get section: %w", err)
}
}
// Set key-value pairs
for key, value := range values {
sec.Key(key).SetValue(value)
}
// Save with proper permissions
return cfg.SaveTo(path)
}
-655
View File
@@ -1,655 +0,0 @@
/*
Copyright © 2025 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"fmt"
"log/slog"
"os"
"strings"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/service"
"github.com/mulgadc/hive/hive/services/nats"
"github.com/mulgadc/hive/hive/services/predastore"
"github.com/mulgadc/hive/hive/services/viperblockd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Manage Hive services",
}
var predastoreCmd = &cobra.Command{
Use: "predastore",
Short: "Manage the predastore service",
}
var viperblockCmd = &cobra.Command{
Use: "viperblock",
Short: "Manage the viperblock service",
}
var natsCmd = &cobra.Command{
Use: "nats",
Short: "Manage the nats service",
}
var hiveCmd = &cobra.Command{
Use: "hive",
Short: "Manage the hive service",
}
var awsgwCmd = &cobra.Command{
Use: "awsgw",
Short: "Manage the awsgw (AWS gateway) service",
}
var predastoreStartCmd = &cobra.Command{
Use: "start",
Short: "Start the predastore service",
Run: func(cmd *cobra.Command, args []string) {
// Add your start logic here
fmt.Println("Starting predastore service...")
// Get the port from the flags
port := viper.GetInt("port")
host := viper.GetString("host")
basePath := viper.GetString("base-path")
debug := viper.GetBool("debug")
// Required, no default
if basePath == "" {
fmt.Println("Base path is not set")
return
}
configPath := viper.GetString("config-path")
if configPath == "" {
fmt.Println("Config path is not set")
return
}
tlsCert := viper.GetString("tls-cert")
if tlsCert == "" {
fmt.Println("TLS cert is not set")
return
}
tlsKey := viper.GetString("tls-key")
if tlsKey == "" {
fmt.Println("TLS key is not set")
return
}
service, err := service.New("predastore", &predastore.Config{
Port: port,
Host: host,
BasePath: basePath,
ConfigPath: configPath,
Debug: debug,
TlsCert: tlsCert,
TlsKey: tlsKey,
})
if err != nil {
fmt.Println("Error starting predastore service:", err)
return
}
service.Start()
fmt.Println("Predastore service started", service)
},
}
var predastoreStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the predastore service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping predastore service...")
service, err := service.New("predastore", &predastore.Config{})
if err != nil {
fmt.Println("Error stopping predastore service:", err)
return
}
service.Stop()
fmt.Println("Predastore service stopped")
},
}
var predastoreStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the predastore service",
Run: func(cmd *cobra.Command, args []string) {
// Add your status logic here
fmt.Println("Predastore service status: ...")
},
}
// Repeat for viperblock
var viperblockStartCmd = &cobra.Command{
Use: "start",
Short: "Start the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting viperblock service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
fmt.Println("Loading config from:", cfgFile)
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
natsHost := viper.GetString("nats-host")
if natsHost != "" {
fmt.Println("Overwriting natsHost:", natsHost)
appConfig.NATS.Host = natsHost
}
s3Host := viper.GetString("s3-host")
if s3Host != "" {
fmt.Println("Overwriting s3host:", s3Host)
appConfig.Predastore.Host = s3Host
}
s3Bucket := viper.GetString("s3-bucket")
if s3Bucket != "" {
fmt.Println("Overwriting s3bucket:", s3Bucket)
appConfig.Predastore.Bucket = s3Bucket
}
s3Region := viper.GetString("s3-region")
if s3Region != "" {
fmt.Println("Overwriting s3Region:", s3Region)
appConfig.Predastore.Region = s3Region
}
accessKey := viper.GetString("access-key")
if accessKey != "" {
fmt.Println("Overwriting access-key:", accessKey)
appConfig.Predastore.AccessKey = accessKey
}
secretKey := viper.GetString("secret-key")
if secretKey != "" {
fmt.Println("Overwriting secret-key:", secretKey)
appConfig.Predastore.SecretKey = secretKey
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting base-dir:", baseDir)
appConfig.Predastore.BaseDir = baseDir
}
pluginPath := viper.GetString("plugin-path")
if pluginPath == "" {
err := fmt.Errorf("plugin-path must be defined")
slog.Error(err.Error())
os.Exit(1)
}
// Check plugin path exists
if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
err := fmt.Errorf("plugin-path does not exist: %s", pluginPath)
slog.Error(err.Error())
os.Exit(1)
}
service, err := service.New("viperblock", &viperblockd.Config{
NatsHost: appConfig.NATS.Host,
PluginPath: pluginPath,
S3Host: appConfig.Predastore.Host,
Bucket: appConfig.Predastore.Bucket,
Region: appConfig.Predastore.Region,
AccessKey: appConfig.Predastore.AccessKey,
SecretKey: appConfig.Predastore.SecretKey,
BaseDir: appConfig.Predastore.BaseDir,
})
if err != nil {
fmt.Println("Error starting viperblock service:", err)
return
}
_, err = service.Start()
if err != nil {
fmt.Println("Error starting viperblock service:", err)
return
}
fmt.Println("Viperblock service started", service)
},
}
var viperblockStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping viperblock service...")
service, err := service.New("viperblock", &viperblockd.Config{})
if err != nil {
fmt.Println("Error stopping viperblock service:", err)
return
}
service.Stop()
fmt.Println("Viperblock service stopped")
},
}
var viperblockStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Viperblock service status: ...")
},
}
// Repeat for nats
var natsStartCmd = &cobra.Command{
Use: "start",
Short: "Start the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting nats service...")
port := viper.GetInt("port")
host := viper.GetString("host")
debug := viper.GetBool("debug")
dataDir := viper.GetString("data-dir")
jetStream := viper.GetBool("jetstream")
service, err := service.New("nats", &nats.Config{
Port: port,
Host: host,
Debug: debug,
DataDir: dataDir,
JetStream: jetStream,
})
if err != nil {
fmt.Println("Error starting nats service:", err)
return
}
service.Start()
fmt.Println("NATS service started")
},
}
var natsStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping nats service...")
service, err := service.New("nats", &nats.Config{})
if err != nil {
fmt.Println("Error stopping nats service:", err)
return
}
service.Stop()
fmt.Println("Nats service stopped")
},
}
var natsStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Nats service status: ...")
},
}
// Repeat for hive
var hiveStartCmd = &cobra.Command{
Use: "start",
Short: "Start the hive service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting hive service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
// Overwrite defaults (CLI first, config second, env third)
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting base-dir to:", baseDir)
appConfig.BaseDir = baseDir
}
// Overwrite defaults (CLI first, config second, env third)
walDir := viper.GetString("wal-dir")
if walDir != "" {
fmt.Println("Overwriting wal-dir to:", walDir)
appConfig.WalDir = walDir
}
service, err := service.New("hive", appConfig)
if err != nil {
fmt.Println("Error starting hive service:", err)
return
}
service.Start()
fmt.Println("HIVE service started")
},
}
var hiveStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the hive service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping hive service...")
service, err := service.New("hive", &config.Config{})
if err != nil {
fmt.Println("Error stopping hive service:", err)
return
}
service.Stop()
fmt.Println("Hive service stopped")
},
}
var hiveStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the hive service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Hive service status: ...")
},
}
// AWS GW
var awsgwStartCmd = &cobra.Command{
Use: "start",
Short: "Start the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting awsgw service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
fmt.Println("Loading config from:", cfgFile)
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
// Overwrite defaults (CLI first, config second, env third)
awsgwHost := viper.GetString("host")
if awsgwHost != "" {
fmt.Println("Overwriting awsgw host to:", awsgwHost)
appConfig.AWSGW.Host = awsgwHost
}
awsgwTlsCert := viper.GetString("tls-cert")
if awsgwTlsCert != "" {
fmt.Println("Overwriting awsgw tls-cert to:", awsgwTlsCert)
appConfig.AWSGW.TLSCert = awsgwTlsCert
}
awsgwTlsKey := viper.GetString("tls-key")
if awsgwTlsKey != "" {
fmt.Println("Overwriting awsgw tls-key to:", awsgwTlsKey)
appConfig.AWSGW.TLSKey = awsgwTlsKey
}
baseDir := viper.GetString("base-dir")
if awsgwTlsKey != "" {
fmt.Println("Overwriting awsgw base-dir to:", baseDir)
appConfig.BaseDir = baseDir
}
service, err := service.New("awsgw", appConfig)
if err != nil {
fmt.Println("Error starting awsgw service:", err)
return
}
service.Start()
fmt.Println("AWSGW service started")
},
}
var awsgwStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping awsgw service...")
service, err := service.New("awsgw", &config.Config{})
if err != nil {
fmt.Println("Error stopping awsgw service:", err)
return
}
service.Stop()
fmt.Println("AWSGW service stopped")
},
}
var awsgwStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("AWSGW service status: ...")
},
}
func init() {
viper.SetEnvPrefix("HIVE") // Prefix for environment variables
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv() // Read environment variables automatically
rootCmd.AddCommand(serviceCmd)
serviceCmd.AddCommand(predastoreCmd)
// Predastore Port
predastoreCmd.PersistentFlags().Int("port", 8443, "Predastore (S3) port")
viper.BindEnv("port", "HIVE_PREDASTORE_PORT")
viper.BindPFlag("port", predastoreCmd.PersistentFlags().Lookup("port"))
// Predastore Host
predastoreCmd.PersistentFlags().String("host", "0.0.0.0", "Predastore (S3) host")
viper.BindEnv("host", "HIVE_PREDASTORE_HOST")
viper.BindPFlag("host", predastoreCmd.PersistentFlags().Lookup("host"))
// Base path
predastoreCmd.PersistentFlags().String("base-path", "", "Predastore (S3) base path")
viper.BindEnv("base-path", "HIVE_PREDASTORE_BASE_PATH")
viper.BindPFlag("base-path", predastoreCmd.PersistentFlags().Lookup("base-path"))
// Predastore Config Path
predastoreCmd.PersistentFlags().String("config-path", "", "Predastore (S3) config path")
viper.BindEnv("config-path", "HIVE_PREDASTORE_CONFIG_PATH")
viper.BindPFlag("config-path", predastoreCmd.PersistentFlags().Lookup("config-path"))
// Predastore Debug
predastoreCmd.PersistentFlags().Bool("debug", false, "Predastore (S3) debug")
viper.BindEnv("debug", "HIVE_PREDASTORE_DEBUG")
viper.BindPFlag("debug", predastoreCmd.PersistentFlags().Lookup("debug"))
// Predastore TLS Cert
predastoreCmd.PersistentFlags().String("tls-cert", "", "Predastore (S3) TLS certificate")
viper.BindEnv("tls-cert", "HIVE_PREDASTORE_TLS_CERT")
viper.BindPFlag("tls-cert", predastoreCmd.PersistentFlags().Lookup("tls-cert"))
// Predastore TLS Key
predastoreCmd.PersistentFlags().String("tls-key", "", "Predastore (S3) TLS key")
viper.BindEnv("tls-key", "HIVE_PREDASTORE_TLS_KEY")
viper.BindPFlag("tls-key", predastoreCmd.PersistentFlags().Lookup("tls-key"))
predastoreCmd.AddCommand(predastoreStartCmd)
predastoreCmd.AddCommand(predastoreStopCmd)
predastoreCmd.AddCommand(predastoreStatusCmd)
serviceCmd.AddCommand(viperblockCmd)
viperblockCmd.PersistentFlags().String("s3-host", "0.0.0.0:8443", "Predastore (S3) host URI")
viper.BindEnv("s3-host", "HIVE_VIPERBLOCK_S3_HOST")
viper.BindPFlag("s3-host", predastoreCmd.PersistentFlags().Lookup("s3-host"))
viperblockCmd.PersistentFlags().String("s3-bucket", "predastore", "Predastore (S3) bucket")
viper.BindEnv("s3-bucket", "HIVE_VIPERBLOCK_S3_BUCKET")
viper.BindPFlag("s3-bucket", predastoreCmd.PersistentFlags().Lookup("s3-bucket"))
viperblockCmd.PersistentFlags().String("s3-region", "ap-southeast-2", "Predastore (S3) region")
viper.BindEnv("s3-region", "HIVE_VIPERBLOCK_S3_REGION")
viper.BindPFlag("s3-region", predastoreCmd.PersistentFlags().Lookup("s3-region"))
viperblockCmd.PersistentFlags().String("plugin-path", "/opt/hive/lib/nbdkit-viperblock-plugin.so", "Pathname to the nbdkit viperblockplugin")
viper.BindEnv("plugin-path", "HIVE_VIPERBLOCK_PLUGIN_PATH")
viper.BindPFlag("plugin-path", predastoreCmd.PersistentFlags().Lookup("plugin-path"))
viperblockCmd.AddCommand(viperblockStartCmd)
viperblockCmd.AddCommand(viperblockStopCmd)
viperblockCmd.AddCommand(viperblockStatusCmd)
// Nats
serviceCmd.AddCommand(natsCmd)
natsCmd.AddCommand(natsStartCmd)
natsCmd.AddCommand(natsStopCmd)
natsCmd.AddCommand(natsStatusCmd)
// Add NATS flags
natsCmd.PersistentFlags().Int("port", 4222, "NATS server port")
viper.BindEnv("port", "HIVE_NATS_PORT")
viper.BindPFlag("port", natsCmd.PersistentFlags().Lookup("port"))
natsCmd.PersistentFlags().String("host", "0.0.0.0", "NATS server host")
viper.BindEnv("host", "HIVE_NATS_HOST")
viper.BindPFlag("host", natsCmd.PersistentFlags().Lookup("host"))
natsCmd.PersistentFlags().Bool("debug", false, "Enable debug logging")
viper.BindEnv("debug", "HIVE_NATS_DEBUG")
viper.BindPFlag("debug", natsCmd.PersistentFlags().Lookup("debug"))
natsCmd.PersistentFlags().String("data-dir", "", "NATS data directory")
viper.BindEnv("data-dir", "HIVE_NATS_DATA_DIR")
viper.BindPFlag("data-dir", natsCmd.PersistentFlags().Lookup("data-dir"))
natsCmd.PersistentFlags().Bool("jetstream", false, "Enable JetStream")
viper.BindEnv("jetstream", "HIVE_NATS_JETSTREAM")
viper.BindPFlag("jetstream", natsCmd.PersistentFlags().Lookup("jetstream"))
// Hive
serviceCmd.AddCommand(hiveCmd)
hiveCmd.AddCommand(hiveStartCmd)
hiveCmd.AddCommand(hiveStopCmd)
hiveCmd.AddCommand(hiveStatusCmd)
hiveCmd.PersistentFlags().String("wal-dir", "", "Write-ahead log (WAL) directory. Place on high-speed NVMe disk, or tmpfs for development.")
viper.BindEnv("wal-dir", "HIVE_WAL_DIR")
viper.BindPFlag("wal-dir", hiveCmd.PersistentFlags().Lookup("wal-dir"))
// AWS GW
serviceCmd.AddCommand(awsgwCmd)
awsgwCmd.PersistentFlags().String("host", "0.0.0.0:9999", "AWS Gateway server host")
viper.BindEnv("host", "HIVE_AWSGW_HOST")
viper.BindPFlag("host", awsgwCmd.PersistentFlags().Lookup("host"))
// AWS GW TLS Cert
awsgwCmd.PersistentFlags().String("tls-cert", "", "AWS Gateway TLS certificate")
viper.BindEnv("tls-cert", "HIVE_AWSGW_TLS_CERT")
viper.BindPFlag("tls-cert", awsgwCmd.PersistentFlags().Lookup("tls-cert"))
// AWS GW TLS Key
awsgwCmd.PersistentFlags().String("tls-key", "", "AWS Gateway TLS key")
viper.BindEnv("tls-key", "HIVE_AWSGW_TLS_KEY")
viper.BindPFlag("tls-key", awsgwCmd.PersistentFlags().Lookup("tls-key"))
awsgwCmd.PersistentFlags().Bool("debug", false, "AWS Gateway Debug")
viper.BindEnv("debug", "HIVE_AWSGW_DEBUG")
viper.BindPFlag("debug", awsgwCmd.PersistentFlags().Lookup("debug"))
awsgwCmd.AddCommand(awsgwStartCmd)
awsgwCmd.AddCommand(awsgwStopCmd)
awsgwCmd.AddCommand(awsgwStatusCmd)
}
-13
View File
@@ -1,13 +0,0 @@
# AWS Gateway configuration file in TOML format.
version = "1.0"
region = "{{.Region}}"
# Enable verbose debugging
debug = false
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
-34
View File
@@ -1,34 +0,0 @@
# Domain configuration file in TOML format.
version = "1.0"
region = "{{.Region}}"
account_id = "{{.AccountID}}"
access_key = "{{.AccessKey}}"
secret_key = "{{.SecretKey}}"
[daemon]
host = "0.0.0.0:4432"
tlskey = "config/server.key"
tlscert = "config/server.pem"
[awsgw]
host = "0.0.0.0:9999"
tlskey = "config/server.key"
tlscert = "config/server.pem"
debug = true
config = "awsgw/awsgw.toml"
[nats]
host = "0.0.0.0:4222"
[nats.acl]
token = "{{.NatsToken}}"
[nats.sub]
[predastore]
host = "0.0.0.0:8443"
region = "{{.Region}}"
bucket = "predastore"
access_key = "{{.AccessKey}}"
secret_key = "{{.SecretKey}}"
-32
View File
@@ -1,32 +0,0 @@
# NATS Server Configuration
# Generated by Hive admin init
# Server configuration
port: 4222
server_name: hive-nats
# Authorization
authorization {
token: "{{.NatsToken}}"
}
# JetStream for persistent messaging
jetstream {
store_dir: "{{.DataDir}}/nats/storage"
max_memory_store: 1GB
max_file_store: 10GB
}
# Logging
debug: false
trace: false
logtime: true
log_file: "{{.DataDir}}/logs/nats.log"
# Limits
max_connections: 1000
max_payload: 1MB
max_pending: 64MB
# Monitoring
http_port: 8222
-28
View File
@@ -1,28 +0,0 @@
# Predastore S3-compatible storage configuration file in TOML format.
version = "1.0"
region = "{{.Region}}"
# Optionally configure port and host, or specify via daemon CLI options
host = "0.0.0.0"
port = 8443
# Enable verbose debugging
debug = false
# Optionally configure base-path if full directory path not defined for "fs" buckets
# base_path = "/home/predastore/data/"
[[buckets]]
name = "predastore"
region = "{{.Region}}"
type = "fs"
pathname = "data/predastore"
public = false
encryption = ""
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
-22
View File
@@ -1,22 +0,0 @@
/*
Copyright © 2025 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import "github.com/mulgadc/hive/cmd/hive/cmd"
func main() {
cmd.Execute()
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/nats-io/nats.go"
"github.com/spf13/cobra"
)
// runClusterShutdown orchestrates a phased, coordinated shutdown of the cluster.
func runClusterShutdown(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
timeout, _ := cmd.Flags().GetDuration("timeout")
dryRun, _ := cmd.Flags().GetBool("dry-run")
cfg, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
nodeCount := len(cfg.Nodes)
phases := []string{"gate", "drain", "storage", "persist", "infra"}
if dryRun {
fmt.Println("Cluster Shutdown Plan (dry-run)")
fmt.Printf(" Nodes: %d\n", nodeCount)
for name, nodeCfg := range cfg.Nodes {
fmt.Printf(" - %s (services: %s)\n", name, strings.Join(nodeCfg.GetServices(), ", "))
}
fmt.Printf(" Phases: %s\n", strings.Join(phases, " -> "))
fmt.Printf(" Timeout per phase: %s\n", timeout)
fmt.Printf(" Force: %v\n", force)
fmt.Println("\nPhase details:")
fmt.Println(" 1. GATE - Stop API gateway and UI, reject new work")
fmt.Println(" 2. DRAIN - Gracefully stop all VMs, persist state")
fmt.Println(" 3. STORAGE - Stop viperblock, cleanup nbdkit")
fmt.Println(" 4. PERSIST - Stop predastore")
fmt.Println(" 5. INFRA - Stop NATS, exit daemons")
return
}
fmt.Printf("Starting coordinated cluster shutdown (%d nodes)\n", nodeCount)
fmt.Printf("Phases: %s\n", strings.Join(phases, " -> "))
fmt.Printf("Timeout per phase: %s\n\n", timeout)
// Write cluster shutdown marker to KV
jsm, err := daemon.NewJetStreamManager(nc, 1)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create JetStream manager: %v\n", err)
} else {
if err := jsm.InitClusterStateBucket(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to init cluster state bucket: %v\n", err)
} else {
state := &daemon.ClusterShutdownState{
Initiator: cfg.Node,
Phase: "starting",
Started: time.Now().UTC().Format(time.RFC3339),
Timeout: timeout.String(),
Force: force,
NodesTotal: nodeCount,
NodesAcked: make(map[string]string),
}
if err := jsm.WriteClusterShutdown(state); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to write shutdown state: %v\n", err)
}
}
}
start := time.Now()
// Execute phases sequentially (except INFRA which is fire-and-forget)
for _, phase := range phases {
topic := "spinifex.cluster.shutdown." + phase
req := daemon.ShutdownRequest{
Phase: phase,
Force: force,
Timeout: int(timeout.Seconds()),
}
reqData, err := json.Marshal(req)
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling request: %v\n", err)
os.Exit(1)
}
if phase == "infra" {
// INFRA is fire-and-forget — NATS is going down, no ACKs possible
fmt.Printf("[INFRA] Sending final shutdown to all nodes...\n")
if err := nc.Publish(topic, reqData); err != nil {
fmt.Fprintf(os.Stderr, "Error publishing infra shutdown: %v\n", err)
}
nc.Flush()
// Wait briefly for messages to propagate
time.Sleep(2 * time.Second)
fmt.Printf("[INFRA] Complete\n")
break
}
// For DRAIN phase, subscribe to progress updates
var progressSub *nats.Subscription
if phase == "drain" {
progressSub, err = nc.Subscribe("spinifex.cluster.shutdown.progress", func(msg *nats.Msg) {
var progress daemon.ShutdownProgress
if err := json.Unmarshal(msg.Data, &progress); err != nil {
return
}
fmt.Printf(" [%s] %s: %d/%d VMs remaining\n", strings.ToUpper(phase), progress.Node, progress.Remaining, progress.Total)
})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to subscribe to progress: %v\n", err)
}
}
// Collect ACKs from all nodes
phaseStart := time.Now()
fmt.Printf("[%s] Sending to %d node(s)...\n", strings.ToUpper(phase), nodeCount)
acks, err := collectShutdownACKs(nc, topic, reqData, nodeCount, timeout)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", strings.ToUpper(phase), err)
if !force {
fmt.Fprintf(os.Stderr, "Aborting shutdown. Use --force to continue despite errors.\n")
os.Exit(1)
}
}
// Unsubscribe from progress
if progressSub != nil {
progressSub.Unsubscribe()
}
// Print results
for _, ack := range acks {
if ack.Error != "" {
fmt.Printf(" [%s] %s: ERROR - %s\n", strings.ToUpper(phase), ack.Node, ack.Error)
} else if len(ack.Stopped) > 0 {
fmt.Printf(" [%s] %s: stopped %s\n", strings.ToUpper(phase), ack.Node, strings.Join(ack.Stopped, ", "))
} else {
fmt.Printf(" [%s] %s: OK\n", strings.ToUpper(phase), ack.Node)
}
}
ackedCount := len(acks)
if ackedCount < nodeCount && !force {
fmt.Fprintf(os.Stderr, "[%s] Only %d/%d nodes responded. Use --force to continue.\n",
strings.ToUpper(phase), ackedCount, nodeCount)
os.Exit(1)
}
fmt.Printf("[%s] Complete (%d/%d nodes, %s)\n\n", strings.ToUpper(phase), ackedCount, nodeCount, time.Since(phaseStart).Round(time.Millisecond))
}
fmt.Printf("Cluster shutdown complete (%s)\n", time.Since(start).Round(time.Millisecond))
}
// collectShutdownACKs publishes a shutdown request and collects ACKs from nodes.
func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount int, timeout time.Duration) ([]daemon.ShutdownACK, error) {
inbox := nats.NewInbox()
sub, err := nc.SubscribeSync(inbox)
if err != nil {
return nil, fmt.Errorf("failed to subscribe to inbox: %w", err)
}
defer sub.Unsubscribe()
if err := nc.PublishRequest(topic, inbox, reqData); err != nil {
return nil, fmt.Errorf("failed to publish request: %w", err)
}
nc.Flush()
var acks []daemon.ShutdownACK
deadline := time.Now().Add(timeout)
for len(acks) < nodeCount {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
msg, err := sub.NextMsg(remaining)
if err != nil {
break
}
var ack daemon.ShutdownACK
if err := json.Unmarshal(msg.Data, &ack); err != nil {
continue
}
acks = append(acks, ack)
}
return acks, nil
}
+307
View File
@@ -0,0 +1,307 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var getCmd = &cobra.Command{
Use: "get",
Short: "Display cluster resources",
Long: `Display cluster resources such as nodes and VMs.`,
}
var getNodesCmd = &cobra.Command{
Use: "nodes",
Short: "Display cluster nodes",
Long: `Display all physical nodes in the cluster with status, IP, region, and services.`,
Run: runGetNodes,
}
var getVMsCmd = &cobra.Command{
Use: "vms",
Aliases: []string{"instances"},
Short: "Display VMs across the cluster",
Long: `Display all VMs running across the cluster with instance type, resources, and placement.`,
Run: runGetVMs,
}
func init() {
rootCmd.AddCommand(getCmd)
getCmd.AddCommand(getNodesCmd)
getCmd.AddCommand(getVMsCmd)
getCmd.PersistentFlags().Duration("timeout", 3*time.Second, "Timeout for collecting responses from nodes")
}
// loadConfigAndConnect loads the cluster config and connects to NATS.
func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
cfgPath := viper.GetString("config")
if cfgPath == "" {
cfgPath = DefaultConfigFile()
}
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load config: %w", err)
}
// Backfill BaseDir from config file path when not set in the config.
// Config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
if nodeConfig, ok := cfg.Nodes[cfg.Node]; ok && nodeConfig.BaseDir == "" {
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
cfg.Nodes[cfg.Node] = nodeConfig
}
nodeConfig := cfg.Nodes[cfg.Node]
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
return cfg, nc, nil
}
// collectResponses publishes to a fan-out topic and collects all responses within the timeout.
func collectResponses(nc *nats.Conn, topic string, timeout time.Duration) ([][]byte, error) {
inbox := nats.NewInbox()
sub, err := nc.SubscribeSync(inbox)
if err != nil {
return nil, fmt.Errorf("failed to subscribe to inbox: %w", err)
}
defer sub.Unsubscribe()
if err := nc.PublishRequest(topic, inbox, nil); err != nil {
return nil, fmt.Errorf("failed to publish request: %w", err)
}
nc.Flush()
var responses [][]byte
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
msg, err := sub.NextMsg(remaining)
if err != nil {
break // timeout or other error — done collecting
}
responses = append(responses, msg.Data)
}
return responses, nil
}
func formatUptime(seconds int64) string {
if seconds <= 0 {
return "-"
}
d := time.Duration(seconds) * time.Second
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd%dh", days, hours)
}
if hours > 0 {
return fmt.Sprintf("%dh%dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
func formatRoles(resp types.NodeStatusResponse) string {
var roles []string
if resp.NATSRole != "" {
roles = append(roles, "nats:"+resp.NATSRole)
}
if resp.PredastoreRole != "" {
roles = append(roles, "predastore:"+resp.PredastoreRole)
}
if len(roles) == 0 {
return "-"
}
return strings.Join(roles, ",")
}
func formatMemGB(gb float64) string {
if gb >= 1.0 {
return fmt.Sprintf("%.1fGi", gb)
}
return fmt.Sprintf("%dMi", int(gb*1024))
}
func runGetNodes(cmd *cobra.Command, args []string) {
cfg, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "spinifex.node.status", timeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Parse responses into a map by node name
respondedNodes := make(map[string]types.NodeStatusResponse)
for _, data := range responses {
var resp types.NodeStatusResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
respondedNodes[resp.Node] = resp
}
// Build table: union of config-known nodes + NATS responders
tableData := pterm.TableData{
{"NAME", "STATUS", "ROLES", "IP", "REGION", "AZ", "UPTIME", "VMs", "SERVICES"},
}
// Collect all node names: config + responded (union)
nodeSet := make(map[string]struct{})
for name := range cfg.Nodes {
nodeSet[name] = struct{}{}
}
for name := range respondedNodes {
nodeSet[name] = struct{}{}
}
nodeNames := make([]string, 0, len(nodeSet))
for name := range nodeSet {
nodeNames = append(nodeNames, name)
}
sort.Strings(nodeNames)
for _, name := range nodeNames {
nodeCfg := cfg.Nodes[name]
if resp, ok := respondedNodes[name]; ok {
tableData = append(tableData, []string{
resp.Node,
resp.Status,
formatRoles(resp),
resp.Host,
resp.Region,
resp.AZ,
formatUptime(resp.Uptime),
fmt.Sprintf("%d", resp.VMCount),
strings.Join(resp.Services, ","),
})
} else {
tableData = append(tableData, []string{
name,
"NotReady",
"-",
nodeCfg.Host,
nodeCfg.Region,
nodeCfg.AZ,
"-",
"-",
strings.Join(nodeCfg.GetServices(), ","),
})
}
}
pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(tableData).Render()
}
func runGetVMs(cmd *cobra.Command, args []string) {
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "spinifex.node.vms", timeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
type vmRow struct {
types.VMInfo
Node string
Host string
}
var allVMs []vmRow
for _, data := range responses {
var resp types.NodeVMsResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
for _, v := range resp.VMs {
allVMs = append(allVMs, vmRow{VMInfo: v, Node: resp.Node, Host: resp.Host})
}
}
if len(allVMs) == 0 {
fmt.Println("No VMs found.")
return
}
// Sort by node then instance ID
sort.Slice(allVMs, func(i, j int) bool {
if allVMs[i].Node != allVMs[j].Node {
return allVMs[i].Node < allVMs[j].Node
}
return allVMs[i].InstanceID < allVMs[j].InstanceID
})
tableData := pterm.TableData{
{"INSTANCE", "STATUS", "TYPE", "VCPU", "MEM", "NODE", "IP", "AGE"},
}
for _, v := range allVMs {
age := "-"
if v.LaunchTime > 0 {
age = formatUptime(time.Now().Unix() - v.LaunchTime)
}
tableData = append(tableData, []string{
v.InstanceID,
v.Status,
v.InstanceType,
fmt.Sprintf("%d", v.VCPU),
formatMemGB(v.MemoryGB),
v.Node,
v.Host,
age,
})
}
pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(tableData).Render()
}
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/stretchr/testify/assert"
)
func TestFormatRoles(t *testing.T) {
tests := []struct {
name string
resp types.NodeStatusResponse
want string
}{
{
name: "no roles",
resp: types.NodeStatusResponse{},
want: "-",
},
{
name: "nats leader only",
resp: types.NodeStatusResponse{NATSRole: "leader"},
want: "nats:leader",
},
{
name: "predastore follower only",
resp: types.NodeStatusResponse{PredastoreRole: "follower"},
want: "predastore:follower",
},
{
name: "both roles",
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "follower"},
want: "nats:leader,predastore:follower",
},
{
name: "both leaders",
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "leader"},
want: "nats:leader,predastore:leader",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatRoles(tt.resp)
assert.Equal(t, tt.want, got)
})
}
}
+57
View File
@@ -0,0 +1,57 @@
package cmd
import (
"os"
"os/user"
"path/filepath"
)
// DefaultConfigDir returns the default configuration directory.
// Production: /etc/spinifex (when running as root AND /etc/spinifex exists)
// Development: ~/spinifex/config
func DefaultConfigDir() string {
if isProductionLayout() {
return "/etc/spinifex"
}
return filepath.Join(realUserHomeDir(), "spinifex", "config")
}
// DefaultDataDir returns the default data directory.
// Production: /var/lib/spinifex
// Development: ~/spinifex
func DefaultDataDir() string {
if isProductionLayout() {
return "/var/lib/spinifex"
}
return filepath.Join(realUserHomeDir(), "spinifex")
}
// realUserHomeDir returns the home directory of the real (non-sudo) user.
// When running under sudo, SUDO_USER is set to the invoking user — resolve
// their home directory so config/data land in the right place.
func realUserHomeDir() string {
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
if u, err := user.Lookup(sudoUser); err == nil {
return u.HomeDir
}
}
homeDir, _ := os.UserHomeDir()
return homeDir
}
// DefaultConfigFile returns the default path to spinifex.toml.
func DefaultConfigFile() string {
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
}
// isProductionLayout returns true when running in a production install.
// Detected by: running as root AND /etc/spinifex directory already exists
// (created by setup.sh during binary install).
func isProductionLayout() bool {
if os.Getuid() == 0 {
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
return true
}
}
return false
}
+57
View File
@@ -0,0 +1,57 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigDir_Dev(t *testing.T) {
// Non-root user without /etc/spinifex should get dev paths
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config")
got := DefaultConfigDir()
if got != expected {
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
}
}
func TestDefaultDataDir_Dev(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex")
got := DefaultDataDir()
if got != expected {
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
}
}
func TestDefaultConfigFile(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
got := DefaultConfigFile()
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
if got != expected {
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
}
}
func TestIsProductionLayout_NonRoot(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
if isProductionLayout() {
t.Error("isProductionLayout() = true for non-root user, want false")
}
}
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
@@ -19,32 +20,30 @@ import (
"fmt"
"os"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
appConfig *config.Config
cfgFile string
//lint:ignore U1000 fixing config loading later
appConfig *config.ClusterConfig
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "hive",
Short: "Hive - Open source AWS-compatible platform for secure edge deployments",
Use: "spx",
Short: "Spinifex - Open source AWS-compatible platform for secure edge deployments",
Long: `
__/\\\________/\\\__/\\\\\\\\\\\__/\\\________/\\\__/\\\\\\\\\\\\\\\_
_\/\\\_______\/\\\_\/////\\\///__\/\\\_______\/\\\_\/\\\///////////__
_\/\\\_______\/\\\_____\/\\\_____\//\\\______/\\\__\/\\\_____________
_\/\\\\\\\\\\\\\\\_____\/\\\______\//\\\____/\\\___\/\\\\\\\\\\\_____
_\/\\\/////////\\\_____\/\\\_______\//\\\__/\\\____\/\\\///////______
_\/\\\_______\/\\\_____\/\\\________\//\\\/\\\_____\/\\\_____________
_\/\\\_______\/\\\_____\/\\\_________\//\\\\\______\/\\\_____________
_\/\\\_______\/\\\__/\\\\\\\\\\\______\//\\\_______\/\\\\\\\\\\\\\\\_
_\///________\///__\///////////________\///________\///////////////__
Hive – Open source AWS-compatible platform for secure edge deployments.
_____ _____ _____ _ _ _____ ______ ________ __
/ ____| __ \_ _| \ | |_ _| ____| ____\ \ / /
| (___ | |__) || | | \| | | | | |__ | |__ \ V /
\___ \| ___/ | | | . ‘ | | | | __| | __| > <
____) | | _| |_| |\ |_| |_| | | |____ / . \
|_____/|_| |_____|_| \_|_____|_| |______/_/ \_\
Spinifex – Open source AWS-compatible platform for secure edge deployments.
Run EC2, VPC, S3, and EBS-like services on bare metal with full control.
Built for environments where running in the cloud isn’t an option.
Whether you’re deploying to edge sites, private data-centers, or operating
@@ -66,45 +65,44 @@ func init() {
// Global flags
rootCmd.PersistentFlags().String("config", "", "config file (required)")
viper.BindEnv("config", "HIVE_CONFIG_PATH")
viper.BindEnv("config", "SPINIFEX_CONFIG_PATH")
viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
// Authentication (access_key, secret)
rootCmd.PersistentFlags().String("access-key", "", "AWS access key (overrides config file and env)")
viper.BindEnv("access-key", "HIVE_ACCESS_KEY")
viper.BindEnv("access-key", "SPINIFEX_ACCESS_KEY")
viper.BindPFlag("access-key", rootCmd.PersistentFlags().Lookup("access-key"))
rootCmd.PersistentFlags().String("secret-key", "", "AWS secret key (overrides config file and env)")
viper.BindEnv("secret-key", "HIVE_SECRET_KEY")
viper.BindEnv("secret-key", "SPINIFEX_SECRET_KEY")
viper.BindPFlag("secret-key", rootCmd.PersistentFlags().Lookup("secret-key"))
rootCmd.PersistentFlags().String("host", "", "AWS Endpoint (overrides config file and env)")
viper.BindEnv("host", "HIVE_HOST")
viper.BindEnv("host", "SPINIFEX_HOST")
viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("host"))
// Viperblock config
rootCmd.PersistentFlags().String("base-dir", "", "Viperblock base directory (overrides config file and env)")
viper.BindEnv("base-dir", "HIVE_BASE_DIR")
viper.BindEnv("base-dir", "SPINIFEX_BASE_DIR")
viper.BindPFlag("base-dir", rootCmd.PersistentFlags().Lookup("base-dir"))
// NATS specific flags
rootCmd.PersistentFlags().String("nats-host", "", "NATS server host (overrides config file and env)")
viper.BindEnv("nats-host", "HIVE_NATS_HOST")
viper.BindEnv("nats-host", "SPINIFEX_NATS_HOST")
viper.BindPFlag("nats-host", rootCmd.PersistentFlags().Lookup("nats-host"))
rootCmd.PersistentFlags().String("nats-token", "", "NATS authentication token (overrides config file and env)")
viper.BindEnv("nats-token", "HIVE_NATS_TOKEN")
viper.BindEnv("nats-token", "SPINIFEX_NATS_TOKEN")
viper.BindPFlag("nats-token", rootCmd.PersistentFlags().Lookup("nats-token"))
rootCmd.PersistentFlags().String("nats-subject", "", "NATS subscription subject (overrides config file and env)")
viper.BindEnv("nats-subject", "HIVE_NATS_SUBJECT")
viper.BindEnv("nats-subject", "SPINIFEX_NATS_SUBJECT")
viper.BindPFlag("nats-subject", rootCmd.PersistentFlags().Lookup("nats-subject"))
// Bind flags to viper
//viper.BindPFlag("nats.host", rootCmd.PersistentFlags().Lookup("nats-host"))
//viper.BindPFlag("nats.acl.token", rootCmd.PersistentFlags().Lookup("nats-token"))
//viper.BindPFlag("nats.sub.subject", rootCmd.PersistentFlags().Lookup("nats-subject"))
}
// initConfig reads in config file and ENV variables if set.
@@ -115,7 +113,11 @@ func initConfig() {
appConfig, err = config.LoadConfig(cfgFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
fmt.Fprintln(os.Stderr, "Continuing with environment variables and defaults...")
// If a config file was explicitly provided, treat load failure as fatal
if cfgFile != "" {
fmt.Fprintf(os.Stderr, "Error: failed to load config %s: %v\n", cfgFile, err)
os.Exit(1)
}
// No config specified — continue with env/defaults (e.g., spx --help)
}
}
+976
View File
@@ -0,0 +1,976 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"log/slog"
"os"
"strings"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/service"
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
"github.com/mulgadc/spinifex/spinifex/services/nats"
"github.com/mulgadc/spinifex/spinifex/services/predastore"
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
"github.com/mulgadc/spinifex/spinifex/services/viperblockd"
"github.com/mulgadc/spinifex/spinifex/services/vpcd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Manage Spinifex services",
}
var predastoreCmd = &cobra.Command{
Use: "predastore",
Short: "Manage the predastore service",
}
var viperblockCmd = &cobra.Command{
Use: "viperblock",
Short: "Manage the viperblock service",
}
var natsCmd = &cobra.Command{
Use: "nats",
Short: "Manage the nats service",
}
var spinifexCmd = &cobra.Command{
Use: "spinifex",
Short: "Manage the spinifex service",
}
var awsgwCmd = &cobra.Command{
Use: "awsgw",
Short: "Manage the awsgw (AWS gateway) service",
}
var vpcdCmd = &cobra.Command{
Use: "vpcd",
Short: "Manage the vpcd (VPC daemon) service",
}
var spinifexUICmd = &cobra.Command{
Use: "spinifex-ui",
Aliases: []string{"ui", "spinifexui"},
Short: "Manage the spinifex-ui service",
}
var predastoreStartCmd = &cobra.Command{
Use: "start",
Short: "Start the predastore service",
Run: func(cmd *cobra.Command, args []string) {
// Add your start logic here
fmt.Println("Starting predastore service...")
// Get the port from the flags
port := viper.GetInt("port")
host := viper.GetString("host")
basePath := viper.GetString("base-path")
debug := viper.GetBool("debug")
// Required, no default
if basePath == "" {
fmt.Println("Base path is not set")
return
}
configPath := viper.GetString("config-path")
if configPath == "" {
fmt.Println("Config path is not set")
return
}
tlsCert := viper.GetString("tls-cert")
if tlsCert == "" {
fmt.Println("TLS cert is not set")
return
}
tlsKey := viper.GetString("tls-key")
if tlsKey == "" {
fmt.Println("TLS key is not set")
return
}
backendType := viper.GetString("backend")
var backend s3.BackendType
if backendType == "distributed" {
backend = s3.BackendDistributed
} else {
backend = s3.BackendFilesystem
}
nodeID := viper.GetInt("node-id")
pprofEnabled := viper.GetBool("pprof")
pprofOutput := viper.GetString("pprof-output")
service, err := service.New("predastore", &predastore.Config{
Port: port,
Host: host,
BasePath: basePath,
ConfigPath: configPath,
Debug: debug,
TlsCert: tlsCert,
TlsKey: tlsKey,
Backend: backend,
NodeID: nodeID,
PprofEnabled: pprofEnabled,
PprofOutputPath: pprofOutput,
})
if err != nil {
fmt.Println("Error starting predastore service:", err)
return
}
if _, err := service.Start(); err != nil {
fmt.Println("Error starting predastore service:", err)
os.Exit(1)
}
fmt.Println("Predastore service started", service)
},
}
var predastoreStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the predastore service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping predastore service...")
service, err := service.New("predastore", &predastore.Config{})
if err != nil {
fmt.Println("Error stopping predastore service:", err)
return
}
if err = service.Stop(); err != nil {
fmt.Println("Error stopping predastore service:", err)
os.Exit(1)
}
fmt.Println("Predastore service stopped")
},
}
var predastoreStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the predastore service",
Run: func(cmd *cobra.Command, args []string) {
// Add your status logic here
fmt.Println("Predastore service status: ...")
},
}
// Repeat for viperblock
var viperblockStartCmd = &cobra.Command{
Use: "start",
Short: "Start the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting viperblock service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
fmt.Println("Loading config from:", cfgFile)
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
natsHost := viper.GetString("nats-host")
if natsHost != "" {
fmt.Println("Overwriting natsHost:", natsHost)
nodeConfig.NATS.Host = natsHost
}
s3Host := viper.GetString("s3-host")
if s3Host != "" {
fmt.Println("Overwriting s3host:", s3Host)
nodeConfig.Predastore.Host = s3Host
}
s3Bucket := viper.GetString("s3-bucket")
if s3Bucket != "" {
fmt.Println("Overwriting s3bucket:", s3Bucket)
nodeConfig.Predastore.Bucket = s3Bucket
}
s3Region := viper.GetString("s3-region")
if s3Region != "" {
fmt.Println("Overwriting s3Region:", s3Region)
nodeConfig.Predastore.Region = s3Region
}
accessKey := viper.GetString("access-key")
if accessKey != "" {
fmt.Println("Overwriting access-key:", accessKey)
nodeConfig.Predastore.AccessKey = accessKey
}
secretKey := viper.GetString("secret-key")
if secretKey != "" {
fmt.Println("Overwriting secret-key:", secretKey)
nodeConfig.Predastore.SecretKey = secretKey
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting base-dir:", baseDir)
nodeConfig.Predastore.BaseDir = baseDir
}
// Apply changes back to cluster config
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
pluginPath := viper.GetString("plugin-path")
if pluginPath == "" {
err := fmt.Errorf("plugin-path must be defined")
slog.Error(err.Error())
os.Exit(1)
}
// Check plugin path exists
if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
err := fmt.Errorf("plugin-path does not exist: %s", pluginPath)
slog.Error(err.Error())
os.Exit(1)
}
// Resolve sharded WAL setting: default false unless explicitly set to true
shardWAL := false
if nodeConfig.Viperblock.ShardWAL != nil {
shardWAL = *nodeConfig.Viperblock.ShardWAL
}
service, err := service.New("viperblock", &viperblockd.Config{
NatsHost: nodeConfig.NATS.Host,
PluginPath: pluginPath,
S3Host: nodeConfig.Predastore.Host,
Bucket: nodeConfig.Predastore.Bucket,
Region: nodeConfig.Predastore.Region,
AccessKey: nodeConfig.Predastore.AccessKey,
SecretKey: nodeConfig.Predastore.SecretKey,
BaseDir: nodeConfig.Predastore.BaseDir,
NodeName: clusterConfig.Node,
ShardWAL: shardWAL,
})
if err != nil {
fmt.Println("Error starting viperblock service:", err)
return
}
_, err = service.Start()
if err != nil {
fmt.Println("Error starting viperblock service:", err)
return
}
fmt.Println("Viperblock service started", service)
},
}
var viperblockStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping viperblock service...")
service, err := service.New("viperblock", &viperblockd.Config{})
if err != nil {
fmt.Println("Error stopping viperblock service:", err)
return
}
if err = service.Stop(); err != nil {
fmt.Println("Error stopping viperblock service:", err)
os.Exit(1)
}
fmt.Println("Viperblock service stopped")
},
}
var viperblockStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Viperblock service status: ...")
},
}
// Repeat for nats
var natsStartCmd = &cobra.Command{
Use: "start",
Short: "Start the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting nats service...")
port := viper.GetInt("port")
host := viper.GetString("host")
debug := viper.GetBool("debug")
dataDir := viper.GetString("data-dir")
jetStream := viper.GetBool("jetstream")
cfgFile := viper.GetString("config")
service, err := service.New("nats", &nats.Config{
ConfigFile: cfgFile,
Port: port,
Host: host,
Debug: debug,
DataDir: dataDir,
JetStream: jetStream,
})
if err != nil {
fmt.Println("Error starting nats service:", err)
return
}
if _, err = service.Start(); err != nil {
fmt.Println("Error starting nats service:", err)
os.Exit(1)
}
fmt.Println("NATS service started")
},
}
var natsStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping nats service...")
service, err := service.New("nats", &nats.Config{})
if err != nil {
fmt.Println("Error stopping nats service:", err)
return
}
if err = service.Stop(); err != nil {
fmt.Println("Error stopping nats service:", err)
os.Exit(1)
}
fmt.Println("Nats service stopped")
},
}
var natsStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Nats service status: ...")
},
}
// Repeat for spinifex
var spinifexStartCmd = &cobra.Command{
Use: "start",
Short: "Start the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting spinifex service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
// Overwrite defaults (CLI first, config second, env third)
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
// Overwrite defaults (CLI first, config second, env third)
walDir := viper.GetString("wal-dir")
if walDir != "" {
fmt.Println("Overwriting wal-dir to:", walDir)
nodeConfig.WalDir = walDir
}
// Apply changes back to cluster config
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
svc, err := service.New("spinifex", clusterConfig)
if err != nil {
fmt.Println("Error starting spinifex service:", err)
return
}
// Set config path for cluster manager
if spxSvc, ok := svc.(interface{ SetConfigPath(string) }); ok {
spxSvc.SetConfigPath(cfgFile)
}
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting spinifex service:", err)
os.Exit(1)
}
fmt.Println("Spinifex service started")
},
}
var spinifexStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping spinifex service...")
service, err := service.New("spinifex", &config.ClusterConfig{})
if err != nil {
fmt.Println("Error stopping spinifex service:", err)
return
}
if err = service.Stop(); err != nil {
fmt.Println("Error stopping spinifex service:", err)
os.Exit(1)
}
fmt.Println("Spinifex service stopped")
},
}
var spinifexStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Spinifex service status: ...")
},
}
// AWS GW
var awsgwStartCmd = &cobra.Command{
Use: "start",
Short: "Start the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting awsgw service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
fmt.Println("Loading config from:", cfgFile)
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
// Overwrite defaults (CLI first, config second, env third)
awsgwHost := viper.GetString("host")
if awsgwHost != "" {
fmt.Println("Overwriting awsgw host to:", awsgwHost)
//nodeConfig.AWSGW.Host = awsgwHost
}
awsgwTlsCert := viper.GetString("tls-cert")
if awsgwTlsCert != "" {
fmt.Println("Overwriting awsgw tls-cert to:", awsgwTlsCert)
nodeConfig.AWSGW.TLSCert = awsgwTlsCert
}
awsgwTlsKey := viper.GetString("tls-key")
if awsgwTlsKey != "" {
fmt.Println("Overwriting awsgw tls-key to:", awsgwTlsKey)
nodeConfig.AWSGW.TLSKey = awsgwTlsKey
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting awsgw base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
// Apply changes back to cluster config
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
awsgw.SetBuildInfo(Version, Commit)
service, err := service.New("awsgw", clusterConfig)
if err != nil {
fmt.Println("Error starting awsgw service:", err)
return
}
if _, err = service.Start(); err != nil {
fmt.Println("Error starting awsgw service:", err)
os.Exit(1)
}
fmt.Println("AWSGW service started")
},
}
var awsgwStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping awsgw service...")
service, err := service.New("awsgw", &config.ClusterConfig{})
if err != nil {
fmt.Println("Error stopping awsgw service:", err)
return
}
if err = service.Stop(); err != nil {
fmt.Println("Error stopping awsgw service:", err)
os.Exit(1)
}
fmt.Println("AWSGW service stopped")
},
}
var awsgwStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("AWSGW service status: ...")
},
}
var spinifexUIStartCmd = &cobra.Command{
Use: "start",
Short: "Start the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting spinifex-ui service...")
port := viper.GetInt("spinifex-ui-port")
host := viper.GetString("spinifex-ui-host")
tlsCert := viper.GetString("spinifex-ui-tls-cert")
tlsKey := viper.GetString("spinifex-ui-tls-key")
svc, err := service.New("spinifex-ui", &spinifexui.Config{
Port: port,
Host: host,
TLSCert: tlsCert,
TLSKey: tlsKey,
})
if err != nil {
fmt.Println("Error starting spinifex-ui service:", err)
return
}
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting spinifex-ui service:", err)
os.Exit(1)
}
fmt.Println("spinifex-ui service started")
},
}
var spinifexUIStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping spinifex-ui service...")
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
fmt.Println("Error stopping spinifex-ui service:", err)
return
}
if err = svc.Stop(); err != nil {
fmt.Println("Error stopping spinifex-ui service:", err)
os.Exit(1)
}
fmt.Println("spinifex-ui service stopped")
},
}
var spinifexUIStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
fmt.Println("Error getting spinifex-ui service status:", err)
return
}
status, err := svc.Status()
if err != nil {
fmt.Println("Error getting spinifex-ui service status:", err)
return
}
fmt.Println("spinifex-ui service status:", status)
},
}
var vpcdStartCmd = &cobra.Command{
Use: "start",
Short: "Start the vpcd service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting vpcd service...")
cfgFile := viper.GetString("config")
if cfgFile == "" {
fmt.Println("Config file is not set")
return
}
clusterConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
// Map cluster-wide external pools to vpcd config
var extPools []vpcd.ExternalPoolConfig
for _, p := range clusterConfig.Network.ExternalPools {
extPools = append(extPools, vpcd.ExternalPoolConfig{
Name: p.Name,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
GatewayIP: p.GatewayIP,
PrefixLen: p.PrefixLen,
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
})
}
// Fallback chassis names derived from config node names. vpcd prefers
// discovering chassis from the OVN Southbound DB at startup; these are
// only used if that discovery fails.
var chassisNames []string
for nodeName := range clusterConfig.Nodes {
chassisNames = append(chassisNames, "chassis-"+nodeName)
}
var bootstrap *vpcd.BootstrapVPC
if clusterConfig.Bootstrap.VpcId != "" {
bootstrap = &vpcd.BootstrapVPC{
AccountID: clusterConfig.Bootstrap.AccountID,
VpcId: clusterConfig.Bootstrap.VpcId,
SubnetId: clusterConfig.Bootstrap.SubnetId,
IgwId: clusterConfig.Bootstrap.IgwId,
Cidr: clusterConfig.Bootstrap.Cidr,
SubnetCidr: clusterConfig.Bootstrap.SubnetCidr,
}
}
svc, err := service.New("vpcd", &vpcd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
ChassisNames: chassisNames,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
WanBridge: nodeConfig.VPCD.WanBridge,
BridgeMode: nodeConfig.VPCD.BridgeMode,
})
if err != nil {
fmt.Println("Error starting vpcd service:", err)
return
}
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting vpcd service:", err)
os.Exit(1)
}
fmt.Println("vpcd service started")
},
}
var vpcdStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the vpcd service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping vpcd service...")
svc, err := service.New("vpcd", &vpcd.Config{})
if err != nil {
fmt.Println("Error stopping vpcd service:", err)
return
}
if err = svc.Stop(); err != nil {
fmt.Println("Error stopping vpcd service:", err)
os.Exit(1)
}
fmt.Println("vpcd service stopped")
},
}
var vpcdStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the vpcd service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("vpcd service status: ...")
},
}
func init() {
viper.SetEnvPrefix("SPINIFEX") // Prefix for environment variables
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv() // Read environment variables automatically
rootCmd.AddCommand(serviceCmd)
serviceCmd.AddCommand(predastoreCmd)
// Predastore Port
predastoreCmd.PersistentFlags().Int("port", 8443, "Predastore (S3) port")
viper.BindEnv("port", "SPINIFEX_PREDASTORE_PORT")
viper.BindPFlag("port", predastoreCmd.PersistentFlags().Lookup("port"))
// Predastore Host
predastoreCmd.PersistentFlags().String("host", "0.0.0.0", "Predastore (S3) host")
viper.BindEnv("host", "SPINIFEX_PREDASTORE_HOST")
viper.BindPFlag("host", predastoreCmd.PersistentFlags().Lookup("host"))
// Base path
predastoreCmd.PersistentFlags().String("base-path", "", "Predastore (S3) base path")
viper.BindEnv("base-path", "SPINIFEX_PREDASTORE_BASE_PATH")
viper.BindPFlag("base-path", predastoreCmd.PersistentFlags().Lookup("base-path"))
// Predastore Config Path
predastoreCmd.PersistentFlags().String("config-path", "", "Predastore (S3) config path")
viper.BindEnv("config-path", "SPINIFEX_PREDASTORE_CONFIG_PATH")
viper.BindPFlag("config-path", predastoreCmd.PersistentFlags().Lookup("config-path"))
// Predastore Debug
predastoreCmd.PersistentFlags().Bool("debug", false, "Predastore (S3) debug")
viper.BindEnv("debug", "SPINIFEX_PREDASTORE_DEBUG")
viper.BindPFlag("debug", predastoreCmd.PersistentFlags().Lookup("debug"))
// Predastore TLS Cert
predastoreCmd.PersistentFlags().String("tls-cert", "", "Predastore (S3) TLS certificate")
viper.BindEnv("tls-cert", "SPINIFEX_PREDASTORE_TLS_CERT")
viper.BindPFlag("tls-cert", predastoreCmd.PersistentFlags().Lookup("tls-cert"))
// Predastore TLS Key
predastoreCmd.PersistentFlags().String("tls-key", "", "Predastore (S3) TLS key")
viper.BindEnv("tls-key", "SPINIFEX_PREDASTORE_TLS_KEY")
viper.BindPFlag("tls-key", predastoreCmd.PersistentFlags().Lookup("tls-key"))
// Predastore Backend
predastoreCmd.PersistentFlags().String("backend", "distributed", "Predastore (S3) backend")
viper.BindEnv("backend", "SPINIFEX_PREDASTORE_BACKEND")
viper.BindPFlag("backend", predastoreCmd.PersistentFlags().Lookup("backend"))
// Predastore Node ID
predastoreCmd.PersistentFlags().Int("node-id", 0, "Predastore (S3) node ID")
viper.BindEnv("node-id", "SPINIFEX_PREDASTORE_NODE_ID")
viper.BindPFlag("node-id", predastoreCmd.PersistentFlags().Lookup("node-id"))
// Predastore CPU Profiling
predastoreCmd.PersistentFlags().Bool("pprof", false, "Enable CPU profiling (also via PPROF_ENABLED=1)")
viper.BindEnv("pprof", "PPROF_ENABLED")
viper.BindPFlag("pprof", predastoreCmd.PersistentFlags().Lookup("pprof"))
// Predastore CPU Profile Output Path
predastoreCmd.PersistentFlags().String("pprof-output", "/tmp/predastore-cpu.prof", "CPU profile output path")
viper.BindEnv("pprof-output", "PPROF_OUTPUT")
viper.BindPFlag("pprof-output", predastoreCmd.PersistentFlags().Lookup("pprof-output"))
predastoreCmd.AddCommand(predastoreStartCmd)
predastoreCmd.AddCommand(predastoreStopCmd)
predastoreCmd.AddCommand(predastoreStatusCmd)
serviceCmd.AddCommand(viperblockCmd)
viperblockCmd.PersistentFlags().String("s3-host", "0.0.0.0:8443", "Predastore (S3) host URI")
viper.BindEnv("s3-host", "SPINIFEX_VIPERBLOCK_S3_HOST")
viper.BindPFlag("s3-host", predastoreCmd.PersistentFlags().Lookup("s3-host"))
viperblockCmd.PersistentFlags().String("s3-bucket", "predastore", "Predastore (S3) bucket")
viper.BindEnv("s3-bucket", "SPINIFEX_VIPERBLOCK_S3_BUCKET")
viper.BindPFlag("s3-bucket", predastoreCmd.PersistentFlags().Lookup("s3-bucket"))
viperblockCmd.PersistentFlags().String("s3-region", "ap-southeast-2", "Predastore (S3) region")
viper.BindEnv("s3-region", "SPINIFEX_VIPERBLOCK_S3_REGION")
viper.BindPFlag("s3-region", predastoreCmd.PersistentFlags().Lookup("s3-region"))
viperblockCmd.PersistentFlags().String("plugin-path", "/opt/spinifex/lib/nbdkit-viperblock-plugin.so", "Pathname to the nbdkit viperblockplugin")
viper.BindEnv("plugin-path", "SPINIFEX_VIPERBLOCK_PLUGIN_PATH")
viper.BindPFlag("plugin-path", predastoreCmd.PersistentFlags().Lookup("plugin-path"))
viperblockCmd.AddCommand(viperblockStartCmd)
viperblockCmd.AddCommand(viperblockStopCmd)
viperblockCmd.AddCommand(viperblockStatusCmd)
// Nats
serviceCmd.AddCommand(natsCmd)
natsCmd.AddCommand(natsStartCmd)
natsCmd.AddCommand(natsStopCmd)
natsCmd.AddCommand(natsStatusCmd)
// Add NATS flags
natsCmd.PersistentFlags().Int("port", 4222, "NATS server port")
viper.BindEnv("port", "SPINIFEX_NATS_PORT")
viper.BindPFlag("port", natsCmd.PersistentFlags().Lookup("port"))
natsCmd.PersistentFlags().String("host", "0.0.0.0", "NATS server host")
viper.BindEnv("host", "SPINIFEX_NATS_HOST")
viper.BindPFlag("host", natsCmd.PersistentFlags().Lookup("host"))
natsCmd.PersistentFlags().Bool("debug", false, "Enable debug logging")
viper.BindEnv("debug", "SPINIFEX_NATS_DEBUG")
viper.BindPFlag("debug", natsCmd.PersistentFlags().Lookup("debug"))
natsCmd.PersistentFlags().String("data-dir", "", "NATS data directory")
viper.BindEnv("data-dir", "SPINIFEX_NATS_DATA_DIR")
viper.BindPFlag("data-dir", natsCmd.PersistentFlags().Lookup("data-dir"))
natsCmd.PersistentFlags().Bool("jetstream", false, "Enable JetStream")
viper.BindEnv("jetstream", "SPINIFEX_NATS_JETSTREAM")
viper.BindPFlag("jetstream", natsCmd.PersistentFlags().Lookup("jetstream"))
// Spinifex
serviceCmd.AddCommand(spinifexCmd)
spinifexCmd.AddCommand(spinifexStartCmd)
spinifexCmd.AddCommand(spinifexStopCmd)
spinifexCmd.AddCommand(spinifexStatusCmd)
spinifexCmd.PersistentFlags().String("wal-dir", "", "Write-ahead log (WAL) directory. Place on high-speed NVMe disk, or tmpfs for development.")
viper.BindEnv("wal-dir", "SPINIFEX_WAL_DIR")
viper.BindPFlag("wal-dir", spinifexCmd.PersistentFlags().Lookup("wal-dir"))
// AWS GW
serviceCmd.AddCommand(awsgwCmd)
awsgwCmd.PersistentFlags().String("host", "0.0.0.0:9999", "AWS Gateway server host")
viper.BindEnv("host", "SPINIFEX_AWSGW_HOST")
viper.BindPFlag("host", awsgwCmd.PersistentFlags().Lookup("host"))
// AWS GW TLS Cert
awsgwCmd.PersistentFlags().String("tls-cert", "", "AWS Gateway TLS certificate")
viper.BindEnv("tls-cert", "SPINIFEX_AWSGW_TLS_CERT")
viper.BindPFlag("tls-cert", awsgwCmd.PersistentFlags().Lookup("tls-cert"))
// AWS GW TLS Key
awsgwCmd.PersistentFlags().String("tls-key", "", "AWS Gateway TLS key")
viper.BindEnv("tls-key", "SPINIFEX_AWSGW_TLS_KEY")
viper.BindPFlag("tls-key", awsgwCmd.PersistentFlags().Lookup("tls-key"))
awsgwCmd.PersistentFlags().Bool("debug", false, "AWS Gateway Debug")
viper.BindEnv("debug", "SPINIFEX_AWSGW_DEBUG")
viper.BindPFlag("debug", awsgwCmd.PersistentFlags().Lookup("debug"))
awsgwCmd.AddCommand(awsgwStartCmd)
awsgwCmd.AddCommand(awsgwStopCmd)
awsgwCmd.AddCommand(awsgwStatusCmd)
// spinifex-ui
serviceCmd.AddCommand(spinifexUICmd)
spinifexUICmd.PersistentFlags().Int("port", 3000, "spinifex-ui server port")
viper.BindEnv("spinifex-ui-port", "SPINIFEX_UI_PORT")
viper.BindPFlag("spinifex-ui-port", spinifexUICmd.PersistentFlags().Lookup("port"))
spinifexUICmd.PersistentFlags().String("host", "0.0.0.0", "spinifex-ui server host")
viper.BindEnv("spinifex-ui-host", "SPINIFEX_UI_HOST")
viper.BindPFlag("spinifex-ui-host", spinifexUICmd.PersistentFlags().Lookup("host"))
spinifexUICmd.PersistentFlags().String("tls-cert", "", "TLS certificate path")
viper.BindEnv("spinifex-ui-tls-cert", "SPINIFEX_UI_TLS_CERT")
viper.BindPFlag("spinifex-ui-tls-cert", spinifexUICmd.PersistentFlags().Lookup("tls-cert"))
spinifexUICmd.PersistentFlags().String("tls-key", "", "TLS key path")
viper.BindEnv("spinifex-ui-tls-key", "SPINIFEX_UI_TLS_KEY")
viper.BindPFlag("spinifex-ui-tls-key", spinifexUICmd.PersistentFlags().Lookup("tls-key"))
spinifexUICmd.AddCommand(spinifexUIStartCmd)
spinifexUICmd.AddCommand(spinifexUIStopCmd)
spinifexUICmd.AddCommand(spinifexUIStatusCmd)
// vpcd
serviceCmd.AddCommand(vpcdCmd)
vpcdCmd.AddCommand(vpcdStartCmd)
vpcdCmd.AddCommand(vpcdStopCmd)
vpcdCmd.AddCommand(vpcdStatusCmd)
}
+6
View File
@@ -0,0 +1,6 @@
# AWS Gateway configuration file in TOML format.
version = "1.0"
region = "{{.Region}}"
# Enable verbose debugging
debug = false
+56
View File
@@ -0,0 +1,56 @@
# NATS Server Configuration
# Generated by Spinifex admin init
# Server configuration
{{- if .BindIP }}
listen: {{.BindIP}}:4222
{{- else }}
listen: 0.0.0.0:4222
{{- end }}
{{- if and .ClusterBindIP .ClusterRoutes }}
cluster {
name: {{ .ClusterName }}
listen: {{.ClusterBindIP}}:4248
routes = [
{{- range .ClusterRoutes }}
"nats://{{ . }}",
{{- end }}
]
}
{{- end }}
server_name: spinifex-nats-{{.Node}}
# Authorization
authorization {
# token: "{{.NatsToken}}"
}
# JetStream for persistent messaging and KV store
jetstream {
store_dir: "{{.DataDir}}/nats/"
max_memory_store: 1GB
max_file_store: 10GB
}
# Logging
debug: false
trace: false
logtime: true
log_file: "{{.DataDir}}/logs/nats.log"
# Limits
max_connections: 1000
max_payload: 1MB
max_pending: 64MB
# Monitoring
{{- if .BindIP }}
http: {{.BindIP}}:8222
{{- else }}
http_port: 8222
{{- end }}
@@ -0,0 +1,55 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "1.0"
region = "{{.Region}}"
host = "0.0.0.0"
port = 8443
debug = false
[rs]
data = 2
parity = 1
{{- range .Nodes}}
[[db]]
id = {{.ID}}
host = "{{.Host}}"
port = 6660
path = "distributed/db/node-{{.ID}}/"
access_key_id = "{{$.AccessKey}}"
secret_access_key = "{{$.SecretKey}}"
{{- if eq .ID 1}}
leader = true
{{- end}}
{{- end}}
{{- range .Nodes}}
[[nodes]]
id = {{.ID}}
host = "{{.Host}}"
port = 9991
path = "distributed/nodes/node-{{.ID}}/"
{{- end}}
[[buckets]]
name = "predastore"
region = "{{.Region}}"
type = "distributed"
public = false
encryption = ""
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://{{.BindIP}}:4222"
nats_token = "{{.NatsToken}}"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
@@ -0,0 +1,89 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "1.0"
region = "{{.Region}}"
# Optionally configure port and host, or specify via daemon CLI options
host = "0.0.0.0"
port = 8443
# Enable verbose debugging
debug = false
# Optionally configure base-path if full directory path not defined for "fs" buckets
# base_path = "/home/predastore/data/"
# Specify reed solomon encoding -
# RS(2,1), 2 data, 1 parity. Minimum
# RS(3,2), 3 data, 2 parity. Recommended for production
[rs]
data = 2
parity = 1
[[db]]
id = 1
host = "127.0.0.1"
port = 6660
#raft_port = 7660 # Optional: Raft consensus port (default: port + 1000)
#advertise_host = "" # Optional: Address to advertise to other Raft nodes
path = "distributed/db/node-1/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
leader = true # Bootstrap leader hint
[[db]]
id = 2
host = "127.0.0.1"
port = 6661
path = "distributed/db/node-2/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
[[db]]
id = 3
host = "127.0.0.1"
port = 6662
path = "distributed/db/node-3/"
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
# Shard nodes
# Using distributed hash ring for object > shard placement.
[[nodes]]
id = 1
host = "0.0.0.0"
port = 9991
path = "distributed/nodes/node-1/"
[[nodes]]
id = 2
host = "0.0.0.0"
port = 9992
path = "distributed/nodes/node-2/"
[[nodes]]
id = 3
host = "0.0.0.0"
port = 9993
path = "distributed/nodes/node-3/"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
nats_token = "{{.NatsToken}}"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
+110
View File
@@ -0,0 +1,110 @@
# Domain configuration file in TOML format.
version = "1.0"
epoch = 1
node = "{{.Node}}"
{{- if .ExternalMode}}
[network]
external_mode = "{{.ExternalMode}}"
{{- if .ExternalDHCP}}
external_dhcp = true
{{- end}}
[[network.external_pools]]
name = "{{.PoolName}}"
{{- if .PoolSource}}
source = "{{.PoolSource}}"
{{- end}}
{{- if .PoolStart}}
range_start = "{{.PoolStart}}"
range_end = "{{.PoolEnd}}"
{{- end}}
gateway = "{{.PoolGateway}}"
{{- if .PoolGatewayIP}}
gateway_ip = "{{.PoolGatewayIP}}"
{{- end}}
prefix_len = {{if .PoolPrefixLen}}{{.PoolPrefixLen}}{{else}}24{{end}}
{{- if .PoolDNSServers}}
dns_servers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
{{- end}}
{{- if .BootstrapVpcId}}
[bootstrap]
account_id = "{{.BootstrapAccountId}}"
vpc_id = "{{.BootstrapVpcId}}"
subnet_id = "{{.BootstrapSubnetId}}"
igw_id = "{{.BootstrapIgwId}}"
cidr = "{{.BootstrapCidr}}"
subnet_cidr = "{{.BootstrapSubnetCidr}}"
{{- end}}
[nodes.{{.Node}}]
node = "{{.Node}}"
host = "{{.BindIP}}"
region = "{{.Region}}"
az = "{{.Az}}"
{{- if .Services}}
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
account_id = "{{.AccountID}}"
[nodes.{{.Node}}.daemon]
host = "{{.BindIP}}:{{.Port}}"
tlskey = "config/server.key"
tlscert = "config/server.pem"
{{- if .ExternalMode}}
dev_networking = false
{{- else}}
dev_networking = true
{{- end}}
[nodes.{{.Node}}.awsgw]
host = "{{.BindIP}}:9999"
tlskey = "config/server.key"
tlscert = "config/server.pem"
debug = true
config = "awsgw/awsgw.toml"
[nodes.{{.Node}}.nats]
host = "{{.BindIP}}:4222"
[nodes.{{.Node}}.nats.acl]
token = "{{.NatsToken}}"
[nodes.{{.Node}}.nats.sub]
[nodes.{{.Node}}.viperblock]
shardwal = false
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
ovn_sb_addr = "{{.OVNSBAddr}}"
{{- if .ExternalIface}}
external_interface = "{{.ExternalIface}}"
{{- end}}
{{- if .WanBridge}}
wan_bridge = "{{.WanBridge}}"
{{- end}}
[nodes.{{.Node}}.predastore]
host = "{{.BindIP}}:8443"
region = "{{.Region}}"
bucket = "predastore"
accesskey = "{{.AccessKey}}"
secretkey = "{{.SecretKey}}"
{{- if gt .PredastoreNodeID 0}}
node_id = {{.PredastoreNodeID}}
{{- end}}
{{- range .RemoteNodes}}
[nodes.{{.Name}}]
node = "{{.Name}}"
host = "{{.Host}}"
region = "{{.Region}}"
az = "{{.AZ}}"
{{- if .Services}}
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
{{- end}}
+162
View File
@@ -0,0 +1,162 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"encoding/json"
"fmt"
"os"
"sort"
"time"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)
var topCmd = &cobra.Command{
Use: "top",
Short: "Display cluster resource usage",
Long: `Display resource usage (CPU, memory) for cluster nodes.`,
}
var topNodesCmd = &cobra.Command{
Use: "nodes",
Short: "Display resource usage per node",
Long: `Display CPU and memory usage per node, plus a summary of available instance types across the cluster.`,
Run: runTopNodes,
}
func init() {
rootCmd.AddCommand(topCmd)
topCmd.AddCommand(topNodesCmd)
topCmd.PersistentFlags().Duration("timeout", 3*time.Second, "Timeout for collecting responses from nodes")
}
func runTopNodes(cmd *cobra.Command, args []string) {
cfg, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "spinifex.node.status", timeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
respondedNodes := make(map[string]types.NodeStatusResponse)
for _, data := range responses {
var resp types.NodeStatusResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
respondedNodes[resp.Node] = resp
}
// Node resource table: union of config-known nodes + NATS responders
nodeSet := make(map[string]struct{})
for name := range cfg.Nodes {
nodeSet[name] = struct{}{}
}
for name := range respondedNodes {
nodeSet[name] = struct{}{}
}
nodeNames := make([]string, 0, len(nodeSet))
for name := range nodeSet {
nodeNames = append(nodeNames, name)
}
sort.Strings(nodeNames)
nodeTable := pterm.TableData{
{"NAME", "CPU (used/total)", "MEM (used/total)", "VMs"},
}
// Aggregate instance type capacity across all nodes
capacityMap := make(map[string]*aggregatedCap)
for _, name := range nodeNames {
if resp, ok := respondedNodes[name]; ok {
nodeTable = append(nodeTable, []string{
resp.Node,
fmt.Sprintf("%d/%d", resp.AllocVCPU, resp.TotalVCPU),
fmt.Sprintf("%s/%s", formatMemGB(resp.AllocMemGB), formatMemGB(resp.TotalMemGB)),
fmt.Sprintf("%d", resp.VMCount),
})
for _, cap := range resp.InstanceTypes {
if agg, ok := capacityMap[cap.Name]; ok {
agg.Available += cap.Available
} else {
capacityMap[cap.Name] = &aggregatedCap{
VCPU: cap.VCPU,
MemoryGB: cap.MemoryGB,
Available: cap.Available,
}
}
}
} else {
nodeTable = append(nodeTable, []string{
name,
"-",
"-",
"-",
})
}
}
pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(nodeTable).Render()
// Instance type capacity summary
if len(capacityMap) == 0 {
return
}
fmt.Println()
capNames := make([]string, 0, len(capacityMap))
for name := range capacityMap {
capNames = append(capNames, name)
}
sort.Strings(capNames)
capTable := pterm.TableData{
{"INSTANCE TYPE", "AVAILABLE", "VCPU", "MEMORY"},
}
for _, name := range capNames {
agg := capacityMap[name]
capTable = append(capTable, []string{
name,
fmt.Sprintf("%d", agg.Available),
fmt.Sprintf("%d", agg.VCPU),
formatMemGB(agg.MemoryGB),
})
}
pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(capTable).Render()
}
type aggregatedCap struct {
VCPU int
MemoryGB float64
Available int
}
+27
View File
@@ -0,0 +1,27 @@
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
// Version variables set via ldflags at build time.
// Example: go build -ldflags "-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=v1.0.0"
var (
Version = "dev"
Commit = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the Spinifex version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("spinifex %s (%s) %s/%s\n", Version, Commit, runtime.GOOS, runtime.GOARCH)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
+29
View File
@@ -0,0 +1,29 @@
package cmd
import (
"testing"
)
func TestVersionVarsExist(t *testing.T) {
// Verify the version variables are set (defaults when not injected via ldflags)
if Version == "" {
t.Error("Version should not be empty")
}
if Commit == "" {
t.Error("Commit should not be empty")
}
}
func TestVersionCommand(t *testing.T) {
// Verify the version command is registered
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Use == "version" {
found = true
break
}
}
if !found {
t.Error("version command not registered on rootCmd")
}
}
+23
View File
@@ -0,0 +1,23 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import "github.com/mulgadc/spinifex/cmd/spinifex/cmd"
func main() {
cmd.Execute()
}
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
# Linux
#genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
# MacOS
mkisofs -output seed.iso -volid cidata -joliet -rock meta-data user-data
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# Example to launch nbdkit
./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-50c968e8dbcc74bd2-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
qemu-system-aarch64 \
-nographic \
-M virt,highmem=off \
-accel hvf \
-cpu host \
-smp 4 \
-m 3000 \
-drive file=/Users/benduncan/qemu/QEMU_EFI.img,if=pflash,format=raw \
-device virtio-blk-pci,drive=debian,bootindex=1 \
-drive if=none,media=disk,id=debian,format=raw,file=nbd://192.168.64.5:10809/default \
-drive format=raw,file=nbd://192.168.64.5:10810/default,if=pflash \
-drive file=nbd://192.168.64.5:10811/default,format=raw,if=virtio \
-device qemu-xhci \
-device usb-kbd \
-device usb-tablet \
-device intel-hda \
-device hda-duplex \
-netdev user,id=net0,hostfwd=tcp::2222-:22 -device virtio-net-device,netdev=net0 \
-device virtio-rng-pci
# ./server/nbdkit -p 10809 --pidfile /tmp/vb-vol-1.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=4294967296 volume=vol-e660ff0065ad21f74 bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=20
# ./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-e660ff0065ad21f74-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
# ./server/nbdkit -p 10811 --pidfile /tmp/vb-vol-1-cloudinit.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=1048576 volume=vol-e660ff0065ad21f74-cloudinit bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
+44 -44
View File
@@ -1,71 +1,71 @@
# Hive Systemd Service
# Spinifex Systemd Service
This directory contains the systemd service configuration for running Hive services on Ubuntu and Debian systems.
This directory contains the systemd service configuration for running Spinifex services on Ubuntu and Debian systems.
## Files
- `hive.service` - The systemd service file that manages all Hive services
- `install-hive-service.sh` - Installation script to set up the service
- `spinifex.service` - The systemd service file that manages all Spinifex services
- `install-spinifex-service.sh` - Installation script to set up the service
- `README.md` - This documentation file
## Installation
### Prerequisites
1. Ensure Hive is installed in `/opt/hive/`
2. Ensure the `hive` binary is available at `/opt/hive/bin/hive`
1. Ensure Spinifex is installed in `/opt/spinifex/`
2. Ensure the `spx` binary is available at `/opt/spinifex/bin/spx`
3. Run the installation script as root
### Setup Configuration
```
/opt/hive/bin/hive admin init
/opt/spinifex/bin/spx admin init
```
This will init the Hive environment and create the default configuration files in the home directory, e.g `~/hive/`.
This will init the Spinifex environment and create the default configuration files in the home directory, e.g `~/spinifex/`.
```
/home/username/hive:
/home/username/spinifex:
amis config images logs nats predastore state viperblock volumes
/home/username/hive/amis:
/home/username/spinifex/amis:
/home/username/hive/config:
awsgw hive.toml nats predastore server.key server.pem
/home/username/spinifex/config:
awsgw spinifex.toml nats predastore server.key server.pem
/home/username/hive/config/awsgw:
/home/username/spinifex/config/awsgw:
awsgw.toml
/home/username/hive/config/nats:
/home/username/spinifex/config/nats:
nats.conf
/home/username/hive/config/predastore:
/home/username/spinifex/config/predastore:
predastore.toml
/home/username/hive/images:
/home/username/spinifex/images:
/home/username/hive/logs:
awsgw.log awsgw.pid hive.log hive.pid nats.log nats.pid predastore.log predastore.pid viperblock.log viperblock.pid
/home/username/spinifex/logs:
awsgw.log awsgw.pid spinifex.log spinifex.pid nats.log nats.pid predastore.log predastore.pid viperblock.log viperblock.pid
/home/username/hive/nats:
/home/username/spinifex/nats:
/home/username/hive/predastore:
/home/username/spinifex/predastore:
data
/home/username/hive/predastore/data:
/home/username/spinifex/predastore/data:
predastore
/home/ben/hive/state:
/home/ben/spinifex/state:
/home/ben/hive/viperblock:
/home/ben/spinifex/viperblock:
/home/ben/hive/volumes:
/home/ben/spinifex/volumes:
```
### Quick Installation
```bash
sudo ./config/install-hive-service.sh
sudo ./config/install-spinifex-service.sh
```
### Manual Installation
@@ -74,7 +74,7 @@ If you prefer to install manually:
1. Copy the service file:
```bash
sudo cp config/hive.service /etc/systemd/system/
sudo cp config/spinifex.service /etc/systemd/system/
```
2. Reload systemd:
@@ -84,44 +84,44 @@ If you prefer to install manually:
3. Enable the service:
```bash
sudo systemctl enable hive.service
sudo systemctl enable spinifex.service
```
## Service Management
### Start the service
```bash
sudo systemctl start hive
sudo systemctl start spinifex
```
### Stop the service
```bash
sudo systemctl stop hive
sudo systemctl stop spinifex
```
### Check service status
```bash
sudo systemctl status hive
sudo systemctl status spinifex
```
### Restart the service
```bash
sudo systemctl restart hive
sudo systemctl restart spinifex
```
### View service logs
```bash
sudo journalctl -u hive -f
sudo journalctl -u spinifex -f
```
### Disable auto-start
```bash
sudo systemctl disable hive
sudo systemctl disable spinifex
```
## Service Details
The `hive.service` file:
The `spinifex.service` file:
- Starts services in the correct order: NATS → Predastore → Viperblock
- Includes proper delays between service starts to ensure dependencies are ready
@@ -143,41 +143,41 @@ The service starts the following components in order:
### Check if the service is running
```bash
sudo systemctl is-active hive
sudo systemctl is-active spinifex
```
### View recent logs
```bash
sudo journalctl -u hive --since "1 hour ago"
sudo journalctl -u spinifex --since "1 hour ago"
```
### Check service configuration
```bash
sudo systemctl cat hive
sudo systemctl cat spinifex
```
### Verify binary exists
```bash
ls -la /opt/hive/bin/hive
ls -la /opt/spinifex/bin/spx
```
### Test service commands manually
```bash
/opt/hive/bin/hive service nats start
/opt/hive/bin/hive service predastore start
/opt/hive/bin/hive service viperblock start
/opt/spinifex/bin/spx service nats start
/opt/spinifex/bin/spx service predastore start
/opt/spinifex/bin/spx service viperblock start
```
## Customization
If you need to modify the service configuration:
1. Edit the service file: `sudo nano /etc/systemd/system/hive.service`
1. Edit the service file: `sudo nano /etc/systemd/system/spinifex.service`
2. Reload systemd: `sudo systemctl daemon-reload`
3. Restart the service: `sudo systemctl restart hive`
3. Restart the service: `sudo systemctl restart spinifex`
## Security Notes
- The service runs as root, which is necessary for system-level services
- Security hardening is enabled with `ProtectSystem=strict` and `NoNewPrivileges=true`
- Only specific paths are writable: `/opt/hive`, `/var/log`, `/var/run`
- Only specific paths are writable: `/opt/spinifex`, `/var/log`, `/var/run`
-49
View File
@@ -1,49 +0,0 @@
[Unit]
Description=Hive Services
Documentation=https://github.com/mulgadc/hive
After=network.target
Wants=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=root
Group=root
WorkingDirectory=/opt/hive
Environment=PATH=/opt/hive/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Start NATS service
ExecStart=/opt/hive/bin/hive service nats start
ExecStartPost=/bin/sleep 3
# Start Predastore service
ExecStart=/opt/hive/bin/hive service predastore start
ExecStartPost=/bin/sleep 2
# Start Viperblock service
ExecStart=/opt/hive/bin/hive service viperblock start
# Stop services in reverse order
ExecStop=/opt/hive/bin/hive service viperblock stop
ExecStopPost=/bin/sleep 2
ExecStop=/opt/hive/bin/hive service predastore stop
ExecStopPost=/bin/sleep 2
ExecStop=/opt/hive/bin/hive service nats stop
# Restart policy
Restart=on-failure
RestartSec=10
# Security settings
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/hive /var/log /var/run
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hive
[Install]
WantedBy=multi-user.target
@@ -1,7 +1,7 @@
#!/bin/bash
# Hive Service Installation Script
# This script installs the hive systemd service on Ubuntu/Debian systems
# Spinifex Service Installation Script
# This script installs the spinifex systemd service on Ubuntu/Debian systems
set -e
@@ -38,7 +38,7 @@ fi
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_FILE="$SCRIPT_DIR/hive.service"
SERVICE_FILE="$SCRIPT_DIR/spinifex.service"
# Check if service file exists
if [[ ! -f "$SERVICE_FILE" ]]; then
@@ -46,7 +46,7 @@ if [[ ! -f "$SERVICE_FILE" ]]; then
exit 1
fi
print_status "Installing Hive systemd service..."
print_status "Installing Spinifex systemd service..."
# Copy service file to systemd directory
cp "$SERVICE_FILE" /etc/systemd/system/
@@ -55,20 +55,20 @@ cp "$SERVICE_FILE" /etc/systemd/system/
systemctl daemon-reload
# Enable the service to start on boot
systemctl enable hive.service
systemctl enable spinifex.service
print_status "Service installed successfully!"
print_status "You can now use the following commands:"
echo " systemctl start hive # Start the service"
echo " systemctl stop hive # Stop the service"
echo " systemctl status hive # Check service status"
echo " systemctl restart hive # Restart the service"
echo " journalctl -u hive -f # View service logs"
echo " systemctl start spinifex # Start the service"
echo " systemctl stop spinifex # Stop the service"
echo " systemctl status spinifex # Check service status"
echo " systemctl restart spinifex # Restart the service"
echo " journalctl -u spinifex -f # View service logs"
# Check if hive binary exists
if [[ ! -f "/opt/hive/bin/hive" ]]; then
print_warning "Hive binary not found at /opt/hive/bin/hive"
print_warning "Please ensure hive is installed in /opt/hive before starting the service"
# Check if spinifex binary exists
if [[ ! -f "/opt/spinifex/bin/spinifex" ]]; then
print_warning "Spinifex binary not found at /opt/spinifex/bin/spinifex"
print_warning "Please ensure spinifex is installed in /opt/spinifex before starting the service"
fi
print_status "Installation complete!"
+80
View File
@@ -0,0 +1,80 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "1.0"
region = "ap-southeast-2"
# Optionally configure port and host, or specify via daemon CLI options
host = "0.0.0.0"
port = 8443
# Enable verbose debugging
debug = false
# Optionally configure base-path if full directory path not defined for "fs" buckets
# base_path = "/home/predastore/data/"
# Specify reed solomon encoding
[rs]
data = 2
parity = 1
[[db]]
id = 1
host = "127.0.0.1"
port = 6660
#raft_port = 7660 # Optional: Raft consensus port (default: port + 1000)
#advertise_host = "" # Optional: Address to advertise to other Raft nodes
path = "distributed/db/node-1/"
access_key_id = "X"
secret_access_key = "X"
leader = true # Bootstrap leader hint
[[db]]
id = 2
host = "127.0.0.1"
port = 6661
path = "distributed/db/node-2/"
access_key_id = "X"
secret_access_key = "X"
[[db]]
id = 3
host = "127.0.0.1"
port = 6662
path = "distributed/db/node-3/"
access_key_id = "X"
secret_access_key = "X"
[[nodes]]
id = 1
host = "0.0.0.0"
port = 9991
path = "distributed/nodes/node-1/"
[[nodes]]
id = 2
host = "0.0.0.0"
port = 9992
path = "distributed/nodes/node-2/"
[[nodes]]
id = 3
host = "0.0.0.0"
port = 9993
path = "distributed/nodes/node-3/"
[[buckets]]
name = "predastore"
region = "ap-southeast-2"
type = "distributed"
public = false
encryption = ""
[[auth]]
access_key_id = "X"
secret_access_key = "X"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
+49
View File
@@ -0,0 +1,49 @@
[Unit]
Description=Spinifex Services
Documentation=https://github.com/mulgadc/spinifex
After=network.target
Wants=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=root
Group=root
WorkingDirectory=/opt/spinifex
Environment=PATH=/opt/spinifex/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Start NATS service
ExecStart=/opt/spinifex/bin/spinifex service nats start
ExecStartPost=/bin/sleep 3
# Start Predastore service
ExecStart=/opt/spinifex/bin/spinifex service predastore start
ExecStartPost=/bin/sleep 2
# Start Viperblock service
ExecStart=/opt/spinifex/bin/spinifex service viperblock start
# Stop services in reverse order
ExecStop=/opt/spinifex/bin/spinifex service viperblock stop
ExecStopPost=/bin/sleep 2
ExecStop=/opt/spinifex/bin/spinifex service predastore stop
ExecStopPost=/bin/sleep 2
ExecStop=/opt/spinifex/bin/spinifex service nats stop
# Restart policy
Restart=on-failure
RestartSec=10
# Security settings
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/spinifex /var/log /var/run
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=spinifex
[Install]
WantedBy=multi-user.target
+626
View File
@@ -0,0 +1,626 @@
# Command Implementation Matrix
## Spinifex Admin CLI
Platform management commands not exposed via the AWS gateway API. These are CLI-only commands.
### Service Management
Service lifecycle commands for starting, stopping, and checking status of all Spinifex cluster services. Each service subcommand supports `start`, `stop`, and `status` operations.
| Command | Flags | Prerequisites | Basic Logic | Status |
|---------|-------|---------------|-------------|--------|
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
| `spx service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
| `spx service predastore status` | — | None | Reports predastore service status | **DONE** |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (default: /opt/spinifex/lib/nbdkit-viperblock-plugin.so) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
| `spx service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
| `spx service viperblock status` | — | None | Reports viperblock service status | **DONE** |
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
| `spx service nats stop` | — | NATS must be running | Stops the NATS service | **DONE** |
| `spx service nats status` | — | None | Reports NATS service status | **DONE** |
| `spx service spinifex start` | `--wal-dir` | `--config` required | Loads cluster config → starts spinifex daemon (VM orchestration, NATS subscriptions, health endpoint) | **DONE** |
| `spx service spinifex stop` | — | Spinifex daemon must be running | Stops the spinifex daemon service | **DONE** |
| `spx service spinifex status` | — | None | Reports spinifex daemon service status | **DONE** |
| `spx service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | `--config` required | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS | **DONE** |
| `spx service awsgw stop` | — | AWS gateway must be running | Stops the AWS gateway service | **DONE** |
| `spx service awsgw status` | — | None | Reports AWS gateway service status | **DONE** |
| `spx service spinifex-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | None | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `spinifexui` | **DONE** |
| `spx service spinifex-ui stop` | — | spinifex-ui must be running | Stops the spinifex-ui service | **DONE** |
| `spx service spinifex-ui status` | — | None | Reports spinifex-ui service status | **DONE** |
| `spx service vpcd start` | — | `--config` required, OVN/OVS installed | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) | **DONE** |
| `spx service vpcd stop` | — | vpcd must be running | Stops the vpcd service | **DONE** |
| `spx service vpcd status` | — | None | Reports vpcd service status | **DONE** |
### Cluster Inspection
Operational commands for inspecting cluster state. These fan out NATS requests to all nodes and aggregate responses.
| Command | Flags | Prerequisites | Basic Logic | Output Columns | Status |
|---------|-------|---------------|-------------|----------------|--------|
| `spx get nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Loads config → publishes to `spinifex.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` | NAME, STATUS, IP, REGION, AZ, UPTIME, VMs, SERVICES | **DONE** |
| `spx get vms` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `spx get instances` | INSTANCE, STATUS, TYPE, VCPU, MEM, NODE, IP, AGE | **DONE** |
### Resource Monitoring
| Command | Flags | Prerequisites | Basic Logic | Output | Status |
|---------|-------|---------------|-------------|--------|--------|
| `spx top nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability | Table 1: NAME, CPU (used/total), MEM (used/total), VMs. Table 2: INSTANCE TYPE, AVAILABLE, VCPU, MEMORY | **DONE** |
### Cluster Initialization
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured | **DONE** |
| `spx admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error) | **DONE** |
### Version
| Command | Flags | Prerequisites | Basic Logic | Status |
|---------|-------|---------------|-------------|--------|
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
### Cluster Operations
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
### Certificate Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
### Account Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials | 1. Create first account (ID 000000000001)<br>2. Sequential IDs (000000000002, etc.)<br>3. Admin user has full access<br>4. Credentials printed once | **DONE** |
| `spx admin account list` | — | Cluster must be running (NATS), master key must exist | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created | 1. List all accounts<br>2. Empty list (only global account) | **DONE** |
### Image Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
## AWS Commands
### EC2 - Instance Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → on termination, removes instance from placement group → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error<br>7. Launch with spread placement group (1 per node)<br>8. Launch with cluster placement group (all on one node)<br>9. Insufficient capacity for placement group (error) | **DONE** |
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:\*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list. Filters applied per-node before aggregation (reduces payload). Also applies to stopped/terminated instances via `describeInstancesFromKV()`. | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes<br>6. Filter by tag<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | `run-instances` (instance must exist in stopped state) | Gateway sends NATS `ec2.cmd.{instance-id}` → daemon restarts stopped QEMU process with same config → state transitions stopped→pending→running | 1. Start a stopped instance<br>2. Start already-running instance (error: IncorrectInstanceState)<br>3. Start with invalid instance ID<br>4. Verify volumes re-mount on start | **DONE** |
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_powerdown` for graceful shutdown → monitors heartbeat until QEMU exits → state transitions running→stopping→stopped | 1. Graceful stop of running instance<br>2. Force stop (kills QEMU process)<br>3. Stop already-stopped instance (error)<br>4. Verify ~30s heartbeat detection | **DONE** |
| `terminate-instances` | `--instance-ids`, `DeleteOnTermination` (per-volume flag, default true) | `--dry-run` | `run-instances` (instance must exist) | Gateway sends NATS to target node → daemon kills QEMU process → cleans up NBD mounts → deletes volumes with `DeleteOnTermination=true` via `volumeService.DeleteVolume()` (S3 cleanup of vol/, vol-efi/, vol-cloudinit/) → internal volumes (EFI, cloud-init) always cleaned up via `ebs.delete` NATS → volumes with `DeleteOnTermination=false` left in available state → state→terminated | 1. Terminate running instance<br>2. Terminate stopped instance<br>3. Terminate with DeleteOnTermination=true deletes volumes<br>4. Terminate with DeleteOnTermination=false preserves volumes<br>5. Terminate already-terminated (idempotent)<br>6. Internal volumes (EFI, cloud-init) always cleaned up<br>7. Invalid instance ID | **DONE** |
| `reboot-instances` | `--instance-ids` | `--dry-run` | `run-instances` (instance must be running) | Gateway validates instance IDs → sends EC2InstanceCommand with `RebootInstance=true` via NATS `ec2.cmd.{instanceId}` → daemon validates instance is in StateRunning (returns IncorrectInstanceState if stopped) → issues QMP `system_reset` → instance reboots without stopping, stays in running state | 1. Reboot running instance<br>2. Reboot multiple instances<br>3. Reboot stopped instance (error: IncorrectInstanceState)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Verify instance stays in running state after reboot | **DONE** |
| `describe-instance-types` | `--filters` (capacity filter only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, all other filters | None | Gateway fans out NATS `ec2.DescribeInstanceTypes` to all nodes → each daemon reports supported types (t3.micro/small/medium/large) with vCPU/memory specs → gateway deduplicates and returns | 1. List all instance types<br>2. Filter by specific type<br>3. Filter with `capacity=true` shows available slots<br>4. Verify vCPU/memory specs match hardware | **DONE** |
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data` | `--disable-api-termination`, `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must be stopped (in NATS KV) | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `spinifex-workers` queue group → daemon loads stopped instance from JetStream KV → applies attribute change → writes back to KV → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Modify running instance (error: NotFound — running instances not in KV)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Instance not stopped (error: IncorrectInstanceState)<br>6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>7. StateReason cleared on type change (recovery from capacity-unavailable)<br>8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>9. No attribute set (error: InvalidParameterValue)<br>10. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
| `get-console-output` | `--instance-id` | `--latest` (always returns latest), `--dry-run` | Instance must be running on a node | Gateway sends NATS `ec2.{instanceId}.GetConsoleOutput` (per-instance topic, routed to owning node) → daemon reads console log file from disk → returns last 64KB base64-encoded with timestamp. Always available regardless of serial console access setting (matches AWS behavior). | 1. Get output from running instance<br>2. Empty log file returns empty output<br>3. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
| `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, instanceInitiatedShutdownBehavior, disableApiTermination, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.DescribeInstanceAttribute` with `spinifex-workers` queue group → daemon checks running instances first (`d.Instances.VMS`), then stopped instances in JetStream KV → returns single attribute per call (matches AWS behavior). Stored attributes (`instanceType`, `userData`) return real values; unstored attributes return AWS defaults (`instanceInitiatedShutdownBehavior`=stop, `disableApiTermination`=false, etc.) | 1. Get instanceType from running instance<br>2. Get userData from stopped instance<br>3. Get default disableApiTermination<br>4. Invalid attribute name (error)<br>5. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
| `describe-instance-credit-specifications` | `--instance-ids` | `--filters`, `--max-results`, `--dry-run` | None | Gateway-only stub — returns `CpuCredits: "standard"` for each requested instance ID. No daemon round-trip. T-series credit mode is not persisted. | 1. Get credit spec for T-series instance<br>2. Multiple instance IDs | **DONE** |
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
### EC2 - Key Pair Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519) | `--key-format` (pem/ppk), `--tag-specifications`, `--dry-run` | None | NATS `ec2.CreateKeyPair` → daemon generates SSH keypair → stores public key in Predastore S3 (`/bucket/ec2/{name}.pub`) → returns private key material (one-time) | 1. Create RSA key pair<br>2. Create ED25519 key pair<br>3. Duplicate key name (error: InvalidKeyPair.Duplicate)<br>4. Verify key material returned only on creation | **DONE** |
| `describe-key-pairs` | `--key-names`, `--key-pair-ids`, `--filters` (key-pair-id, key-name, fingerprint, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → applies filters → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | Key must exist | NATS `ec2.DeleteKeyPair` → daemon deletes public key from Predastore S3 → returns success | 1. Delete existing key pair<br>2. Delete non-existent key (idempotent, no error)<br>3. Verify key no longer in describe-key-pairs | **DONE** |
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | None | NATS `ec2.ImportKeyPair` → daemon stores provided public key in Predastore S3 → returns key name and fingerprint | 1. Import valid SSH public key<br>2. Import invalid key material (error)<br>3. Import duplicate name (error)<br>4. Verify imported key usable with run-instances | **DONE** |
### EC2 - AMI Image Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
| `copy-image` | — | `--source-image-id`, `--source-region`, `--name`, `--description`, `--encrypted`, `--kms-key-id`, `--client-token`, `--copy-image-tags`, `--tag-specifications` | Source AMI must exist | Copy AMI data between regions/nodes in Predastore → create new AMI metadata with new ami-ID → return new ami-ID | 1. Copy image within same region<br>2. Copy non-existent image (error)<br>3. Verify copied image independent of source | **NOT STARTED** |
| `import-image` | — | `--disk-containers` (Format, Url/S3Bucket+S3Key), `--description`, `--architecture`, `--platform` | S3 bucket with disk image | Download disk image from S3 → convert format (VMDK/VHD/RAW→QCOW2) → create viperblock volume → register as AMI | 1. Import QCOW2 image<br>2. Import RAW image<br>3. Invalid format (error)<br>4. Verify imported image launchable | **NOT STARTED** |
| `describe-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Read specific attribute from AMI metadata in S3 → return attribute value | 1. Get description attribute<br>2. Get launch permission<br>3. Invalid attribute name (error) | **NOT STARTED** |
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
### EC2 - Volume (EBS) Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata), `--filters` (volume-id, status, size, volume-type, attachment.instance-id, attachment.status, attachment.device, availability-zone, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → applies filters → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value<br>6. Filter by status, size, volume-type<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-volume` | `--volume-id`, `--size`, `--volume-type`, `--iops` | `--throughput`, `--dry-run`, `--multi-attach-enabled` | Volume must exist | NATS `ec2.ModifyVolume` → daemon sends resize request to viperblock → NBD does not support live resize, instance must be stopped → returns modification state | 1. Increase volume size<br>2. Modify volume type<br>3. Decrease size (error - not supported)<br>4. Modify attached volume (requires stop/start) | **DONE** |
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3 only), `--snapshot-id` (creates volume from snapshot) | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput`, `--tag-specifications` | Valid AZ configured via `spinifex init` | Gateway validates input → NATS `ec2.CreateVolume` → daemon generates vol-ID via viperblock → creates volume (empty or from snapshot) of specified size → persists config.json to Predastore S3 → returns vol-ID with state=available | 1. Create 80GB gp3 volume<br>2. Boundary sizes (1 GiB min, 16384 GiB max)<br>3. Invalid AZ (error)<br>4. Verify volume in describe-volumes<br>5. Unsupported volume type (error - only gp3)<br>6. Size out of range (error)<br>7. Create from snapshot | **DONE** |
| `delete-volume` | `--volume-id` | `--dry-run` | Volume must exist and be detached (state=available) | Gateway validates vol- prefix → NATS `ec2.DeleteVolume` → daemon confirms state=available and no AttachedInstance → NATS `ebs.delete` to viperblockd (stops nbdkit/WAL) → deletes S3 objects under vol-id/, vol-id-efi/, vol-id-cloudinit/ → returns success | 1. Delete detached volume<br>2. Delete attached volume (error: VolumeInUse)<br>3. Delete non-existent volume (error: InvalidVolume.NotFound)<br>4. Verify volume gone from describe-volumes<br>5. Malformed volume ID (error: InvalidVolumeID.Malformed)<br>6. Double delete (idempotent NotFound) | **DONE** |
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (optional, auto-assigns `/dev/sd[f-p]`) | `--dry-run` | Volume must exist (available), instance must exist (running) | Gateway sends to `ec2.cmd.{instanceId}` → daemon validates volume (Predastore) → `ebs.mount` via NATS (viperblock starts NBD server) → QMP `blockdev-add` (nbd-{volId}) → QMP `device_add` (virtio-blk-pci, vdisk-{volId}) → three-phase rollback on failure → update EBSRequests + BlockDeviceMappings → persist to JetStream + Predastore → respond with VolumeAttachment | 1. Attach volume to running instance<br>2. Auto-assign device name<br>3. Attach already-attached volume (VolumeInUse)<br>4. Attach to non-existent instance (InvalidInstanceID.NotFound)<br>5. Attach to stopped instance (IncorrectInstanceState)<br>6. Volume not found (InvalidVolume.NotFound)<br>7. All device slots full (AttachmentLimitExceeded)<br>8. Volume persists across stop/start | **DONE** |
| `detach-volume` | `--volume-id`, `--instance-id` (optional, resolved via DescribeVolumes), `--device` (optional cross-check), `--force` | `--dry-run` | Volume must be attached, instance must be running | Gateway resolves InstanceId if omitted (via DescribeVolumes) → sends to `ec2.cmd.{instanceId}` → daemon validates (running, attached, not boot/EFI/CloudInit, device match) → three-phase hot-unplug: QMP `device_del` (force continues on failure) → QMP `blockdev-del` (abort if fails, preserves state to prevent double-attach) → `ebs.unmount` via NATS (best-effort) → remove from EBSRequests + BlockDeviceMappings → update volume metadata to available → persist state → respond with VolumeAttachment (state=detaching) | 1. Detach with explicit InstanceId<br>2. Detach without InstanceId (gateway resolution)<br>3. Detach with correct --device cross-check<br>4. Missing VolumeId (InvalidParameterValue)<br>5. Volume not attached (IncorrectState)<br>6. Nonexistent volume (InvalidVolume.NotFound)<br>7. Nonexistent instance (InvalidInstanceID.NotFound)<br>8. Instance not running (IncorrectInstanceState)<br>9. Device mismatch (InvalidParameterValue)<br>10. Boot volume protection (OperationNotPermitted)<br>11. Force flag (continues past device_del failure)<br>12. Volume reusability (re-attach after detach) | **DONE** |
| `describe-volume-status` | `--volume-ids`, `--filters` (volume-id, volume-status.status, availability-zone) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → applies filters → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | None | Query pending/completed volume modifications → return modification state, progress, original/target size | 1. Check in-progress modification<br>2. Check completed modification<br>3. No modifications returns empty | **NOT STARTED** |
### EC2 - Snapshot Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-snapshot` | `--volume-id`, `--description`, `--tag-specifications` | `--dry-run` | Volume must exist in Predastore | Gateway validates vol- prefix → NATS `ec2.CreateSnapshot` → daemon reads VolumeConfig from Predastore → generates snap-ID via viperblock → stores SnapshotConfig (metadata-only, points to source volume) as completed → returns Snapshot with state=completed, progress=100% | 1. Snapshot from valid volume<br>2. Missing volume ID (InvalidParameterValue)<br>3. Volume not found (InvalidVolume.NotFound)<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Snapshot with description<br>6. Snapshot with tag specifications | **DONE** |
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist, instance-volume attachment tracking | Create snapshots of all volumes attached to instance → return list of snapshot IDs. Blocked: requires instance-volume attachment tracking to discover which volumes to snapshot. | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
| `delete-snapshot` | `--snapshot-id` | `--dry-run` | Snapshot must exist | Gateway validates snap- prefix → NATS `ec2.DeleteSnapshot` → daemon verifies snapshot exists in Predastore → lists and deletes all objects under snapshot prefix → returns success | 1. Delete existing snapshot<br>2. Delete non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing snapshot ID (InvalidParameterValue)<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
| `describe-snapshots` | `--snapshot-ids`, `--filters` (snapshot-id, status, volume-id, volume-size, owner-id, tag:\*) | `--owner-ids`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → applies filters → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed)<br>5. Filter by status, volume-id, volume-size<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
### EC2 - Tags
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-tags` | `--resources`, `--tags` (Key=,Value=) | `--dry-run` | None | Gateway validates input (resources non-empty, tags non-empty, keys non-empty) → NATS `ec2.CreateTags` → daemon merges tags into per-resource JSON in Predastore S3 (`tags/{resourceId}.json`) → return success | 1. Tag an instance<br>2. Tag multiple resources at once<br>3. Overwrite existing tag value<br>4. Empty resources list (error: MissingParameter)<br>5. Empty tags list (error: MissingParameter)<br>6. Empty tag key (error: InvalidParameterValue) | **DONE** |
| `delete-tags` | `--resources`, `--tags` | `--dry-run` | None | Gateway validates input (resources non-empty) → NATS `ec2.DeleteTags` → daemon removes specified tag keys from resource JSON, or all tags if no keys specified → return success | 1. Delete specific tag by key<br>2. Delete all tags (no tags specified)<br>3. Empty resources list (error: MissingParameter) | **DONE** |
| `describe-tags` | `--filters` (resource-id, resource-type, key, value) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeTags` → daemon lists all `tags/*.json` from Predastore S3 → applies filters (resource-id, resource-type, key, value) → returns tag list with resource type auto-detected from ID prefix | 1. List all tags<br>2. Filter by resource-id<br>3. Filter by resource-type (instance, volume, image, snapshot, vpc, subnet, security-group, route-table, internet-gateway, egress-only-internet-gateway)<br>4. Filter by key<br>5. Filter by value<br>6. Empty result when no tags exist | **DONE** |
### EC2 - Regions & Availability Zones
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-regions` | *(returns configured region, endpoint, opt-in status from config — input params ignored)* | `--region-names`, `--filters`, `--all-regions`, `--dry-run` | None | Return configured region from spinifex init config with Endpoint and OptInStatus → local-only response, no NATS | 1. List all regions<br>2. Filter by region name<br>3. Verify endpoint URL returned<br>4. Verify OptInStatus returned | **DONE** |
| `describe-availability-zones` | *(returns configured AZ, region, zone ID, state, opt-in status from config — input params ignored)* | `--zone-names`, `--filters`, `--all-availability-zones` | None | Return configured AZ from spinifex init config with zone ID, state, group name, network border group → local-only response, no NATS | 1. List all AZs<br>2. Filter by zone name<br>3. Verify zone state is available<br>4. Verify region name matches config | **DONE** |
### EC2 - Account Attributes
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-account-attributes` | `--attribute-names` | `--dry-run` | None | Gateway parses input → returns static account attributes: supported-platforms=VPC, default-vpc=none, max-instances=100, vpc-max-security-groups-per-interface=5, max-elastic-ips=5, vpc-max-elastic-ips=20 → local-only response, no NATS | 1. List all account attributes<br>2. Filter by attribute name<br>3. Verify all 6 attributes returned with correct values | **DONE** |
### EC2 - Account Settings (Deferred)
Persistence layer works (NATS JetStream KV), but stored values are not yet enforced by downstream services. Will be completed post-merge.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `enable-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.EnableEbsEncryptionByDefault` with `spinifex-workers` queue group → daemon stores `EbsEncryptionByDefault=true` in JetStream KV (`spinifex-ec2-account-settings` bucket) → return `EbsEncryptionByDefault=true`. Enforcement deferred: stored value not yet checked by CreateVolume/CreateSnapshot. | 1. Enable encryption<br>2. Verify via get | **STARTED** (enforcement pending) |
| `disable-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.DisableEbsEncryptionByDefault` with `spinifex-workers` queue group → daemon stores `EbsEncryptionByDefault=false` in JetStream KV → return `EbsEncryptionByDefault=false`. | 1. Disable encryption<br>2. Verify via get | **STARTED** (enforcement pending) |
| `get-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.GetEbsEncryptionByDefault` with `spinifex-workers` queue group → daemon reads `EbsEncryptionByDefault` from JetStream KV → return current state. | 1. Get default state<br>2. Verify matches last enable/disable | **STARTED** (enforcement pending) |
| `enable-serial-console-access` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.EnableSerialConsoleAccess` with `spinifex-workers` queue group → daemon stores `SerialConsoleAccessEnabled=true` in JetStream KV (`spinifex-ec2-account-settings` bucket) → return enabled=true. Stored for future interactive serial console support (not currently enforced by any operation). | 1. Enable access<br>2. Verify via get | **STARTED** (enforcement pending) |
| `disable-serial-console-access` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.DisableSerialConsoleAccess` with `spinifex-workers` queue group → daemon stores `SerialConsoleAccessEnabled=false` in JetStream KV → return enabled=false. Stored for future interactive serial console support (not currently enforced by any operation). | 1. Disable access<br>2. Verify via get | **STARTED** (enforcement pending) |
| `get-serial-console-access-status` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.GetSerialConsoleAccessStatus` with `spinifex-workers` queue group → daemon reads `SerialConsoleAccessEnabled` from JetStream KV → return current state. | 1. Get current status<br>2. Verify matches last enable/disable | **DONE** |
| `enable-snapshot-block-public-access` | — | `--state` | None | Store snapshot block public access state in JetStream KV | 1. Enable with block-all-sharing<br>2. Enable with block-new-sharing | **NOT STARTED** (enforcement pending) |
| `disable-snapshot-block-public-access` | — | `--dry-run` | None | Clear snapshot block public access state in JetStream KV | 1. Disable access | **NOT STARTED** (enforcement pending) |
| `get-snapshot-block-public-access-state` | — | `--dry-run` | None | Read snapshot block public access state from JetStream KV | 1. Get current state | **NOT STARTED** (enforcement pending) |
| `enable-image-block-public-access` | — | `--image-block-public-access-state` | None | Store image block public access state in JetStream KV | 1. Enable with block-new-sharing | **NOT STARTED** (enforcement pending) |
| `disable-image-block-public-access` | — | `--dry-run` | None | Clear image block public access state in JetStream KV | 1. Disable access | **NOT STARTED** (enforcement pending) |
| `get-image-block-public-access-state` | — | `--dry-run` | None | Read image block public access state from JetStream KV | 1. Get current state | **NOT STARTED** (enforcement pending) |
| `modify-instance-metadata-defaults` | — | `--http-tokens`, `--http-put-response-hop-limit`, `--http-endpoint`, `--instance-metadata-tags` | None | Store instance metadata defaults in JetStream KV | 1. Set httpTokens=required<br>2. Set hop limit | **NOT STARTED** (enforcement pending) |
| `get-instance-metadata-defaults` | — | `--dry-run` | None | Read instance metadata defaults from JetStream KV | 1. Get current defaults | **NOT STARTED** (enforcement pending) |
### EC2 - Egress-Only Internet Gateway
Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGWs are stored in `spinifex-eigw` KV bucket but have no effect on network topology (no vpcd events published, unlike IGW which publishes `vpc.igw-attach`/`vpc.igw-detach`).
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-egress-only-internet-gateway` | `--vpc-id`, `--tag-specifications` | `--client-token`, `--dry-run` | VPC must exist | Gateway validates VpcId → NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates EgressOnlyIGWRecord in `spinifex-eigw` KV bucket with state=attached (always attached at creation, unlike IGW) → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **STARTED** (KV only, no OVN) |
| `delete-egress-only-internet-gateway` | `--egress-only-internet-gateway-id` | `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from `spinifex-eigw` KV → returns ReturnCode=true. No detach required (unlike IGW). | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **STARTED** (KV only, no OVN) |
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids`, `--filters` (egress-only-internet-gateway-id, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → applies filters → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list<br>4. Unknown filter returns InvalidParameterValue | **STARTED** (KV only, no OVN) |
### EC2 - Placement Groups
Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket, keyed by `{accountID}.{groupName}`. All mutations use CAS (compare-and-swap) with 5-retry limit for optimistic concurrency. Supported strategies: **spread** (strict 1 instance per node) and **cluster** (all instances pinned to single node). Partition strategy is rejected. Internal NATS operations (ReserveSpreadNodes, FinalizeSpreadInstances, ReleaseSpreadNodes, RemoveInstanceFromPlacementGroup, ReserveClusterNode, FinalizeClusterInstances) support atomic multi-node coordination for RunInstances and TerminateInstances.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level` (hardcoded to "host"), `--tag-specifications`, `--dry-run` | None | Gateway validates GroupName + Strategy → NATS `ec2.CreatePlacementGroup` with `spinifex-workers` queue group → daemon generates pg-ID (pg-{16hex}) → stores PlacementGroupRecord in `spinifex-placement-groups` KV with state=available, SpreadLevel="host" for spread → rejects partition strategy (InvalidParameterValue) → rejects duplicate names (InvalidPlacementGroup.Duplicate) → returns PlacementGroup | 1. Create with spread strategy<br>2. Create with cluster strategy<br>3. Create with partition strategy (error: InvalidParameterValue)<br>4. Duplicate name (error: InvalidPlacementGroup.Duplicate)<br>5. Missing GroupName (error)<br>6. Missing Strategy (error) | **DONE** |
| `delete-placement-group` | `--group-name` | `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists (InvalidPlacementGroup.Unknown) → counts instances in NodeInstances map → rejects if non-empty (InvalidPlacementGroup.InUse) → deletes from KV → returns success | 1. Delete empty group<br>2. Delete group with instances (error: InvalidPlacementGroup.InUse)<br>3. Delete non-existent group (error: InvalidPlacementGroup.Unknown) | **DONE** |
| `describe-placement-groups` | `--group-names`, `--group-ids`, `--filters` (strategy, state, spread-level, group-name) | `--dry-run` | None | NATS `ec2.DescribePlacementGroups` → daemon lists all keys from `spinifex-placement-groups` KV → filters by GroupNames, GroupIds, or Filters → returns list with Strategy, State, SpreadLevel, GroupId | 1. List all groups<br>2. Filter by name<br>3. Filter by group ID<br>4. Filter by strategy<br>5. Filter by state<br>6. Empty result when no groups exist | **DONE** |
### EC2 - Dedicated Hosts
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `allocate-hosts` | — | `--availability-zone`, `--instance-type`, `--quantity`, `--auto-placement`, `--tag-specifications` | None | NATS `ec2.AllocateHosts` → daemon allocates host resources → stores in JetStream KV with h-ID → return host ID list | 1. Allocate single host<br>2. Allocate multiple hosts<br>3. Missing AZ or instance type (error) | **NOT STARTED** |
| `describe-hosts` | — | `--host-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeHosts` → daemon lists hosts from KV → return host list with capacity and instance info | 1. List all hosts<br>2. Filter by host ID<br>3. Filter by instance type | **NOT STARTED** |
| `release-hosts` | — | `--host-ids` | Host must exist, no running instances | NATS `ec2.ReleaseHosts` → daemon verifies no instances on host → delete from KV → return success/failure per host | 1. Release empty host<br>2. Release host with instances (error)<br>3. Release non-existent host (error) | **NOT STARTED** |
### EC2 - IPv4 Pools
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-public-ipv4-pool` | — | `--tag-specifications`, `--dry-run` | None | NATS `ec2.CreatePublicIpv4Pool` → daemon creates pool in JetStream KV with ipv4pool-ID → return pool ID | 1. Create pool<br>2. Verify in describe | **NOT STARTED** |
| `delete-public-ipv4-pool` | — | `--pool-id`, `--dry-run` | Pool must exist and be empty | NATS `ec2.DeletePublicIpv4Pool` → daemon verifies pool empty → delete from KV → return success | 1. Delete empty pool<br>2. Delete pool with addresses (error) | **NOT STARTED** |
| `describe-public-ipv4-pools` | — | `--pool-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribePublicIpv4Pools` → daemon lists pools from KV → return pool list with address counts | 1. List all pools<br>2. Filter by pool ID | **NOT STARTED** |
### EC2 - DHCP Options
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-dhcp-options` | — | `--dhcp-configurations` (domain-name, domain-name-servers, ntp-servers, netbios-name-servers, netbios-node-type), `--tag-specifications` | None | NATS `ec2.CreateDhcpOptions` → daemon stores DHCP option set in JetStream KV with dopt-ID → return DHCP options with configurations | 1. Create with domain-name + DNS servers<br>2. Create with NTP servers<br>3. Verify in describe | **NOT STARTED** |
| `delete-dhcp-options` | — | `--dhcp-options-id`, `--dry-run` | DHCP options must exist and not be associated with a VPC | NATS `ec2.DeleteDhcpOptions` → daemon verifies not associated → delete from KV → return success | 1. Delete unassociated options<br>2. Delete associated options (error)<br>3. Delete non-existent (error) | **NOT STARTED** |
| `describe-dhcp-options` | — | `--dhcp-options-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeDhcpOptions` → daemon lists DHCP options from KV → return list with configurations | 1. List all DHCP options<br>2. Filter by ID | **NOT STARTED** |
| `associate-dhcp-options` | — | `--dhcp-options-id`, `--vpc-id`, `--dry-run` | DHCP options and VPC must exist | NATS `ec2.AssociateDhcpOptions` → daemon links DHCP options to VPC → update VPC metadata → return success | 1. Associate with VPC<br>2. Missing DHCP options ID (error)<br>3. Missing VPC ID (error) | **NOT STARTED** |
### EC2 - Capacity Reservations
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-capacity-reservation` | — | `--instance-type`, `--instance-platform`, `--availability-zone`, `--instance-count`, `--end-date`, `--end-date-type`, `--instance-match-criteria`, `--tag-specifications` | None | NATS `ec2.CreateCapacityReservation` → daemon stores reservation in JetStream KV with cr-ID → return reservation with state=active | 1. Create with required fields<br>2. Missing instance type (error)<br>3. Verify in describe | **NOT STARTED** |
| `cancel-capacity-reservation` | — | `--capacity-reservation-id`, `--dry-run` | Reservation must exist | NATS `ec2.CancelCapacityReservation` → daemon marks reservation as cancelled in KV → return success | 1. Cancel active reservation<br>2. Cancel non-existent (error) | **NOT STARTED** |
| `describe-capacity-reservations` | — | `--capacity-reservation-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeCapacityReservations` → daemon lists reservations from KV → return list with state and counts | 1. List all reservations<br>2. Filter by ID<br>3. Filter by instance type | **NOT STARTED** |
| `modify-capacity-reservation` | — | `--capacity-reservation-id`, `--instance-count`, `--end-date`, `--end-date-type` | Reservation must exist | NATS `ec2.ModifyCapacityReservation` → daemon updates reservation in KV → return success | 1. Modify instance count<br>2. Modify end date<br>3. Missing reservation ID (error) | **NOT STARTED** |
### EC2 - Elastic IP
EIP operations are conditionally available — only registered when external IPAM is configured (pool mode). EIPs are stored in `spinifex-eip-allocations` KV bucket. Association publishes `vpc.add-nat`/`vpc.delete-nat` events to vpcd for OVN NAT configuration.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `allocate-address` | `--public-ipv4-pool` (optional, specific IPAM pool), `--tag-specifications` | `--domain` (hardcoded vpc), `--dry-run` | EIP service must be initialized (external IPAM configured) | Gateway validates input → NATS `ec2.AllocateAddress` with `spinifex-workers` queue group → daemon allocates IP from external IPAM pool (named or best-matching) → creates EIPRecord with state=allocated in `spinifex-eip-allocations` KV → returns AllocationId, PublicIp, Domain=vpc, PublicIpv4Pool | 1. Allocate EIP from default pool<br>2. Allocate from specific pool<br>3. Pool exhausted (InsufficientAddressCapacity)<br>4. With tag specifications<br>5. Verify in describe-addresses | **DONE** |
| `release-address` | `--allocation-id` | `--dry-run` | EIP must exist and be disassociated | Gateway validates AllocationId non-empty → NATS `ec2.ReleaseAddress` → daemon retrieves EIP record from KV → checks state != associated (InvalidAddressLocked if still associated) → releases IP back to IPAM pool → deletes KV record → returns success | 1. Release disassociated EIP<br>2. Release associated EIP (error: InvalidAddressLocked)<br>3. Release non-existent (error: InvalidAllocationID.NotFound)<br>4. Missing AllocationId (error: MissingParameter) | **DONE** |
| `associate-address` | `--allocation-id`, `--network-interface-id` OR `--instance-id`, `--private-ip-address` (optional override) | `--dry-run`, `--allow-reassociation` | EIP and target ENI/instance must exist | Gateway validates AllocationId + one of NetworkInterfaceId/InstanceId required → NATS `ec2.AssociateAddress` → daemon retrieves EIP → resolves target ENI (by ID or instance lookup) → generates AssociationId → updates record state=associated with ENI/instance/VPC/private IP → publishes `vpc.add-nat` event to vpcd (OVN NAT configuration) → returns AssociationId | 1. Associate with instance<br>2. Associate with specific ENI<br>3. Associate with private IP override<br>4. Missing AllocationId (MissingParameter)<br>5. Missing target (MissingParameter)<br>6. Non-existent EIP (InvalidAllocationID.NotFound)<br>7. Non-existent ENI (InvalidNetworkInterfaceID.NotFound) | **DONE** |
| `disassociate-address` | `--association-id` | `--dry-run` | Association must exist | Gateway validates AssociationId non-empty → NATS `ec2.DisassociateAddress` → daemon scans KV to find EIP by AssociationId → publishes `vpc.delete-nat` event to vpcd → clears association fields → reverts state to allocated → updates KV → returns success | 1. Disassociate existing<br>2. Non-existent association (InvalidAssociationID.NotFound)<br>3. Missing AssociationId (MissingParameter) | **DONE** |
| `describe-addresses` | `--allocation-ids`, `--public-ips`, `--filters` (allocation-id, public-ip, instance-id, association-id, domain, tag:\*) | `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → applies filters → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP<br>4. Non-existent AllocationId returns error<br>5. Empty result when no EIPs<br>6. Filter by instance-id, domain<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-addresses-attribute` | `--allocation-ids` (optional filter) | `--attribute`, `--dry-run`, `--max-results`, `--next-token` | None | Gateway validates input → NATS `ec2.DescribeAddressesAttribute` with `spinifex-workers` queue group → daemon looks up EIP records by AllocationIds (or scans all account EIPs if none specified) → returns AddressAttribute list with AllocationId and PublicIp per entry. Unlike DescribeAddresses, returns empty list (not error) for non-existent AllocationIds. PtrRecord always nil (reverse-DNS not supported). | 1. List all EIP attributes<br>2. Filter by allocation IDs<br>3. Non-existent AllocationId returns empty (not error)<br>4. Nil input returns error | **DONE** |
### EC2 - NAT Gateway
NAT gateways are stored in `spinifex-vpc-nat-gateways` KV bucket. Deleted gateways are moved to `spinifex-vpc-deleted-nat-gateways` with a 1-hour TTL for Terraform polling. SNAT rule coordination uses `vpc.add-nat-gateway` and `vpc.delete-nat-gateway` NATS events (published via route table service on CreateRoute and during DeleteNatGateway respectively).
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type` (public only), `--tag-specifications`, `--dry-run` | Subnet must exist, EIP must be allocated and unassociated | Gateway validates SubnetId + AllocationId (required) → NATS `ec2.CreateNatGateway` with `spinifex-workers` queue group → daemon verifies subnet exists in `spinifex-vpc-subnets` KV → verifies EIP exists in `spinifex-vpc-eips` KV and has no AssociationId (Resource.AlreadyAssociated if in use) → generates nat-ID → stores NatGatewayRecord in `spinifex-vpc-nat-gateways` KV with state=available → returns NatGateway with addresses | 1. Create NAT gateway (happy path)<br>2. Subnet not found (InvalidSubnetID.NotFound)<br>3. EIP not found (InvalidAllocationID.NotFound)<br>4. EIP already associated (Resource.AlreadyAssociated)<br>5. Missing SubnetId or AllocationId (MissingParameter) | **DONE** |
| `delete-nat-gateway` | `--nat-gateway-id` | `--dry-run` | NAT gateway must exist | Gateway validates NatGatewayId (required) → NATS `ec2.DeleteNatGateway` → daemon retrieves record from KV → scans all route tables in VPC for routes targeting this NAT gateway → publishes `vpc.delete-nat-gateway` event per affected subnet (SNAT rule cleanup) → sets state=deleted → stores in `spinifex-vpc-deleted-nat-gateways` with 1-hour TTL → deletes from active KV bucket | 1. Delete existing NAT gateway<br>2. Delete non-existent (InvalidNatGatewayID.NotFound)<br>3. Deleted gateway visible when queried by ID (state=deleted)<br>4. Deleted gateway excluded from general list queries (TTL auto-expire) | **DONE** |
| `describe-nat-gateways` | `--nat-gateway-ids`, `--filters` (vpc-id, state) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeNatGateways` → daemon scans `spinifex-vpc-nat-gateways` KV → applies NatGatewayIds filter and/or vpc-id/state filters → for specific IDs not found in active bucket, checks deleted bucket → returns InvalidNatGatewayID.NotFound if ID never existed → returns list with state, addresses, subnet, VPC | 1. List all NAT gateways<br>2. Filter by specific IDs<br>3. Filter by state<br>4. Filter by vpc-id<br>5. Non-existent ID (InvalidNatGatewayID.NotFound)<br>6. Deleted gateway visible by ID query | **DONE** |
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | NAT gateway must exist (private type) | NATS `ec2.AssignPrivateNatGatewayAddress` → daemon assigns private IPs to NAT gateway → return assigned addresses | 1. Assign address<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
| `associate-nat-gateway-address` | — | `--nat-gateway-id`, `--allocation-ids` | NAT gateway and EIPs must exist | NATS `ec2.AssociateNatGatewayAddress` → daemon associates EIPs with NAT gateway → return association info | 1. Associate EIP<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
### EC2 - VPC Networking (Core)
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, routers, and ACLs. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). Security groups stored in `spinifex-vpc-security-groups` KV bucket with optimistic locking on rule updates.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `spinifex-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `spinifex-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Invalid CIDR format (error: InvalidVpcRange)<br>3. CIDR prefix outside /16–/28 (error)<br>4. Verify in describe-vpcs<br>5. Tags applied at creation | **DONE** |
| `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `spinifex-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `spinifex-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC<br>2. Delete VPC with subnets (DependencyViolation)<br>3. Delete non-existent VPC (error) | **DONE** |
| `describe-vpcs` | `--vpc-ids`, `--filters` (vpc-id, state, cidr-block, is-default, owner-id, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → applies filters → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error<br>4. Filter by state, cidr-block, is-default<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-vpc-attribute` | `--vpc-id`, `--enable-dns-hostnames`, `--enable-dns-support`, `--enable-network-address-usage-metrics` | `--dry-run` | VPC must exist | Gateway validates VpcId non-empty → NATS `ec2.ModifyVpcAttribute` → daemon retrieves VPC record from `spinifex-vpc-vpcs` KV → updates boolean flags if provided → updates KV with optimistic locking → returns empty output | 1. Enable DNS hostnames<br>2. Enable DNS support<br>3. Enable network address usage metrics<br>4. Missing VPC ID (MissingParameter)<br>5. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `describe-vpc-attribute` | `--vpc-id`, `--attribute` (enableDnsHostnames, enableDnsSupport, enableNetworkAddressUsageMetrics) | `--dry-run` | VPC must exist | Gateway validates VpcId + Attribute non-empty → NATS `ec2.DescribeVpcAttribute` → daemon retrieves VPC record → returns single attribute as AttributeBooleanValue (one attribute per call, matches AWS behavior) → validates attribute name | 1. Get enableDnsHostnames<br>2. Get enableDnsSupport<br>3. Get enableNetworkAddressUsageMetrics<br>4. Invalid attribute name (InvalidParameterValue)<br>5. Missing VPC ID (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR<br>2. Overlapping CIDR (error)<br>3. Max CIDR blocks exceeded (error) | **NOT STARTED** |
| `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR<br>2. Remove primary CIDR (error) | **NOT STARTED** |
| `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `spinifex-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)<br>3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)<br>4. CIDR prefix outside /16–/28 (error)<br>5. Missing VPC or CIDR (error: MissingParameter) | **DONE** |
| `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `spinifex-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet<br>2. Delete non-existent subnet (error) | **DONE** |
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id, subnet-id, availability-zone, cidr-block, state, default-for-az, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies filters → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error<br>5. Filter by availability-zone, cidr-block, state<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-subnet-attribute` | `--subnet-id`, `--map-public-ip-on-launch` | `--assign-ipv6-address-on-creation`, `--dry-run` | Subnet must exist | Gateway validates SubnetId non-empty → NATS `ec2.ModifySubnetAttribute` → daemon retrieves Subnet record from `spinifex-vpc-subnets` KV → updates MapPublicIpOnLaunch flag if provided → updates KV with optimistic locking → returns empty output | 1. Enable auto-assign public IP<br>2. Disable auto-assign public IP<br>3. Missing SubnetId (MissingParameter)<br>4. Non-existent subnet (InvalidSubnetID.NotFound) | **DONE** |
| `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR<br>2. Missing subnet ID (error) | **NOT STARTED** |
| `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR<br>2. Invalid association (error) | **NOT STARTED** |
| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)<br>3. Verify default egress rule (allow all)<br>4. Verify no default ingress rules<br>5. Missing GroupName (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound)<br>7. With tag specifications | **DONE** |
| `delete-security-group` | `--group-id` | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.DeleteSecurityGroup` → daemon retrieves SG from KV → deletes from `spinifex-vpc-security-groups` KV → publishes `vpc.delete-sg` event to vpcd → returns success | 1. Delete existing SG<br>2. Delete non-existent SG (error: InvalidGroup.NotFound)<br>3. Missing GroupId (MissingParameter) | **DONE** |
| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name, group-id, description, ip-permission.cidr, tag:\*) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → applies filters (AND logic across filter names, OR across values) → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name<br>4. Filter by GroupId<br>5. Non-existent GroupId (InvalidGroup.NotFound)<br>6. Empty result when no SGs<br>7. Filter by description, ip-permission.cidr<br>8. Unknown filter returns InvalidParameterValue | **DONE** |
| `authorize-security-group-ingress` | `--group-id`, `--ip-permissions` (IpProtocol, FromPort, ToPort, IpRanges/CidrIp, UserIdGroupPairs/GroupId) | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.AuthorizeSecurityGroupIngress` → daemon retrieves SG → converts IpPermissions to SGRule objects → appends to IngressRules → updates KV with optimistic locking (revision check) → publishes `vpc.update-sg` event to vpcd → returns true | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Allow from source security group<br>4. Missing GroupId (MissingParameter)<br>5. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `authorize-security-group-egress` | `--group-id`, `--ip-permissions` | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.AuthorizeSecurityGroupEgress` → daemon retrieves SG → appends rules to EgressRules → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR<br>3. Missing GroupId (MissingParameter)<br>4. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `revoke-security-group-ingress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupIngress` → daemon retrieves SG → removes matching rules from IngressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `revoke-security-group-egress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupEgress` → daemon retrieves SG → removes matching rules from EgressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `spinifex-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW<br>2. Verify in describe-internet-gateways<br>3. Tags applied at creation | **DONE** |
| `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (ResourceAlreadyAssociated)<br>3. Non-existent IGW (error)<br>4. Missing params (MissingParameter) | **DONE** |
| `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW<br>2. Detach from wrong VPC (GatewayNotAttached)<br>3. Detach unattached IGW (error) | **DONE** |
| `delete-internet-gateway` | `--internet-gateway-id` | `--dry-run` | IGW must be detached | NATS `ec2.DeleteInternetGateway` → daemon validates IGW exists and VpcId is empty (DependencyViolation if attached) → deletes from `spinifex-igw` KV | 1. Delete detached IGW<br>2. Delete attached IGW (DependencyViolation)<br>3. Delete non-existent (error) | **DONE** |
| `describe-internet-gateways` | `--internet-gateway-ids`, `--filters` (internet-gateway-id, attachment.vpc-id, attachment.state, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → applies filters → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info<br>4. Filter by attachment.vpc-id, attachment.state<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
### EC2 - Route Tables
Route tables are stored in `spinifex-vpc-route-tables` JetStream KV bucket, keyed by `{accountID}.{routeTableId}`. Each route table has a Routes array (with local route pre-populated) and an Associations array. All operations validate VPC consistency. When a route targeting a NAT gateway is created, `vpc.add-nat-gateway` events are published per associated subnet for vpcd SNAT rule creation.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-route-table` | `--vpc-id` | `--tag-specifications`, `--dry-run` | VPC must exist | Gateway validates VpcId (required) → NATS `ec2.CreateRouteTable` with `spinifex-workers` queue group → daemon verifies VPC exists in `spinifex-vpc-vpcs` KV → retrieves VPC CIDR → generates rtb-ID → creates RouteTableRecord with IsMain=false, local route pre-populated (destination=VPC CIDR, gateway="local"), empty associations → stores in `spinifex-vpc-route-tables` KV → returns RouteTable | 1. Create route table with local route (happy path)<br>2. VPC not found (InvalidVpcID.NotFound)<br>3. Missing VpcId (MissingParameter) | **DONE** |
| `delete-route-table` | `--route-table-id` | `--dry-run` | Route table must exist, not main, no subnet associations | Gateway validates RouteTableId (required) → NATS `ec2.DeleteRouteTable` → daemon retrieves record → rejects if IsMain=true (DependencyViolation) → rejects if has non-main subnet associations (DependencyViolation) → deletes from KV → returns success | 1. Delete custom route table<br>2. Delete main route table (DependencyViolation)<br>3. Delete table with subnet associations (DependencyViolation)<br>4. Delete non-existent (InvalidRouteTableID.NotFound) | **DONE** |
| `describe-route-tables` | `--route-table-ids`, `--filters` (vpc-id, route-table-id, association.main, association.route-table-association-id, association.subnet-id, route.destination-cidr-block, route.gateway-id) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeRouteTables` → daemon scans all keys in `spinifex-vpc-route-tables` KV → filters by RouteTableIds if specified → applies all provided filters (AND logic) → returns error for non-existent requested IDs → converts records to EC2 RouteTable objects | 1. List all route tables<br>2. Filter by vpc-id<br>3. Filter by association.main (true/false)<br>4. Non-existent ID (InvalidRouteTableID.NotFound)<br>5. Empty result when no route tables | **DONE** |
| `create-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id`, `--nat-gateway-id` | `--egress-only-internet-gateway-id`, `--vpc-peering-connection-id`, `--dry-run` | Route table and target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.CreateRoute` → daemon retrieves route table → checks for duplicate destination (RouteAlreadyExists) → validates exactly one of GatewayId/NatGatewayId provided → if GatewayId: verifies IGW exists in `spinifex-igw` KV and is attached to same VPC → if NatGatewayId: verifies NAT GW exists in `spinifex-vpc-nat-gateways` KV and is in same VPC → for NAT GW routes, publishes `vpc.add-nat-gateway` event per associated subnet → appends RouteRecord (state=active, origin=CreateRoute) → updates KV → returns Return=true | 1. Add IGW route (happy path)<br>2. Duplicate destination (RouteAlreadyExists)<br>3. IGW not found (InvalidInternetGatewayID.NotFound)<br>4. NAT GW not found (InvalidNatGatewayID.NotFound)<br>5. Gateway not in same VPC (InvalidParameterValue)<br>6. Missing RouteTableId or DestinationCidrBlock (MissingParameter) | **DONE** |
| `delete-route` | `--route-table-id`, `--destination-cidr-block` | `--dry-run` | Route must exist | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.DeleteRoute` → daemon retrieves route table → finds matching route → rejects if gateway is "local" (InvalidParameterValue — local routes cannot be deleted) → removes route from Routes array → updates KV → returns success | 1. Delete IGW route (happy path, local route remains)<br>2. Delete local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. Missing parameters (MissingParameter) | **DONE** |
| `replace-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` | `--nat-gateway-id`, `--dry-run` | Route must exist, new target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock + GatewayId (required) → NATS `ec2.ReplaceRoute` → daemon retrieves route table → finds matching route → rejects if local route → verifies IGW exists and is attached to same VPC → updates route GatewayId, clears NatGatewayId, sets state=active → updates KV → returns success | 1. Replace route target (IGW swap)<br>2. Replace local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. IGW not found (InvalidInternetGatewayID.NotFound) | **DONE** |
| `associate-route-table` | `--route-table-id`, `--subnet-id` | `--gateway-id`, `--dry-run` | Route table and subnet must exist in same VPC | Gateway validates RouteTableId + SubnetId (required) → NATS `ec2.AssociateRouteTable` → daemon retrieves route table → verifies subnet exists and is in same VPC → scans ALL route tables in VPC to check subnet isn't already explicitly associated (Resource.AlreadyAssociated) → generates rtbassoc-ID → appends AssociationRecord (Main=false) → updates KV → returns AssociationId with state=associated | 1. Associate subnet (happy path)<br>2. Duplicate subnet association (Resource.AlreadyAssociated)<br>3. Subnet not found (InvalidSubnetID.NotFound)<br>4. Subnet in different VPC (InvalidParameterValue)<br>5. Missing parameters (MissingParameter) | **DONE** |
| `disassociate-route-table` | `--association-id` | `--dry-run` | Association must exist, not main table association | Gateway validates AssociationId (required) → NATS `ec2.DisassociateRouteTable` → daemon scans ALL route tables in account to find matching AssociationId → rejects if Main=true (InvalidParameterValue — main associations are immutable) → removes association from Associations array → updates KV → returns success | 1. Disassociate subnet (happy path)<br>2. Disassociate main table (InvalidParameterValue)<br>3. Association not found (InvalidAssociationID.NotFound) | **DONE** |
| `replace-route-table-association` | `--association-id`, `--route-table-id` | `--dry-run` | Association and new route table must exist | Gateway validates AssociationId + RouteTableId (required) → NATS `ec2.ReplaceRouteTableAssociation` → daemon verifies new route table exists → scans all route tables to find matching AssociationId → if Main=true: atomically swaps IsMain flag between old and new table → two-phase commit: removes association from old table, creates new AssociationId on new table → compensating write on failure (logs CRITICAL if compensation fails) → returns new AssociationId with state=associated | 1. Move subnet between tables (happy path)<br>2. Replace main table association (swaps IsMain flags)<br>3. Association not found (InvalidAssociationID.NotFound)<br>4. Route table not found (InvalidRouteTableID.NotFound) | **DONE** |
### EC2 - VPC Peering
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpc-peering-connection` | — | `--vpc-id`, `--peer-vpc-id`, `--peer-owner-id`, `--peer-region`, `--tag-specifications` | Both VPCs must exist | NATS `ec2.CreateVpcPeeringConnection` → daemon creates peering in KV with pcx-ID → state=pending-acceptance → return peering connection | 1. Create peering between two VPCs<br>2. Missing VPC ID (error)<br>3. Overlapping CIDRs (error) | **NOT STARTED** |
| `accept-vpc-peering-connection` | — | `--vpc-peering-connection-id`, `--dry-run` | Peering must exist in pending-acceptance state | NATS `ec2.AcceptVpcPeeringConnection` → daemon updates state to active → configure OVS cross-VPC flows → return peering | 1. Accept pending peering<br>2. Accept already active (error) | **NOT STARTED** |
| `reject-vpc-peering-connection` | — | `--vpc-peering-connection-id`, `--dry-run` | Peering must exist in pending-acceptance state | NATS `ec2.RejectVpcPeeringConnection` → daemon updates state to rejected → return peering | 1. Reject pending peering<br>2. Reject already active (error) | **NOT STARTED** |
| `delete-vpc-peering-connection` | — | `--vpc-peering-connection-id`, `--dry-run` | Peering must exist | NATS `ec2.DeleteVpcPeeringConnection` → daemon removes OVS cross-VPC flows → delete from KV → return success | 1. Delete active peering<br>2. Delete non-existent (error) | **NOT STARTED** |
| `describe-vpc-peering-connections` | — | `--vpc-peering-connection-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeVpcPeeringConnections` → daemon lists peerings from KV → return list with state | 1. List all peerings<br>2. Filter by ID<br>3. Filter by VPC | **NOT STARTED** |
| `modify-vpc-peering-connection-options` | — | `--vpc-peering-connection-id`, `--requester-peering-connection-options`, `--accepter-peering-connection-options` | Peering must be active | NATS `ec2.ModifyVpcPeeringConnectionOptions` → daemon updates peering options in KV → return modified options | 1. Enable DNS resolution<br>2. Missing peering ID (error) | **NOT STARTED** |
### EC2 - VPC Endpoints
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpc-endpoint` | — | `--vpc-id`, `--service-name`, `--vpc-endpoint-type` (Gateway/Interface), `--route-table-ids`, `--subnet-ids`, `--tag-specifications` | VPC must exist | NATS `ec2.CreateVpcEndpoint` → daemon creates endpoint in KV with vpce-ID → return endpoint | 1. Create Gateway endpoint for S3<br>2. Create Interface endpoint<br>3. Missing VPC ID (error) | **NOT STARTED** |
| `delete-vpc-endpoints` | — | `--vpc-endpoint-ids`, `--dry-run` | Endpoints must exist | NATS `ec2.DeleteVpcEndpoints` → daemon deletes endpoints from KV → return success/failure per endpoint | 1. Delete existing endpoint<br>2. Delete non-existent (error) | **NOT STARTED** |
| `describe-vpc-endpoints` | — | `--vpc-endpoint-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeVpcEndpoints` → daemon lists endpoints from KV → return list with state and type | 1. List all endpoints<br>2. Filter by VPC<br>3. Filter by service name | **NOT STARTED** |
| `describe-vpc-endpoint-services` | — | `--service-names`, `--filters`, `--max-results` | None | NATS `ec2.DescribeVpcEndpointServices` → daemon returns available services (S3, DynamoDB equivalents) → return service list | 1. List available services<br>2. Filter by service name | **NOT STARTED** |
| `modify-vpc-endpoint` | — | `--vpc-endpoint-id`, `--add-route-table-ids`, `--remove-route-table-ids`, `--add-subnet-ids`, `--remove-subnet-ids` | Endpoint must exist | NATS `ec2.ModifyVpcEndpoint` → daemon updates endpoint route tables/subnets in KV → return success | 1. Add route table<br>2. Remove subnet<br>3. Missing endpoint ID (error) | **NOT STARTED** |
### EC2 - VPN Gateway & Customer Gateway
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-customer-gateway` | — | `--type` (ipsec.1), `--bgp-asn`, `--ip-address`, `--tag-specifications` | None | NATS `ec2.CreateCustomerGateway` → daemon stores in JetStream KV with cgw-ID → return customer gateway | 1. Create with BGP ASN and IP<br>2. Missing required fields (error) | **NOT STARTED** |
| `delete-customer-gateway` | — | `--customer-gateway-id`, `--dry-run` | Must exist, no VPN connections | NATS `ec2.DeleteCustomerGateway` → daemon verifies no active connections → delete from KV → return success | 1. Delete existing<br>2. Delete with active VPN (error) | **NOT STARTED** |
| `describe-customer-gateways` | — | `--customer-gateway-ids`, `--filters` | None | NATS `ec2.DescribeCustomerGateways` → daemon lists from KV → return list with BGP/IP info | 1. List all<br>2. Filter by ID | **NOT STARTED** |
| `create-vpn-gateway` | — | `--type` (ipsec.1), `--amazon-side-asn`, `--tag-specifications` | None | NATS `ec2.CreateVpnGateway` → daemon stores in JetStream KV with vgw-ID → return VPN gateway | 1. Create VPN gateway<br>2. Verify in describe | **NOT STARTED** |
| `delete-vpn-gateway` | — | `--vpn-gateway-id`, `--dry-run` | Must be detached | NATS `ec2.DeleteVpnGateway` → daemon verifies detached → delete from KV → return success | 1. Delete detached gateway<br>2. Delete attached (error) | **NOT STARTED** |
| `attach-vpn-gateway` | — | `--vpn-gateway-id`, `--vpc-id` | Both must exist | NATS `ec2.AttachVpnGateway` → daemon links VPN gateway to VPC → return attachment | 1. Attach to VPC<br>2. Already attached (error) | **NOT STARTED** |
| `detach-vpn-gateway` | — | `--vpn-gateway-id`, `--vpc-id` | Must be attached | NATS `ec2.DetachVpnGateway` → daemon unlinks VPN gateway from VPC → return success | 1. Detach from VPC<br>2. Not attached (error) | **NOT STARTED** |
| `describe-vpn-gateways` | — | `--vpn-gateway-ids`, `--filters` | None | NATS `ec2.DescribeVpnGateways` → daemon lists from KV → return list with attachment info | 1. List all<br>2. Filter by VPC attachment | **NOT STARTED** |
### EC2 - VPN Connections
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpn-connection` | — | `--type` (ipsec.1), `--customer-gateway-id`, `--vpn-gateway-id`, `--options`, `--tag-specifications` | Customer gateway and VPN gateway must exist | NATS `ec2.CreateVpnConnection` → daemon stores connection in JetStream KV with vpn-ID → return connection with tunnel info | 1. Create IPsec connection<br>2. Missing customer gateway (error)<br>3. Missing VPN gateway (error) | **NOT STARTED** |
| `delete-vpn-connection` | — | `--vpn-connection-id`, `--dry-run` | Connection must exist | NATS `ec2.DeleteVpnConnection` → daemon deletes from KV → return success | 1. Delete existing connection<br>2. Delete non-existent (error) | **NOT STARTED** |
| `describe-vpn-connections` | — | `--vpn-connection-ids`, `--filters` | None | NATS `ec2.DescribeVpnConnections` → daemon lists from KV → return list with state and tunnel info | 1. List all connections<br>2. Filter by ID<br>3. Filter by VPN gateway | **NOT STARTED** |
| `modify-vpn-connection` | — | `--vpn-connection-id`, `--vpn-gateway-id`, `--customer-gateway-id` | Connection must exist | NATS `ec2.ModifyVpnConnection` → daemon updates connection target in KV → return modified connection | 1. Change VPN gateway<br>2. Missing connection ID (error) | **NOT STARTED** |
| `modify-vpn-connection-options` | — | `--vpn-connection-id`, `--local-ipv4-network-cidr`, `--remote-ipv4-network-cidr` | Connection must exist | NATS `ec2.ModifyVpnConnectionOptions` → daemon updates tunnel options in KV → return modified connection | 1. Modify CIDR ranges<br>2. Missing connection ID (error) | **NOT STARTED** |
### EC2 - Network ACLs
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-network-acl` | — | `--vpc-id`, `--tag-specifications` | VPC must exist | NATS `ec2.CreateNetworkAcl` → daemon creates NACL in KV with acl-ID → add default deny-all rules → return NACL | 1. Create NACL in VPC<br>2. Missing VPC ID (error)<br>3. Verify default deny rules | **NOT STARTED** |
| `delete-network-acl` | — | `--network-acl-id`, `--dry-run` | Must not be default NACL | NATS `ec2.DeleteNetworkAcl` → daemon verifies not default → delete from KV → return success | 1. Delete non-default NACL<br>2. Delete default NACL (error) | **NOT STARTED** |
| `describe-network-acls` | — | `--network-acl-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNetworkAcls` → daemon lists NACLs from KV → return list with entries | 1. List all NACLs<br>2. Filter by VPC | **NOT STARTED** |
| `create-network-acl-entry` | — | `--network-acl-id`, `--rule-number`, `--protocol`, `--rule-action` (allow/deny), `--cidr-block`, `--ingress`/`--egress`, `--port-range` | NACL must exist | NATS `ec2.CreateNetworkAclEntry` → daemon adds rule entry to NACL in KV → configure OVS flow → return success | 1. Add ingress allow rule<br>2. Add egress deny rule<br>3. ICMP type/code parsing<br>4. Missing NACL ID (error) | **NOT STARTED** |
| `delete-network-acl-entry` | — | `--network-acl-id`, `--rule-number`, `--ingress`/`--egress` | Entry must exist | NATS `ec2.DeleteNetworkAclEntry` → daemon removes rule from NACL → remove OVS flow → return success | 1. Delete existing entry<br>2. Delete non-existent entry (error) | **NOT STARTED** |
| `replace-network-acl-association` | — | `--association-id`, `--network-acl-id` | Both must exist | NATS `ec2.ReplaceNetworkAclAssociation` → daemon swaps NACL for subnet → return new association ID | 1. Replace association<br>2. Missing association ID (error) | **NOT STARTED** |
| `replace-network-acl-entry` | — | `--network-acl-id`, `--rule-number`, `--protocol`, `--rule-action`, `--cidr-block`, `--ingress`/`--egress`, `--port-range` | Entry must exist | NATS `ec2.ReplaceNetworkAclEntry` → daemon replaces rule in KV → update OVS flow → return success | 1. Replace existing entry<br>2. Replace non-existent entry (error) | **NOT STARTED** |
### EC2 - Prefix Lists
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-managed-prefix-list` | — | `--prefix-list-name`, `--address-family` (IPv4/IPv6), `--max-entries`, `--entries`, `--tag-specifications` | None | NATS `ec2.CreateManagedPrefixList` → daemon stores prefix list in JetStream KV with pl-ID → return prefix list with version | 1. Create with IPv4 entries<br>2. Name, address family, max entries required<br>3. Verify in describe | **NOT STARTED** |
| `delete-managed-prefix-list` | — | `--prefix-list-id`, `--dry-run` | Must exist, not referenced by security groups/route tables | NATS `ec2.DeleteManagedPrefixList` → daemon verifies not referenced → delete from KV → return success | 1. Delete unreferenced list<br>2. Delete referenced list (error) | **NOT STARTED** |
| `describe-managed-prefix-lists` | — | `--prefix-list-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeManagedPrefixLists` → daemon lists from KV → return list with state and version | 1. List all prefix lists<br>2. Filter by ID | **NOT STARTED** |
| `modify-managed-prefix-list` | — | `--prefix-list-id`, `--current-version`, `--add-entries`, `--remove-entries`, `--prefix-list-name` | Must exist, current version must match | NATS `ec2.ModifyManagedPrefixList` → daemon validates version → apply changes → increment version → return updated list | 1. Add entries<br>2. Remove entries<br>3. Version mismatch (error) | **NOT STARTED** |
| `get-managed-prefix-list-entries` | — | `--prefix-list-id`, `--target-version`, `--max-results` | Prefix list must exist | NATS `ec2.GetManagedPrefixListEntries` → daemon reads entries from KV → return entry list | 1. List entries<br>2. List entries at specific version | **NOT STARTED** |
| `get-managed-prefix-list-associations` | — | `--prefix-list-id`, `--max-results` | Prefix list must exist | NATS `ec2.GetManagedPrefixListAssociations` → daemon finds references in SGs/route tables → return association list | 1. List associations<br>2. No associations returns empty | **NOT STARTED** |
### EC2 - Network Interfaces
ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocation from `spinifex-vpc-ipam`. ENIs are auto-created by `run-instances --subnet-id` and auto-deleted on termination. MAC addresses are deterministically generated from the ENI ID. Attach/detach are internal operations used by instance lifecycle — no standalone attach/detach API yet.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-network-interface` | `--subnet-id`, `--private-ip-address`, `--description`, `--tag-specifications` | `--groups`, `--dry-run` | Subnet must exist | Gateway validates SubnetId (required) → NATS `ec2.CreateNetworkInterface` → daemon verifies subnet exists in `spinifex-vpc-subnets` KV → if no PrivateIpAddress, allocates from IPAM pool (CAS-based, up to 5 retries) → generates eni-ID → generates deterministic MAC from ENI ID → stores ENIRecord in `spinifex-vpc-enis` KV → publishes `vpc.create-port` event to vpcd (creates OVN logical port) → returns NetworkInterface with status=available | 1. Create in subnet (auto-allocate IP)<br>2. Create with specific private IP<br>3. Missing subnet ID (MissingParameter)<br>4. Non-existent subnet (error)<br>5. Tags applied at creation | **DONE** |
| `delete-network-interface` | `--network-interface-id` | `--dry-run` | Must not be attached (status != in-use) | NATS `ec2.DeleteNetworkInterface` → daemon verifies ENI exists and status is not "in-use" (InvalidNetworkInterfaceInUse) → releases IP back to IPAM pool → deletes from `spinifex-vpc-enis` KV → publishes `vpc.delete-port` event to vpcd | 1. Delete detached ENI<br>2. Delete attached ENI (InvalidNetworkInterfaceInUse)<br>3. Delete non-existent (error) | **DONE** |
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `spinifex-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | ENI must exist; at least one attribute required | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon validates ENI exists → updates description and/or security groups in `spinifex-vpc-enis` KV (optimistic locking) → return success | 1. Modify description<br>2. Update security groups<br>3. No attributes (InvalidParameterValue)<br>4. Non-existent ENI (error) | **DONE** |
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
### EC2 - Launch Templates
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-launch-template` | — | `--launch-template-name`, `--launch-template-data` (ImageId, InstanceType, etc.), `--tag-specifications` | None | NATS `ec2.CreateLaunchTemplate` → daemon stores template in JetStream KV with lt-ID → version 1 → return template metadata | 1. Create template with full config<br>2. Duplicate name (error)<br>3. Verify in describe output | **NOT STARTED** |
| `create-launch-template-version` | — | `--launch-template-id` or `--launch-template-name`, `--launch-template-data`, `--source-version` | Template must exist | NATS `ec2.CreateLaunchTemplateVersion` → daemon creates new version in KV → return version details | 1. Create version from scratch<br>2. Create version from source version<br>3. Template not found (error) | **NOT STARTED** |
| `delete-launch-template` | — | `--launch-template-id` or `--launch-template-name`, `--dry-run` | Template must exist | NATS `ec2.DeleteLaunchTemplate` → daemon deletes template and all versions from KV → return deleted template info | 1. Delete by ID<br>2. Delete by name<br>3. Non-existent template (error) | **NOT STARTED** |
| `describe-launch-templates` | — | `--launch-template-ids`, `--launch-template-names`, `--filters` | None | NATS `ec2.DescribeLaunchTemplates` → daemon lists templates from KV → return list | 1. List all templates<br>2. Filter by name<br>3. Filter by ID | **NOT STARTED** |
| `describe-launch-template-versions` | — | `--launch-template-id` or `--launch-template-name`, `--versions`, `--min-version`, `--max-version` | Template must exist | NATS `ec2.DescribeLaunchTemplateVersions` → daemon lists versions from KV → return version list with data | 1. List all versions<br>2. List specific version<br>3. Template not found (error) | **NOT STARTED** |
### EC2 - Misc Operations
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `delete-network-interface-permission` | — | `--network-interface-permission-id`, `--force` | Permission must exist | NATS `ec2.DeleteNetworkInterfacePermission` → daemon deletes permission from KV → return success | 1. Delete existing permission<br>2. Force delete | **NOT STARTED** |
| `enable-address-transfer` | — | `--allocation-id`, `--transfer-account-id` | EIP must exist | NATS `ec2.EnableAddressTransfer` → daemon stores transfer in JetStream KV with 7-day expiration → return AllocationId and TransferAccountId | 1. Enable transfer<br>2. Verify transfer stored with expiration | **NOT STARTED** |
| `disable-address-transfer` | — | `--allocation-id` | Transfer must exist | NATS `ec2.DisableAddressTransfer` → daemon updates transfer status to disabled in KV → return status | 1. Disable active transfer<br>2. Disable non-existent transfer (error) | **NOT STARTED** |
### EBS Direct API
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `start-snapshot` | — | `--volume-size`, `--parent-snapshot-id`, `--description`, `--encrypted` | None | Initialize snapshot in viperblock → return snap-ID for block-level writes | 1. Start new snapshot<br>2. Start incremental from parent | **NOT STARTED** |
| `put-snapshot-block` | — | `--snapshot-id`, `--block-index`, `--block-data`, `--checksum` | start-snapshot | Write block data to snapshot at specified index in viperblock → verify checksum | 1. Write single block<br>2. Write with bad checksum (error) | **NOT STARTED** |
| `get-snapshot-block` | — | `--snapshot-id`, `--block-index` | Snapshot must exist | Read block data from snapshot at specified index → return data with checksum | 1. Read existing block<br>2. Read sparse block (zeros) | **NOT STARTED** |
| `complete-snapshot` | — | `--snapshot-id`, `--changed-blocks-count` | start-snapshot, put-snapshot-block | Finalize snapshot → mark as completed → sync to Predastore | 1. Complete valid snapshot<br>2. Complete with wrong block count (error) | **NOT STARTED** |
| `list-snapshot-blocks` | — | `--snapshot-id`, `--max-results`, `--next-token` | Snapshot must exist | List all non-empty blocks in snapshot with tokens and sizes | 1. List blocks of populated snapshot<br>2. List blocks of empty snapshot | **NOT STARTED** |
| `list-changed-blocks` | — | `--second-snapshot-id`, `--first-snapshot-id`, `--max-results` | Snapshots must exist | Compare two snapshots → return list of differing block indexes | 1. Compare parent and child snapshots<br>2. Compare unrelated snapshots | **NOT STARTED** |
### IAM - User Management
All IAM operations are account-scoped. Users, policies, and access keys are namespaced per account. The gateway extracts the account ID from the authenticated access key via `c.Locals("sigv4.accountId")` and passes it to all IAM service methods. Root user (account `000000000000`) bypasses policy evaluation entirely.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-user` | `--user-name`, `--path` | `--tags`, `--permissions-boundary` | None | Gateway validates UserName non-empty → IAMService.CreateUser(accountID, input) → generates AIDA-prefixed UserId → stores in KV at key `{accountID}.{userName}` → ARN includes real account ID → conditional create for race safety | 1. Create user<br>2. Duplicate name in same account (EntityAlreadyExists)<br>3. Same name in different account (succeeds)<br>4. Missing username (InvalidInput)<br>5. Default path is `/` | **DONE** |
| `get-user` | `--user-name` | — | User must exist in caller's account | Gateway validates UserName → IAMService.GetUser(accountID, input) → reads from KV at `{accountID}.{userName}` → returns User with ARN, Path, CreateDate | 1. Get existing user<br>2. Get non-existent user (NoSuchEntity)<br>3. Cannot see user in different account (NoSuchEntity) | **DONE** |
| `list-users` | `--path-prefix` | `--max-items`, `--marker` | None | IAMService.ListUsers(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix if specified → returns user list scoped to caller's account | 1. List all users in account<br>2. List with path prefix filter<br>3. Empty list<br>4. Does not show other accounts' users | **DONE** |
| `delete-user` | `--user-name` | — | User must exist, no access keys attached | Gateway validates UserName → IAMService.DeleteUser(accountID, input) → checks user has no access keys → deletes from KV | 1. Delete user with no keys<br>2. Delete user with keys attached (DeleteConflict)<br>3. Delete non-existent user (NoSuchEntity) | **DONE** |
### IAM - Access Key Management
Access keys are globally unique (keyed by AccessKeyID) but associated with an account. Max 2 keys per user. Secrets encrypted with AES-256-GCM using master key.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-access-key` | `--user-name` | — | User must exist | IAMService.CreateAccessKey(accountID, input) → generates AKIA-prefixed key ID + random secret → encrypts secret with master key → stores in KV (globally unique key ID) → updates user's AccessKeys list → returns plaintext secret (one-time only) | 1. Create key for user (AKIA prefix)<br>2. Max 2 keys per user (LimitExceeded on 3rd)<br>3. Non-existent user (NoSuchEntity)<br>4. Secret only returned on creation | **DONE** |
| `list-access-keys` | `--user-name` | `--max-items`, `--marker` | None | IAMService.ListAccessKeys(accountID, input) → reads user's AccessKeys list → fetches each key's metadata → returns key IDs and status (no secrets) | 1. List keys for user<br>2. No keys returns empty list<br>3. Shows Status (Active/Inactive) but not secrets | **DONE** |
| `delete-access-key` | `--access-key-id`, `--user-name` | — | Key must exist | IAMService.DeleteAccessKey(accountID, input) → removes key from global KV → removes key ID from user's AccessKeys list | 1. Delete existing key<br>2. Delete non-existent key (NoSuchEntity) | **DONE** |
| `update-access-key` | `--access-key-id`, `--user-name`, `--status` (Active/Inactive) | — | Key must exist | IAMService.UpdateAccessKey(input) → reads key from global KV (key ID is globally unique, no accountID needed) → updates Status field → inactive keys rejected at SigV4 auth | 1. Deactivate key (Inactive)<br>2. Reactivate key (Active)<br>3. Non-existent key (NoSuchEntity)<br>4. Inactive key → auth returns InvalidClientTokenId | **DONE** |
### IAM - Policy Management
Policies are account-scoped. KV key is `{accountID}.{policyName}`. Policy documents require `Version: "2012-10-17"` and valid Statement array. Supports wildcard action matching (`ec2:*`, `ec2:Describe*`, `*`). Evaluation order: explicit Deny > explicit Allow > implicit Deny.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-policy` | `--policy-name`, `--policy-document`, `--path`, `--description` | `--tags` | None | Gateway validates PolicyName and PolicyDocument non-empty → IAMService.CreatePolicy(accountID, input) → validates JSON (Version, Statement with Effect/Action/Resource) → generates ANPA-prefixed PolicyId → stores at `{accountID}.{policyName}` → ARN includes real account ID | 1. Create with valid document<br>2. Malformed JSON (MalformedPolicyDocument)<br>3. Missing Effect/Action/Resource (MalformedPolicyDocument)<br>4. Duplicate name in same account (EntityAlreadyExists)<br>5. Same name in different account (succeeds)<br>6. With path and description | **DONE** |
| `get-policy` | `--policy-arn` | — | Policy must exist | Gateway validates PolicyArn → extracts account ID and policy name from ARN → IAMService.GetPolicy(accountID, input) → returns policy metadata (no document) with AttachmentCount | 1. Get existing policy<br>2. Non-existent policy (NoSuchEntity)<br>3. Returns metadata only, not document | **DONE** |
| `get-policy-version` | `--policy-arn`, `--version-id` | — | Policy must exist | Gateway validates PolicyArn and VersionId → IAMService.GetPolicyVersion(accountID, input) → returns PolicyVersion with URL-encoded Document, VersionId, IsDefaultVersion | 1. Get version v1 (returns document)<br>2. Non-existent version (NoSuchEntity)<br>3. Non-existent policy (NoSuchEntity) | **DONE** |
| `list-policies` | — | `--scope`, `--only-attached`, `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListPolicies(accountID, input) → prefix-scans KV for `{accountID}.*` → returns all policies in caller's account | 1. List all policies in account<br>2. Empty list<br>3. Does not show other accounts' policies | **DONE** |
| `delete-policy` | `--policy-arn` | — | Policy must exist, not attached to any user | Gateway validates PolicyArn → IAMService.DeletePolicy(accountID, input) → checks no users have this policy attached (scans account's users) → deletes from KV | 1. Delete unattached policy<br>2. Delete attached policy (DeleteConflict)<br>3. Delete non-existent (NoSuchEntity) | **DONE** |
### IAM - Policy Attachment
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `attach-user-policy` | `--user-name`, `--policy-arn` | — | User and policy must exist in caller's account | Gateway validates both params → IAMService.AttachUserPolicy(accountID, input) → verifies policy exists → adds policy ARN to user's AttachedPolicies list → idempotent (re-attach succeeds without duplicate) | 1. Attach policy to user<br>2. Already attached (idempotent, no error)<br>3. Non-existent user (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **DONE** |
| `detach-user-policy` | `--user-name`, `--policy-arn` | — | Policy must be attached to user | Gateway validates both params → IAMService.DetachUserPolicy(accountID, input) → removes policy ARN from user's AttachedPolicies list | 1. Detach attached policy<br>2. Policy not attached (NoSuchEntity)<br>3. Non-existent user (NoSuchEntity) | **DONE** |
| `list-attached-user-policies` | `--user-name` | `--path-prefix`, `--max-items`, `--marker` | User must exist | IAMService.ListAttachedUserPolicies(accountID, input) → reads user's AttachedPolicies list → returns policy ARNs and names | 1. List attached policies<br>2. No policies returns empty list | **DONE** |
### IAM - Policy Evaluation
The gateway evaluates IAM policies on every request (except root user bypass). Policy evaluation is internal — not an AWS API.
| Feature | Description | Status |
|---------|-------------|--------|
| Root user bypass | Root user (`000000000000:root`) skips all policy checks | **DONE** |
| Default deny | Users with no attached policies are denied all actions | **DONE** |
| Explicit allow | Matching Allow statement grants access | **DONE** |
| Explicit deny | Deny statements override Allow (deny wins) | **DONE** |
| Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** |
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** |
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
### IAM - Roles
IAM roles are required for instance profiles, cross-account access, and service-linked permissions. Without roles, workloads must use static access keys. Roles should be stored in a `spinifex-iam-roles` KV bucket keyed by `{accountID}.{roleName}`. Trust policies (AssumeRolePolicyDocument) define who can assume the role.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-role` | — | `--role-name`, `--assume-role-policy-document`, `--path`, `--description`, `--max-session-duration`, `--permissions-boundary`, `--tags` | None | IAMService.CreateRole(accountID, input) → validate RoleName + AssumeRolePolicyDocument (required) → validate trust policy JSON → generate AROA-prefixed RoleId → store RoleRecord in `spinifex-iam-roles` KV at `{accountID}.{roleName}` → ARN: `arn:aws:iam::{accountID}:role/{path}{roleName}` → return Role | 1. Create with valid trust policy<br>2. Malformed trust policy (MalformedPolicyDocument)<br>3. Duplicate name (EntityAlreadyExists)<br>4. Missing RoleName (InvalidInput)<br>5. With path and description | **NOT STARTED** |
| `get-role` | — | `--role-name` | Role must exist | IAMService.GetRole(accountID, input) → reads from KV at `{accountID}.{roleName}` → returns Role with ARN, Path, AssumeRolePolicyDocument, CreateDate | 1. Get existing role<br>2. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `list-roles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListRoles(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix → returns role list scoped to caller's account | 1. List all roles<br>2. List with path prefix<br>3. Empty list<br>4. Account isolation | **NOT STARTED** |
| `delete-role` | — | `--role-name` | Role must exist, no attached policies, no instance profiles | IAMService.DeleteRole(accountID, input) → verify no attached policies (DeleteConflict) → verify not in any instance profile (DeleteConflict) → delete from KV | 1. Delete role with no attachments<br>2. Delete role with policies (DeleteConflict)<br>3. Delete role in instance profile (DeleteConflict)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `update-role` | — | `--role-name`, `--description`, `--max-session-duration` | Role must exist | IAMService.UpdateRole(accountID, input) → update description and/or session duration in KV → return success | 1. Update description<br>2. Update max session duration<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `update-assume-role-policy` | — | `--role-name`, `--policy-document` | Role must exist | IAMService.UpdateAssumeRolePolicy(accountID, input) → validate trust policy JSON → update AssumeRolePolicyDocument in KV → return success | 1. Update trust policy<br>2. Malformed policy (MalformedPolicyDocument)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `attach-role-policy` | — | `--role-name`, `--policy-arn` | Role and policy must exist | IAMService.AttachRolePolicy(accountID, input) → verify policy exists → add ARN to role's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent role (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **NOT STARTED** |
| `detach-role-policy` | — | `--role-name`, `--policy-arn` | Policy must be attached | IAMService.DetachRolePolicy(accountID, input) → remove ARN from role's AttachedPolicies | 1. Detach attached policy<br>2. Not attached (NoSuchEntity)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `list-attached-role-policies` | — | `--role-name`, `--path-prefix`, `--max-items`, `--marker` | Role must exist | IAMService.ListAttachedRolePolicies(accountID, input) → reads role's AttachedPolicies list → returns ARNs and names | 1. List attached policies<br>2. No policies (empty list)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
### IAM - Instance Profiles
Instance profiles are containers for IAM roles that allow EC2 instances to assume a role and obtain temporary credentials. Required for `run-instances --iam-instance-profile`. Stored in `spinifex-iam-instance-profiles` KV bucket keyed by `{accountID}.{profileName}`.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-instance-profile` | — | `--instance-profile-name`, `--path`, `--tags` | None | IAMService.CreateInstanceProfile(accountID, input) → validate name (required) → generate AIPA-prefixed InstanceProfileId → store in `spinifex-iam-instance-profiles` KV with empty Roles array → ARN: `arn:aws:iam::{accountID}:instance-profile/{path}{name}` → return InstanceProfile | 1. Create profile<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
| `get-instance-profile` | — | `--instance-profile-name` | Profile must exist | IAMService.GetInstanceProfile(accountID, input) → reads from KV → returns InstanceProfile with Roles array | 1. Get existing profile<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `list-instance-profiles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListInstanceProfiles(accountID, input) → prefix-scans KV → returns list | 1. List all profiles<br>2. Empty list | **NOT STARTED** |
| `list-instance-profiles-for-role` | — | `--role-name`, `--max-items`, `--marker` | Role must exist | IAMService.ListInstanceProfilesForRole(accountID, input) → scan all profiles → filter where Roles contains role name → return list | 1. List profiles for role<br>2. Role not in any profile (empty) | **NOT STARTED** |
| `delete-instance-profile` | — | `--instance-profile-name` | Profile must exist, Roles must be empty | IAMService.DeleteInstanceProfile(accountID, input) → verify Roles empty (DeleteConflict if not) → delete from KV | 1. Delete empty profile<br>2. Delete profile with role (DeleteConflict)<br>3. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `add-role-to-instance-profile` | — | `--instance-profile-name`, `--role-name` | Both must exist, max 1 role per profile | IAMService.AddRoleToInstanceProfile(accountID, input) → verify profile exists → verify role exists → check Roles array is empty (LimitExceeded if role already present) → append role → update KV | 1. Add role to empty profile<br>2. Profile already has role (LimitExceeded)<br>3. Non-existent profile (NoSuchEntity)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `remove-role-from-instance-profile` | — | `--instance-profile-name`, `--role-name` | Role must be in profile | IAMService.RemoveRoleFromInstanceProfile(accountID, input) → remove role from Roles array → update KV | 1. Remove role<br>2. Role not in profile (NoSuchEntity) | **NOT STARTED** |
### IAM - Groups
Groups provide a way to attach policies to multiple users. Stored in `spinifex-iam-groups` KV bucket keyed by `{accountID}.{groupName}`.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-group` | — | `--group-name`, `--path` | None | IAMService.CreateGroup(accountID, input) → validate GroupName (required) → generate AGPA-prefixed GroupId → store in KV → return Group | 1. Create group<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
| `get-group` | — | `--group-name` | Group must exist | IAMService.GetGroup(accountID, input) → reads group from KV → returns Group with member Users list | 1. Get existing group<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `list-groups` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListGroups(accountID, input) → prefix-scans KV → returns list | 1. List all groups<br>2. Empty list | **NOT STARTED** |
| `delete-group` | — | `--group-name` | Group must exist, no members, no attached policies | IAMService.DeleteGroup(accountID, input) → verify no members → verify no attached policies → delete from KV | 1. Delete empty group<br>2. Delete group with members (DeleteConflict)<br>3. Delete group with policies (DeleteConflict)<br>4. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `add-user-to-group` | — | `--group-name`, `--user-name` | Both must exist | IAMService.AddUserToGroup(accountID, input) → verify group + user exist → add user to group's Members list → idempotent | 1. Add user<br>2. Already a member (idempotent)<br>3. Non-existent group (NoSuchEntity)<br>4. Non-existent user (NoSuchEntity) | **NOT STARTED** |
| `remove-user-from-group` | — | `--group-name`, `--user-name` | User must be in group | IAMService.RemoveUserFromGroup(accountID, input) → remove user from Members list → update KV | 1. Remove member<br>2. Not a member (NoSuchEntity) | **NOT STARTED** |
| `list-groups-for-user` | — | `--user-name`, `--max-items`, `--marker` | User must exist | IAMService.ListGroupsForUser(accountID, input) → scan all groups → filter where Members contains user → return list | 1. List groups for user<br>2. User in no groups (empty) | **NOT STARTED** |
| `attach-group-policy` | — | `--group-name`, `--policy-arn` | Group and policy must exist | IAMService.AttachGroupPolicy(accountID, input) → verify policy exists → add ARN to group's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent group (NoSuchEntity) | **NOT STARTED** |
| `detach-group-policy` | — | `--group-name`, `--policy-arn` | Policy must be attached | IAMService.DetachGroupPolicy(accountID, input) → remove ARN from AttachedPolicies | 1. Detach policy<br>2. Not attached (NoSuchEntity) | **NOT STARTED** |
| `list-attached-group-policies` | — | `--group-name`, `--path-prefix`, `--max-items`, `--marker` | Group must exist | IAMService.ListAttachedGroupPolicies(accountID, input) → reads group's AttachedPolicies → returns ARNs and names | 1. List attached policies<br>2. No policies (empty) | **NOT STARTED** |
### STS (Security Token Service)
STS provides temporary credentials and identity verification. `GetCallerIdentity` is called by every AWS SDK/CLI invocation. `AssumeRole` is required once IAM roles are implemented. STS needs its own gateway service endpoint (`sts` service in the `supportedServices` map) with a dedicated action map.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `get-caller-identity` | — | *(no flags)* | Valid SigV4 credentials | Gateway extracts accountID and username from authenticated request → returns Account, Arn (`arn:aws:iam::{accountID}:user/{userName}` or `arn:aws:sts::{accountID}:assumed-role/{roleName}/{sessionName}`), UserId. Note: a Spinifex-custom version exists at `spinifex.GetCallerIdentity` but needs a proper STS endpoint. | 1. Get identity for IAM user<br>2. Get identity for assumed role<br>3. Get identity for root user<br>4. Invalid credentials (AuthFailure) | **NOT STARTED** |
| `assume-role` | — | `--role-arn`, `--role-session-name`, `--duration-seconds`, `--policy`, `--external-id`, `--serial-number`, `--token-code`, `--tags`, `--transitive-tag-keys` | Role must exist, caller must be allowed by trust policy | Gateway validates RoleArn + RoleSessionName (required) → looks up role → evaluates trust policy (AssumeRolePolicyDocument) against caller → generates temporary credentials (AccessKeyId with ASIA prefix, SecretAccessKey, SessionToken) → credentials expire after DurationSeconds (default 3600, max 43200) → return Credentials + AssumedRoleUser (ARN, AssumedRoleId) | 1. Assume role with valid trust policy<br>2. Trust policy denies caller (AccessDenied)<br>3. Non-existent role (NoSuchEntity)<br>4. Expired session token rejected at SigV4<br>5. Custom duration<br>6. Session tags | **NOT STARTED** |
| `get-session-token` | — | `--duration-seconds`, `--serial-number`, `--token-code` | Valid IAM user credentials (not role) | Gateway validates caller is IAM user (not assumed role) → generates temporary credentials (ASIA prefix) → session inherits user's permissions → return Credentials | 1. Get token for IAM user<br>2. Duration within range<br>3. Reject if caller is assumed role | **NOT STARTED** |
### ELBv2 (Elastic Load Balancing v2 — Application Load Balancer)
All ELBv2 resources are stored in the `spinifex-elbv2` JetStream KV bucket. Key format: `lb.{lbId}`, `tg.{tgId}`, `listener.{listenerId}`. The data plane uses a system-managed ALB VM running HAProxy, launched automatically during CreateLoadBalancer. HAProxy configuration is pushed via NATS (`elbv2.alb.{lbId}.config` topic) whenever listeners or targets change. Agent readiness is polled via `elbv2.alb.{lbId}.ping`.
#### ELBv2 - Load Balancers
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-load-balancer` | `--name`, `--subnets`, `--security-groups`, `--scheme` (internet-facing/internal), `--tags`, `--ip-address-type` (ipv4 only) | `--type` (hardcoded application), `--customer-owned-ipv4-pool`, `--dry-run` | None (subnets optional) | Gateway validates Name (required) → NATS `elbv2.CreateLoadBalancer` with `spinifex-workers` queue group → daemon validates scheme (internet-facing or internal, InvalidScheme if other) → checks duplicate name in account (DuplicateLoadBalancerName) → generates LB ID → builds DNS name `{name}-{lbId}.{region}.elb.spinifex.local` → if subnets provided, creates one ENI per subnet with `spinifex:managed-by=elbv2` tag (rolls back on failure) → if system AMI available, launches t3.nano ALB VM with cloud-init running `/usr/local/bin/alb-agent` → state=provisioning → background goroutine polls agent ping for ~5min → on agent ready: state=active, on timeout: state=failed → if no VM launcher: state=active immediately → persists to KV → returns LoadBalancer with ARN, DNSName, State | 1. Create with name (happy path)<br>2. Internal scheme<br>3. Invalid scheme (InvalidScheme)<br>4. Duplicate name (DuplicateLoadBalancerName)<br>5. Missing name (MissingParameter)<br>6. With tags<br>7. With subnets (ENI creation) | **DONE** |
| `delete-load-balancer` | `--load-balancer-arn` | `--dry-run` | LB must exist | Gateway validates LoadBalancerArn (required) → NATS `elbv2.DeleteLoadBalancer` → daemon verifies LB exists and belongs to account → terminates ALB VM (async background) → deletes all listeners for this LB → deletes system-managed ENIs → deletes LB record from KV → returns success | 1. Delete existing LB<br>2. Not found (LoadBalancerNotFound)<br>3. Cascades listener cleanup<br>4. ENI cleanup | **DONE** |
| `describe-load-balancers` | `--load-balancer-arns`, `--names` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeLoadBalancers` → daemon lists all LB records from KV → filters by account ID → applies ARN filter and/or Name filter → returns LoadBalancer list with State, DNSName, Scheme, VpcId, AvailabilityZones | 1. List all LBs<br>2. Filter by name<br>3. Account isolation | **DONE** |
#### ELBv2 - Target Groups
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-target-group` | `--name`, `--protocol` (HTTP), `--port` (default 80), `--vpc-id`, `--target-type` (instance only), `--health-check-protocol`, `--health-check-port`, `--health-check-path` (default /), `--health-check-interval-seconds` (default 30), `--health-check-timeout-seconds` (default 5), `--healthy-threshold-count` (default 5), `--unhealthy-threshold-count` (default 2), `--matcher` (HttpCode, default 200), `--tags` | `--health-check-enabled`, `--protocol-version`, `--ip-address-type`, `--dry-run` | None | Gateway validates Name (required) → NATS `elbv2.CreateTargetGroup` → daemon checks duplicate name in same VPC (DuplicateTargetGroupName) → generates TG ID → creates HealthCheckConfig with provided values or defaults → stores record with empty Targets array → returns TargetGroup with ARN | 1. Create with defaults<br>2. Custom health check<br>3. Duplicate name (DuplicateTargetGroupName)<br>4. Missing name (MissingParameter) | **DONE** |
| `delete-target-group` | `--target-group-arn` | `--dry-run` | TG must exist, not referenced by listener | Gateway validates TargetGroupArn (required) → NATS `elbv2.DeleteTargetGroup` → daemon verifies TG exists → scans all listeners to check if any DefaultActions reference this TG (TargetGroupInUse if referenced) → deletes from KV | 1. Delete TG<br>2. TG in use by listener (TargetGroupInUse)<br>3. Not found (TargetGroupNotFound) | **DONE** |
| `describe-target-groups` | `--target-group-arns`, `--names`, `--load-balancer-arn` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeTargetGroups` → daemon lists all TG records → filters by account → applies ARN/Name filters → if LoadBalancerArn specified, lists listeners for that LB and includes only TGs referenced by those listeners → returns TargetGroup list | 1. List all TGs<br>2. Filter by LB ARN (via listener lookup)<br>3. Account isolation | **DONE** |
#### ELBv2 - Targets
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `register-targets` | `--target-group-arn`, `--targets` (Id=instance-id, Port=optional override) | `--dry-run` | TG must exist, instances should exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.RegisterTargets` → daemon fetches TG → deduplicates against existing targets (key: `{id}:{port}`, idempotent) → resolves instance ID → private IP via VPC ENI lookup → adds target with state=initial → persists to KV → calls `pushConfigForTargetGroup()` to regenerate HAProxy config on all LBs using this TG → returns success | 1. Register targets<br>2. Idempotent re-registration<br>3. TG not found (TargetGroupNotFound)<br>4. Missing ARN or targets (MissingParameter) | **DONE** |
| `deregister-targets` | `--target-group-arn`, `--targets` (Id, Port) | `--dry-run` | TG must exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.DeregisterTargets` → daemon fetches TG → builds removal set (key: `{id}:{port}`) → filters Targets array to keep only non-matching → persists to KV → calls `pushConfigForTargetGroup()` to update HAProxy config → returns success | 1. Deregister targets<br>2. TG not found (TargetGroupNotFound)<br>3. Missing parameters (MissingParameter) | **DONE** |
| `describe-target-health` | `--target-group-arn`, `--targets` (optional filter to specific targets) | `--include` | TG must exist | Gateway validates TargetGroupArn (required) → NATS `elbv2.DescribeTargetHealth` → daemon fetches TG → builds filter set from Targets param (if specified) → for each target, returns TargetHealthDescription with Target (Id, Port) and TargetHealth (State, Description). Note: health state is stored in records, not actively polled — HAProxy runs its own independent health checks. | 1. Describe all targets<br>2. Filter to specific targets<br>3. TG not found (TargetGroupNotFound) | **DONE** |
#### ELBv2 - Listeners
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP, default), `--port` (default 80) | `--ssl-policy`, `--certificates`, `--alpn-policy`, `--mutual-authentication`, `--dry-run` | LB must exist | Gateway validates LoadBalancerArn + DefaultActions (required) → NATS `elbv2.CreateListener` → daemon verifies LB exists → checks for duplicate port on same LB (DuplicateListener) → generates listener ID → builds ARN → stores ListenerRecord in KV → calls `pushConfig(lb)` to regenerate HAProxy config (creates frontend for listener port, backend for target group with health checks) → returns Listener with ARN, Protocol, Port, DefaultActions | 1. Create listener<br>2. Duplicate port (DuplicateListener)<br>3. LB not found (LoadBalancerNotFound)<br>4. Graceful handling when NATS unavailable | **DONE** |
| `delete-listener` | `--listener-arn` | `--dry-run` | Listener must exist | Gateway validates ListenerArn (required) → NATS `elbv2.DeleteListener` → daemon verifies listener exists and belongs to account → deletes from KV → fetches parent LB → calls `pushConfig(lb)` to regenerate HAProxy config without this listener → returns success | 1. Delete listener<br>2. Not found (ListenerNotFound) | **DONE** |
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeListeners` → daemon lists listeners → if LoadBalancerArn specified, filters to that LB → applies ARN filter → account isolation → returns Listener list | 1. Filter by LB ARN<br>2. Account isolation | **DONE** |
#### ELBv2 - Not Implemented
| Feature | Description | Priority | Status |
|---------|-------------|----------|--------|
| Listener rules | Path-based and hostname-based routing (CreateRule, DeleteRule, DescribeRules, ModifyRule) | High | **NOT STARTED** |
| HTTPS/TLS termination | SSL certificates, SSL policies, ALPN | High | **NOT STARTED** |
| Modify operations | ModifyLoadBalancerAttributes, ModifyTargetGroup, ModifyTargetGroupAttributes, ModifyListener | Medium | **NOT STARTED** |
| Connection draining | Deregistration delay for graceful target removal | Medium | **NOT STARTED** |
| Stickiness | Session affinity / sticky sessions on target groups | Medium | **NOT STARTED** |
| Active health checking | API-driven health state updates (currently HAProxy-only) | Medium | **NOT STARTED** |
| Target types | IP and Lambda target types (only instance supported) | Low | **NOT STARTED** |
| Access logs | S3 access log delivery | Low | **NOT STARTED** |
| WAF integration | AWS WAF association for web application firewall | Low | **NOT STARTED** |
### CloudWatch (Basic Monitoring)
Basic CloudWatch metrics support is required for `monitor-instances`/`unmonitor-instances` to be meaningful. Daemon nodes should publish CPU, disk, and network metrics for running instances. Metrics stored in a time-series-friendly structure in JetStream KV or dedicated NATS subjects.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `put-metric-data` | — | `--namespace`, `--metric-data` (MetricName, Value, Unit, Timestamp, Dimensions) | None | Gateway validates Namespace + MetricData (required) → NATS `cloudwatch.PutMetricData` → daemon stores metric data points in time-series KV (key: `{namespace}.{metricName}.{dimensionHash}`) → return success | 1. Put single metric<br>2. Put with dimensions (InstanceId)<br>3. Missing namespace (MissingParameter) | **NOT STARTED** |
| `get-metric-statistics` | — | `--namespace`, `--metric-name`, `--start-time`, `--end-time`, `--period`, `--statistics` (Average, Sum, Min, Max, SampleCount), `--dimensions` | None | Gateway validates required fields → NATS `cloudwatch.GetMetricStatistics` → daemon queries time-series data → aggregates by period → returns Datapoints with requested statistics | 1. Get CPU utilization over 1 hour<br>2. Get with dimensions (InstanceId)<br>3. No data returns empty | **NOT STARTED** |
| `list-metrics` | — | `--namespace`, `--metric-name`, `--dimensions`, `--recently-active` | None | NATS `cloudwatch.ListMetrics` → daemon scans metric namespaces → returns Metrics list with Namespace, MetricName, Dimensions | 1. List all metrics<br>2. Filter by namespace<br>3. Filter by metric name | **NOT STARTED** |
| `describe-alarms` | — | `--alarm-names`, `--alarm-name-prefix`, `--state-value`, `--action-prefix` | None | NATS `cloudwatch.DescribeAlarms` → daemon lists alarms from KV → return MetricAlarms with State, Threshold, ComparisonOperator | 1. List all alarms<br>2. Filter by state (OK, ALARM, INSUFFICIENT_DATA) | **NOT STARTED** |
| `put-metric-alarm` | — | `--alarm-name`, `--namespace`, `--metric-name`, `--statistic`, `--period`, `--evaluation-periods`, `--threshold`, `--comparison-operator`, `--alarm-actions`, `--dimensions` | None | NATS `cloudwatch.PutMetricAlarm` → daemon stores alarm config in KV → alarm evaluation runs on metric publish → state transitions trigger alarm actions | 1. Create alarm<br>2. Update existing alarm<br>3. Missing required fields | **NOT STARTED** |
| `delete-alarms` | — | `--alarm-names` | Alarms must exist | NATS `cloudwatch.DeleteAlarms` → daemon deletes alarm records from KV → return success | 1. Delete existing alarm<br>2. Non-existent alarm (ResourceNotFound) | **NOT STARTED** |
### Auto Scaling
Auto Scaling manages instance fleets based on scaling policies. Depends on Launch Templates (EC2) and optionally ELBv2 target groups for health-based scaling. Stored in `spinifex-autoscaling` KV bucket.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-auto-scaling-group` | — | `--auto-scaling-group-name`, `--launch-template` (Id/Name, Version), `--min-size`, `--max-size`, `--desired-capacity`, `--vpc-zone-identifier` (subnet IDs), `--target-group-arns`, `--health-check-type` (EC2/ELB), `--health-check-grace-period`, `--tags` | Launch template must exist | NATS `autoscaling.CreateAutoScalingGroup` → daemon validates launch template → stores ASG record in KV → launches DesiredCapacity instances using template → registers with target groups if specified → starts health check loop → return success | 1. Create ASG with launch template<br>2. Scale to desired capacity<br>3. Missing launch template (ValidationError)<br>4. Min > Max (ValidationError) | **NOT STARTED** |
| `update-auto-scaling-group` | — | `--auto-scaling-group-name`, `--min-size`, `--max-size`, `--desired-capacity`, `--launch-template`, `--health-check-type`, `--health-check-grace-period` | ASG must exist | NATS `autoscaling.UpdateAutoScalingGroup` → daemon updates ASG config in KV → if DesiredCapacity changed, scale up/down accordingly → return success | 1. Update desired capacity<br>2. Update launch template<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
| `delete-auto-scaling-group` | — | `--auto-scaling-group-name`, `--force-delete` | ASG must exist | NATS `autoscaling.DeleteAutoScalingGroup` → if force: terminate all instances → deregister from target groups → delete from KV → return success. Without force: reject if instances > 0 | 1. Delete empty ASG<br>2. Force delete with instances<br>3. Delete with instances (ResourceInUse) | **NOT STARTED** |
| `describe-auto-scaling-groups` | — | `--auto-scaling-group-names`, `--filters`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingGroups` → daemon lists ASG records → return list with Instances, DesiredCapacity, MinSize, MaxSize | 1. List all ASGs<br>2. Filter by name | **NOT STARTED** |
| `set-desired-capacity` | — | `--auto-scaling-group-name`, `--desired-capacity`, `--honor-cooldown` | ASG must exist | NATS `autoscaling.SetDesiredCapacity` → daemon updates desired count → scale up (launch) or scale down (terminate oldest) → return success | 1. Scale up<br>2. Scale down<br>3. Exceeds max (ValidationError) | **NOT STARTED** |
| `describe-auto-scaling-instances` | — | `--instance-ids`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingInstances` → daemon lists all ASG-managed instances → return list with ASG name, lifecycle state, health status | 1. List all ASG instances<br>2. Filter by instance ID | **NOT STARTED** |
| `put-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name`, `--policy-type` (TargetTrackingScaling, StepScaling, SimpleScaling), `--target-tracking-configuration`, `--scaling-adjustment`, `--cooldown` | ASG must exist | NATS `autoscaling.PutScalingPolicy` → daemon stores policy in KV → links to CloudWatch alarms for metric-based triggers → return PolicyARN | 1. Create target tracking policy<br>2. Create step scaling policy<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
| `delete-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name` | Policy must exist | NATS `autoscaling.DeleteScalingPolicy` → daemon deletes policy → remove CloudWatch alarm links → return success | 1. Delete existing policy<br>2. Non-existent policy | **NOT STARTED** |
+373
View File
@@ -0,0 +1,373 @@
# Spinifex Architecture
Spinifex is an AWS-compatible infrastructure platform for bare-metal, edge, and on-premise deployments. It provides EC2-compatible VM orchestration backed by QEMU/KVM, with storage provided by companion projects Viperblock (EBS) and Predastore (S3).
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWS SDK / CLI │
│ (aws ec2 run-instances --endpoint-url ...) │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ HTTPS (SigV4 Auth)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWS Gateway (Port 9999) │
│ │
│ • TLS termination • AWS SigV4 authentication │
│ • EC2 Query Protocol parsing • Routes to service handlers │
│ • XML response generation • NATS client connection │
│ │
│ File: spinifex/services/awsgw/awsgw.go │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ JSON over NATS
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ NATS Message Broker │
│ │
│ Topics: Queue Groups: │
│ • ec2.RunInstances • spinifex-workers (load balanced) │
│ • ec2.DescribeInstances • No queue (fan-out to all nodes) │
│ • ec2.cmd.{instanceID} (per-instance start/stop/terminate) │
│ • ebs.mount / ebs.unmount │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Spinifex Daemon │ │ Spinifex Daemon │ │ Spinifex Daemon │
│ (Node 1) │ │ (Node 2) │ │ (Node N) │
│ │ │ │ │ │
│ • NATS subscriber │ │ • NATS subscriber │ │ • NATS subscriber │
│ • Resource manager │ │ • Resource manager │ │ • Resource manager │
│ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │
│ • QMP monitoring │ │ • QMP monitoring │ │ • QMP monitoring │
│ │ │ │ │ │
│ File: daemon.go │ │ File: daemon.go │ │ File: daemon.go │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ QEMU/KVM VMs │ │ QEMU/KVM VMs │ │ QEMU/KVM VMs │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
## Request Flow
### 1. AWS SDK Request
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by pointing to a custom endpoint:
```bash
aws --endpoint-url https://localhost:9999 --no-verify-ssl ec2 run-instances \
--image-id ami-debian12 \
--instance-type t3.micro \
--key-name my-keypair
```
The AWS SDK formats this as an HTTP POST with:
- AWS SigV4 authentication headers
- EC2 Query Protocol body (Action=RunInstances&ImageId=ami-debian12...)
### 2. AWS Gateway
The gateway (`spinifex/services/awsgw/awsgw.go:57-107`) is the entry point:
```go
// awsgw.go:74 - Connect to NATS
natsConn, err := nats.Connect(nodeConfig.NATS.Host, opts...)
// awsgw.go:89-94 - Create gateway with NATS connection
gw := gateway.GatewayConfig{
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
}
// awsgw.go:103 - Start TLS listener
log.Fatal(app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey))
```
Request routing (`spinifex/gateway/gateway.go:125-156`):
1. **Authentication**: SigV4 middleware validates AWS credentials (`gateway.go:104`)
2. **Service Detection**: Extracts service name (ec2, iam, account) from Authorization header (`gateway.go:158-186`)
3. **Action Dispatch**: Routes to service-specific handler (`gateway.go:136-145`)
```go
// gateway.go:136-145
switch svc {
case "ec2":
err = gw.EC2_Request(ctx)
case "account":
err = gw.Account_Request(ctx)
case "iam":
err = gw.IAM_Request(ctx)
}
```
### 3. EC2 Handler
The EC2 handler (`spinifex/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
```go
// ec2.go - Action routing
switch action {
case "RunInstances":
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
case "DescribeInstances":
output, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, expectedNodes)
// ... more actions
}
```
### 4. NATS Messaging
The gateway communicates with daemons via NATS request/response (`spinifex/handlers/ec2/instance/service_nats.go:24-57`):
```go
// service_nats.go:24-57
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
// Marshal input to JSON
jsonData, err := json.Marshal(input)
// Send request to daemon via NATS with 5 minute timeout
msg, err := s.natsConn.Request("ec2.RunInstances", jsonData, 5*time.Minute)
// Unmarshal response
var reservation ec2.Reservation
err = json.Unmarshal(msg.Data, &reservation)
return &reservation, nil
}
```
### 5. Daemon Processing
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests:
```go
// daemon.go:319 - Subscribe with queue group for load balancing
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances",
"spinifex-workers", // Queue group - only one daemon handles each request
d.handleEC2RunInstances,
)
// daemon.go:373 - Subscribe without queue group for fan-out
d.natsSubscriptions["ec2.DescribeInstances"], err = d.natsConn.Subscribe(
"ec2.DescribeInstances",
d.handleEC2DescribeInstances, // All daemons respond
)
```
**Queue Groups**: Topics subscribed with a queue group (`spinifex-workers`) are load-balanced - only one daemon handles each request. Topics without a queue group fan out to all daemons.
### 6. VM Launch
When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability (`daemon.go:66-74`)
2. **Volume Generation**: Creates boot, cloud-init, and EFI volumes via Viperblock
3. **Volume Mount**: Sends `ebs.mount` request to Viperblock, receives NBD URI
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
```go
// daemon.go - Volume mounting via NATS
msg, err := d.natsConn.Request("ebs.mount", ebsMountRequest, 10*time.Second)
// daemon.go - QEMU launch with NBD storage
cmd := exec.Command("qemu-system-x86_64",
"-drive", fmt.Sprintf("file=nbd:%s,format=raw", nbdURI),
// ... additional QEMU args
)
```
## Key Components
### Daemon Structure
```go
// daemon.go:76-102
type Daemon struct {
node string // Node identifier
clusterConfig *config.ClusterConfig // Cluster-wide configuration
config *config.Config // Node-specific configuration
natsConn *nats.Conn // NATS connection
resourceMgr *ResourceManager // CPU/Memory tracking
instanceService *InstanceServiceImpl // EC2 instance operations
keyService *KeyServiceImpl // SSH key operations
imageService *ImageServiceImpl // AMI operations
Instances vm.Instances // Local VMs
clusterApp *fiber.App // HTTP cluster manager
}
```
### Resource Manager
Tracks available and allocated CPU/memory to prevent overcommit:
```go
// daemon.go:66-74
type ResourceManager struct {
mu sync.RWMutex
availableVCPU int
availableMem float64
allocatedVCPU int
allocatedMem float64
instanceTypes map[string]InstanceType // t3.micro, t3.small, etc.
}
```
### NATS Topics
| Topic | Queue Group | Purpose |
|-------|-------------|---------|
| `ec2.RunInstances` | `spinifex-workers` | Launch new instances |
| `ec2.DescribeInstances` | None (fan-out) | Query all nodes for instances |
| `ec2.cmd.{instanceID}` | None (owner only) | Per-instance commands (start/stop/terminate, attach/detach volume) |
| `ec2.CreateKeyPair` | `spinifex-workers` | Generate SSH keypair |
| `ec2.DescribeKeyPairs` | `spinifex-workers` | List SSH keypairs |
| `ec2.DescribeImages` | `spinifex-workers` | List AMIs |
| `ebs.mount` | - | Mount volume via Viperblock |
| `ebs.unmount` | - | Unmount volume |
| `spinifex.admin.{node}.health` | None | Node health checks |
### Multi-Node Aggregation
For operations that need data from all nodes (like `DescribeInstances`), the gateway uses inbox-based fan-out (`spinifex/gateway/ec2/instance/DescribeInstances.go`):
```go
// DescribeInstances.go:16-111
func DescribeInstances(...) {
// Create unique inbox for collecting responses
inbox := nats.NewInbox()
sub, err := natsConn.SubscribeSync(inbox)
// Publish to all nodes (no queue group = all daemons respond)
err = natsConn.PublishRequest("ec2.DescribeInstances", inbox, jsonData)
// Collect responses from all nodes with 3-second timeout
for {
msg, err := sub.NextMsg(3 * time.Second)
allReservations = append(allReservations, nodeOutput.Reservations...)
}
return allReservations
}
```
## Storage Integration
### Viperblock (EBS)
Block storage volumes are managed via NATS messages to Viperblock:
```go
// Volume mount request
type EBSRequest struct {
Name string // Volume name (vol-xxxxx)
VolType string // gp2, io1, etc.
Boot bool // Boot volume flag
EFI bool // EFI boot volume
CloudInit bool // Cloud-init volume
NBDURI string // NBD URI returned from mount
}
```
Viperblock responds with an NBD URI that QEMU uses to access the block device.
### Predastore (S3)
Object storage used for:
- AMI image storage
- SSH public key storage (`/bucket/ec2/{key-name}.pub`)
- Volume metadata and configuration
## Configuration
Cluster configuration (`spinifex/config/config.go`):
```go
type ClusterConfig struct {
Epoch uint64 // Incremented on config changes
Node string // This node's identifier
Version string // Spinifex version
Nodes map[string]Config // Configuration for all nodes
}
type Config struct {
Node, Host, Region, AZ string
DataDir string
Daemon DaemonConfig // Cluster manager port
NATS NATSConfig // NATS broker address
Predastore PredastoreConfig // S3 endpoint
AWSGW AWSGWConfig // Gateway port, TLS certs
AccessKey, SecretKey string
}
```
## File Structure
```
spinifex/
├── spinifex/
│ ├── services/
│ │ └── awsgw/
│ │ └── awsgw.go # AWS Gateway service entry point
│ ├── gateway/
│ │ ├── gateway.go # Request routing, auth middleware
│ │ ├── ec2.go # EC2 action dispatcher
│ │ └── ec2/
│ │ └── instance/
│ │ ├── RunInstances.go
│ │ └── DescribeInstances.go
│ ├── handlers/
│ │ └── ec2/
│ │ ├── instance/
│ │ │ ├── service_nats.go # NATS request/response
│ │ │ └── service_impl.go # Business logic
│ │ ├── key/
│ │ └── image/
│ ├── daemon/
│ │ └── daemon.go # Daemon entry point, NATS subscriptions
│ ├── config/
│ │ └── config.go # Configuration structures
│ ├── vm/
│ │ └── instance.go # VM instance representation
│ └── qmp/
│ └── qmp.go # QEMU Machine Protocol client
└── cmd/
└── spinifex/
└── main.go # CLI entry point
```
## Development
### Running the Stack
1. Start NATS broker
2. Start Predastore (S3)
3. Start Viperblock (EBS)
4. Start Spinifex Gateway (`spx awsgw`)
5. Start Spinifex Daemon(s) (`spx daemon`)
### Testing with AWS CLI
```bash
# Set endpoint
export AWS_ENDPOINT_URL=https://localhost:9999
# Run instance
aws --no-verify-ssl ec2 run-instances \
--image-id ami-debian12 \
--instance-type t3.micro
# List instances (queries all nodes)
aws --no-verify-ssl ec2 describe-instances
# Terminate instance
aws --no-verify-ssl ec2 terminate-instances --instance-ids i-xxxxx
```
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Spinifex Documentation
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
+55
View File
@@ -0,0 +1,55 @@
---
title: "Creating Accounts"
description: "Create and manage isolated user accounts with their own resources."
category: "Administration"
tags:
- accounts
- iam
- admin
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Creating Accounts
> Create and manage isolated user accounts with their own resources.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex supports multi-tenant account isolation. Each account gets its own IAM credentials, AWS CLI profile, and isolated resource namespace.
## Instructions
## Create Account
```bash
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Troubleshooting
## Credentials Not Working
Verify the AWS CLI configuration files exist and contain the correct profile:
```bash
cat ~/.aws/config
cat ~/.aws/credentials
```
Ensure the `AWS_PROFILE` environment variable matches the profile name:
```bash
echo $AWS_PROFILE
export AWS_PROFILE=spinifex-myteam
```
+71
View File
@@ -0,0 +1,71 @@
---
title: "Hybrid Sync"
description: "Synchronize data between Spinifex and AWS when network connectivity is available."
category: "Migration"
tags:
- hybrid
- sync
- s3
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Hybrid Sync
> Synchronize data between Spinifex and AWS when network connectivity is available.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex's hybrid mode enables bidirectional data synchronization between local infrastructure and AWS cloud services. Ideal for intermittent connectivity environments.
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Push Local to Cloud
```bash
aws s3 sync s3://local-bucket/ s3://cloud-bucket/ \
--source-region spinifex --region us-east-1
```
## Pull Cloud to Local
```bash
aws s3 sync s3://cloud-bucket/ s3://local-bucket/ \
--source-region us-east-1 --region spinifex
```
## EBS Volume Backup
```bash
rsync -avz /data/ user@cloud-server:/backup/spinifex-data/
```
## Troubleshooting
## Sync Fails Mid Transfer
S3 sync is idempotent — re-run the same command to resume where it left off:
```bash
aws s3 sync s3://local-bucket/ s3://cloud-bucket/ \
--source-region spinifex --region us-east-1
```
Only changed or missing files will be transferred on subsequent runs.
+538
View File
@@ -0,0 +1,538 @@
---
title: "IAM Users and Policies"
description: "Create IAM users, manage access keys, and control permissions with policies."
category: "Administration"
tags:
- iam
- users
- policies
- access keys
- security
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS IAM CLI Reference"
url: "https://docs.aws.amazon.com/cli/latest/reference/iam/"
- title: "IAM Policy Reference"
url: "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html"
---
# IAM Users and Policies
> Create IAM users, manage access keys, and control permissions with policies.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Users](#users)
- [Access Keys](#access-keys)
- [Policies](#policies)
- [Attaching Policies](#attaching-policies)
- [Putting It All Together](#putting-it-all-together)
- [Command Reference](#command-reference)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex implements AWS-compatible IAM with 16 operations covering user management, access key lifecycle, policy CRUD, and policy attachment. All IAM resources are scoped to the account that creates them — users in one account cannot see or modify resources in another.
When you create an account with `spx admin account create`, Spinifex bootstraps a root user with an `AdministratorAccess` policy and writes the credentials to `~/.aws/credentials`. From there, you use the standard AWS CLI to manage additional users and permissions.
**How authentication works:** Every AWS CLI request is signed with SigV4 using an access key pair. The gateway verifies the signature, resolves the caller's account, and evaluates attached policies before routing the request. The root user (account `000000000000`) bypasses policy evaluation entirely.
## Instructions
## Prerequisites
- A running Spinifex cluster with the AWS gateway started
- An account created via `spx admin account create` (see [Creating Accounts](/docs/creating-accounts))
- AWS CLI configured with the account's profile:
```bash
export AWS_PROFILE=spinifex-myteam
```
## Users
### Create a User
```bash
aws iam create-user --user-name alice
```
The response includes the user's ARN, unique ID, and creation date:
```json
{
"User": {
"UserName": "alice",
"UserId": "AIDA1A2B3C4D5E6F7890",
"Arn": "arn:aws:iam::000000000001:user/alice",
"Path": "/",
"CreateDate": "2026-03-24T10:00:00Z"
}
}
```
Use `--path` to organise users into hierarchical groups:
```bash
aws iam create-user --user-name bob --path /developers/
```
### Get a User
```bash
aws iam get-user --user-name alice
```
### List Users
```bash
aws iam list-users
```
Filter by path prefix:
```bash
aws iam list-users --path-prefix /developers/
```
### Delete a User
Before deleting a user, remove all access keys and detach all policies:
```bash
# Remove access keys
aws iam list-access-keys --user-name alice
aws iam delete-access-key --user-name alice --access-key-id AKIA...
# Detach policies
aws iam list-attached-user-policies --user-name alice
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
# Now delete
aws iam delete-user --user-name alice
```
## Access Keys
Access keys are how users authenticate with the AWS CLI. Each user can have up to **2 access keys** at a time, allowing key rotation without downtime.
### Create an Access Key
```bash
aws iam create-access-key --user-name alice
```
```json
{
"AccessKey": {
"UserName": "alice",
"AccessKeyId": "AKIA1A2B3C4D5E6F7890ABCD",
"Status": "Active",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"CreateDate": "2026-03-24T10:05:00Z"
}
}
```
> **The secret access key is only shown once.** Save it immediately. If lost, delete the key and create a new one.
Add the credentials to `~/.aws/credentials` under a new profile and set `AWS_PROFILE` to use them.
### List Access Keys
```bash
aws iam list-access-keys --user-name alice
```
### Rotate Access Keys
Create a second key, update your configuration, then delete the old one:
```bash
# 1. Create new key (while old key still works)
aws iam create-access-key --user-name alice
# 2. Update ~/.aws/credentials with the new key
# 3. Verify the new key works
AWS_PROFILE=spinifex-alice aws sts get-caller-identity
# 4. Delete the old key
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD_KEY_ID
```
### Deactivate an Access Key
Temporarily disable a key without deleting it:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
--status Inactive
```
Reactivate it later:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
--status Active
```
## Policies
Policies are JSON documents that define what actions a user can perform on which resources.
### Policy Document Format
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
"Resource": "*"
}
]
}
```
Each statement has:
- **Effect** — `Allow` or `Deny`
- **Action** — Service actions (e.g. `ec2:RunInstances`, `s3:GetObject`). Supports wildcards: `ec2:*`, `s3:Get*`, or `*` for all actions.
- **Resource** — Target resources. Use `*` for all resources.
**Evaluation order:** An explicit `Deny` always wins. If no statement matches, access is denied by default.
### Create a Policy
Save the policy document to a file, then create the policy:
```bash
cat > /tmp/ec2-readonly.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeImages",
"ec2:DescribeKeyPairs",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcs"
],
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name EC2ReadOnly \
--policy-document file:///tmp/ec2-readonly.json
```
```json
{
"Policy": {
"PolicyName": "EC2ReadOnly",
"PolicyId": "ANPA1A2B3C4D5E6F7890",
"Arn": "arn:aws:iam::000000000001:policy/EC2ReadOnly",
"Path": "/",
"DefaultVersionId": "v1",
"CreateDate": "2026-03-24T10:10:00Z"
}
}
```
### Common Policy Examples
**Full administrator access:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
```
**S3 read/write (for hybrid sync, backups):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:DeleteObject"
],
"Resource": "*"
}
]
}
```
**EC2 operator (launch and manage instances, no VPC changes):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:DescribeImages"
],
"Resource": "*"
}
]
}
```
**Deny terminate (attach alongside a broader Allow to prevent accidental termination):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
```
### Get a Policy
```bash
aws iam get-policy \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
### Get the Policy Document
Use `get-policy-version` with version `v1` to retrieve the actual JSON document:
```bash
aws iam get-policy-version \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly \
--version-id v1
```
### List Policies
```bash
aws iam list-policies
```
### Delete a Policy
A policy must be detached from all users before it can be deleted:
```bash
aws iam delete-policy \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
## Attaching Policies
Policies have no effect until attached to a user.
### Attach a Policy to a User
```bash
aws iam attach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
### List a User's Policies
```bash
aws iam list-attached-user-policies --user-name alice
```
### Detach a Policy
```bash
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
## Putting It All Together
A complete workflow for onboarding a developer with scoped EC2 and S3 access:
```bash
# 1. Create the user
aws iam create-user --user-name dev-carol --path /developers/
# 2. Create an access key
aws iam create-access-key --user-name dev-carol
# Save the AccessKeyId and SecretAccessKey from the output
# 3. Create a scoped policy
cat > /tmp/dev-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EC2Access",
"Effect": "Allow",
"Action": ["ec2:*"],
"Resource": "*"
},
{
"Sid": "S3ReadOnly",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": "*"
},
{
"Sid": "NoTerminate",
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name DeveloperAccess \
--policy-document file:///tmp/dev-policy.json
# 4. Attach the policy
aws iam attach-user-policy --user-name dev-carol \
--policy-arn arn:aws:iam::000000000001:policy/DeveloperAccess
# 5. Configure the developer's AWS CLI
aws configure --profile spinifex-carol
# Enter the access key and secret from step 2
# 6. Verify
AWS_PROFILE=spinifex-carol aws ec2 describe-instances
AWS_PROFILE=spinifex-carol aws ec2 terminate-instances --instance-ids i-123
# ^ This will be denied by the NoTerminate statement
```
## Command Reference
| Command | Key Parameters | Notes |
| ----------------------------- | -------------------------------------------- | --------------------------------- |
| `create-user` | `--user-name`, `--path` | Path default: `/` |
| `get-user` | `--user-name` | |
| `list-users` | `--path-prefix` | |
| `delete-user` | `--user-name` | Must have no keys or policies |
| `create-access-key` | `--user-name` | Max 2 per user; secret shown once |
| `list-access-keys` | `--user-name` | |
| `delete-access-key` | `--user-name`, `--access-key-id` | |
| `update-access-key` | `--user-name`, `--access-key-id`, `--status` | `Active` or `Inactive` |
| `create-policy` | `--policy-name`, `--policy-document` | Max 6144 bytes |
| `get-policy` | `--policy-arn` | Metadata only |
| `get-policy-version` | `--policy-arn`, `--version-id` | Use `v1`; includes document |
| `list-policies` | | |
| `delete-policy` | `--policy-arn` | Must not be attached |
| `attach-user-policy` | `--user-name`, `--policy-arn` | Idempotent |
| `detach-user-policy` | `--user-name`, `--policy-arn` | |
| `list-attached-user-policies` | `--user-name` | |
## Troubleshooting
### AccessDenied on IAM Commands
The calling user must have IAM permissions. Attach a policy with `iam:*` actions, or use the root account profile:
```bash
export AWS_PROFILE=spinifex-myteam
aws iam list-users
```
### InvalidClientTokenId
The access key is either inactive or does not exist. Check the key status:
```bash
aws iam list-access-keys --user-name alice
```
If the key shows `Inactive`, reactivate it:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA... --status Active
```
If the key was deleted, create a new one.
### DeleteConflict When Deleting a User
The user still has access keys or attached policies. Remove them first:
```bash
# Check for access keys
aws iam list-access-keys --user-name alice
# Check for attached policies
aws iam list-attached-user-policies --user-name alice
```
### DeleteConflict When Deleting a Policy
The policy is still attached to one or more users. Detach it from all users before deleting:
```bash
# Find who has it attached, then detach
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
aws iam delete-policy \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
```
### LimitExceeded When Creating Access Keys
Each user can have at most 2 access keys. Delete an existing key before creating a new one:
```bash
aws iam list-access-keys --user-name alice
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD
aws iam create-access-key --user-name alice
```
### MalformedPolicyDocument
The policy JSON is invalid. Check that:
- `Version` is exactly `"2012-10-17"`
- At least one `Statement` exists
- Each statement has `Effect` (`Allow`/`Deny`), `Action`, and `Resource`
- The document is under 6144 bytes
- The JSON is well-formed (no trailing commas, correct quoting)
+131
View File
@@ -0,0 +1,131 @@
---
title: "Air-Gapped Install"
description: "Deploy Spinifex in environments without internet connectivity. Covers offline package caching, USB deployment, and package verification."
category: "Getting Started"
tags:
- install
- air-gapped
- offline
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "apt-cacher-ng"
url: "https://wiki.debian.org/AptCacherNg"
---
# Air-Gapped Installation
> Deploy Spinifex in environments without internet connectivity.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
**Deployment methods:**
- **apt-cacher-ng** — Local APT cache for Debian/Ubuntu packages
- **Go module cache** — Pre-downloaded Go dependencies
- **USB deployment** — Portable installation media with all dependencies
- **GPG verification** — Cryptographic package integrity checks
## Instructions
## Step 1. Prepare Packages (on a connected machine)
Set up a local APT cache to collect all required packages:
```bash
sudo apt install apt-cacher-ng
sudo systemctl enable --now apt-cacher-ng
```
Pre-download all Spinifex dependencies:
```bash
sudo apt install --download-only nbdkit nbdkit-plugin-dev pkg-config \
qemu-system qemu-utils qemu-kvm libvirt-daemon-system \
libvirt-clients libvirt-dev make gcc unzip xz-utils file \
ovn-central ovn-host openvswitch-switch
```
## Step 2. Create USB Deployment Media
```bash
mkdir -p /media/spinifex-deploy/{apt-packages,go-cache,spinifex-source}
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
cp -r ~/Development/mulga/spinifex /media/spinifex-deploy/spinifex-source/
```
## Step 3. Verify Package Integrity
Sign and verify packages before transferring to the target:
```bash
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
sha256sum -c /media/spinifex-deploy/checksums.sha256
```
## Step 4. Install on the Target Server
Mount the USB and install:
```bash
sudo mount /dev/sdb1 /mnt/usb
sudo dpkg -i /mnt/usb/apt-packages/*.deb
cp -r /mnt/usb/spinifex-source ~/Development/mulga/spinifex
cd ~/Development/mulga/spinifex && make build
```
Then follow the [Source Install](/docs/install-source) guide from Step 3 (Setup OVN) onwards.
## Troubleshooting
### Missing Dependencies After dpkg Install
Some packages may have unresolved dependencies. Fix with:
```bash
sudo dpkg -i /mnt/usb/apt-packages/*.deb
sudo apt-get install -f
```
The `-f` flag tells apt to fix broken dependencies using what's available locally.
### GPG Verification Fails
The signing key may not match or the download was corrupted. Re-import the key and verify:
```bash
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
```
If verification still fails, re-download the package on the connected machine and compare checksums:
```bash
sha256sum spinifex-v1.0.tar.gz
```
### Go Module Cache Errors
The Go module cache may have been incompletely copied. On the connected machine, re-export the full cache:
```bash
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
```
On the target, restore it:
```bash
mkdir -p ~/go/pkg/mod/
cp -r /mnt/usb/go-cache ~/go/pkg/mod/cache
```
+177
View File
@@ -0,0 +1,177 @@
---
title: "Multi-Node Install"
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
category: "Getting Started"
tags:
- install
- multi node
- cluster
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
---
# Multi-Node Installation
> Deploy Spinifex across multiple servers to create an availability zone.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
A Spinifex cluster distributes services across multiple servers for high availability, data durability, and fault tolerance. Cluster formation is automatic — the init node waits for peers to join, then distributes credentials, CA certificates, and configuration.
**Network requirements:**
- Minimum 1 NIC per server (2 recommended for production)
- UDP port 6081 open between hosts (Geneve tunnels)
- TCP ports 4222, 4248, 6641, 6642 open between hosts (NATS, OVN)
## Instructions
## Step 1. Install Spinifex on Each Server
```bash
curl -fsSL https://install.mulgadc.com | bash
```
## Step 2. Set Node IP Variables
On **each server**, export the management IPs for all nodes:
```bash
export SPINIFEX_NODE1=192.168.1.10
export SPINIFEX_NODE2=192.168.1.11
export SPINIFEX_NODE3=192.168.1.12
export AWS_REGION=us-east-1
export AWS_AZ=us-east-1a
```
## Step 3. Setup OVN Networking
Server 1 runs OVN central and must be set up first. If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it. Otherwise use `--wan-bridge=br-wan --wan-iface=eth1` (dedicated WAN NIC) or `--macvlan --wan-iface=eth0` (single-NIC).
**Server 1:**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--management \
--encap-ip=$SPINIFEX_NODE1
```
**Server 2** (after server 1 is ready):
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
--encap-ip=$SPINIFEX_NODE2
```
**Server 3** (after server 1 is ready):
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
--encap-ip=$SPINIFEX_NODE3
```
Verify all 3 chassis registered:
```bash
sudo ovn-sbctl show
```
## Step 4. Form the Cluster
Run init and join concurrently — init blocks until all nodes join.
**Server 1 — Initialize:**
```bash
sudo spx admin init \
--node node1 --nodes 3 \
--bind $SPINIFEX_NODE1 --cluster-bind $SPINIFEX_NODE1 \
--port 4432 --region $AWS_REGION --az $AWS_AZ
```
Save the admin credentials — they will not be shown again.
**Server 2 — Join** (while init is running):
```bash
sudo spx admin join \
--node node2 --bind $SPINIFEX_NODE2 --cluster-bind $SPINIFEX_NODE2 \
--host $SPINIFEX_NODE1:4432 --region $AWS_REGION --az $AWS_AZ
```
**Server 3 — Join** (while init is running):
```bash
sudo spx admin join \
--node node3 --bind $SPINIFEX_NODE3 --cluster-bind $SPINIFEX_NODE3 \
--host $SPINIFEX_NODE1:4432 --region $AWS_REGION --az $AWS_AZ
```
## Step 5. Start Services
On **all servers**:
```bash
sudo systemctl start spinifex.target
```
## Step 6. Verify
From any node:
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your cluster is working.
**Congratulations! Your Spinifex cluster is installed.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### Nodes Not Joining
The init command must still be running when join executes. If init exited, re-run with `--force`.
```bash
curl -s http://$SPINIFEX_NODE1:4432/health
```
### OVN Chassis Not Registering
```bash
sudo ovn-sbctl show
sudo ss -tlnp | grep 6642
```
### CA Certificate Not Trusted
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
### Cross-Host VMs Cannot Communicate
```bash
sudo ovs-vsctl show | grep -i geneve
sudo ss -ulnp | grep 6081
```
+124
View File
@@ -0,0 +1,124 @@
---
title: "Source Install"
description: "Build Spinifex from source for development, custom builds, or contributing."
category: "Getting Started"
tags:
- install
- source
- development
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Go Downloads"
url: "https://go.dev/dl/"
---
# Source Installation
> Build Spinifex from source for development, custom builds, or contributing.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide builds Spinifex from source. For production deployments, the [binary installer](/docs/install) is recommended.
**Requirements:**
- Ubuntu 22.04+ / Debian 12+
- Go 1.26.1+
- GCC, make, pkg-config
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
## Instructions
## Step 1. Install Dependencies
```bash
mkdir -p ~/Development/mulga && cd ~/Development/mulga
git clone https://github.com/mulgadc/spinifex.git
sudo make -C spinifex quickinstall
export PATH=$PATH:/usr/local/go/bin
```
## Step 2. Clone and Build
```bash
cd spinifex
./scripts/clone-deps.sh
./scripts/dev-setup.sh
```
Confirm `./bin/spx` exists.
## Step 3. Setup OVN
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
```bash
./scripts/setup-ovn.sh --management
```
If your WAN is a physical NIC, choose one:
```bash
# Dedicated WAN NIC (not your SSH connection):
./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
# Single-NIC host (SSH-safe macvlan):
./scripts/setup-ovn.sh --management --macvlan --wan-iface=enp0s3
```
## Step 4. Initialize
```bash
sudo ./bin/spx admin init --node node1 --nodes 1
```
Save the admin credentials printed during init.
## Step 5. Start Services
```bash
./scripts/start-dev.sh
```
## Step 6. Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your installation is working.
**Congratulations! Spinifex is installed from source.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### Go Not Found in PATH
```bash
export PATH=$PATH:/usr/local/go/bin
```
### ./bin/spx Missing After Build
```bash
./scripts/dev-setup.sh
```
### CA Certificate Not Trusted
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
+132
View File
@@ -0,0 +1,132 @@
---
title: "Single-Node Install"
description: "Install Spinifex on a single server using the binary installer."
category: "Getting Started"
tags:
- install
- single node
- quickstart
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
---
# Installing Spinifex
> Install Spinifex on a single server using the binary installer.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services — EC2, VPC, EBS, and S3 — to bare-metal, edge, and on-prem environments.
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Install](/docs/install-multi-node). To build from source, see [Source Install](/docs/install-source).
**Supported Operating Systems:**
- Ubuntu 22.04 / 24.04 / 25.10
- Debian 12 / 13
**What Gets Installed:**
- Spinifex daemon and CLI
- QEMU/KVM for virtual machine management
- OVN/Open vSwitch for VPC networking
- Predastore (S3-compatible object storage)
- Viperblock (EBS-compatible block storage)
- AWS CLI v2
## Instructions
## Step 1. Install Spinifex
```bash
curl -fsSL https://install.mulgadc.com | bash
```
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
## Step 2. Setup OVN Networking
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
```
If your WAN is a physical NIC, choose one:
```bash
# Dedicated WAN NIC (not your SSH connection):
sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
```
**Separating VPC tunnel traffic from WAN:** If you want Geneve tunnel traffic (inter-VM east-west for VPC traffic) to use a dedicated interface instead of the WAN IP, add `--encap-ip=<IP>` to specify the tunnel endpoint address:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=10.0.0.1
```
This is recommended for production and required in multi-node deployments. See [Multi-Node Install](/docs/install-multi-node) for details.
## Step 3. Initialize
```bash
sudo spx admin init --node node1 --nodes 1
```
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials. Save the admin credentials printed during init — they will not be shown again.
## Step 4. Start Services
```bash
sudo systemctl start spinifex.target
```
## Step 5. Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your installation is working.
**Congratulations! Spinifex is installed.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### spx Command Not Found
```bash
export PATH=$PATH:/usr/local/bin
```
### CA Certificate Not Trusted
`sudo spx admin init` installs the CA automatically. If you need to re-install it manually:
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
### OVN Services Not Starting
```bash
sudo systemctl is-active ovn-controller
journalctl -u ovn-controller --no-pager -n 20
```
+173
View File
@@ -0,0 +1,173 @@
---
title: "Launching Instances"
description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex."
category: "Compute"
tags:
- ec2
- instances
- vm
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS EC2 CLI"
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
---
# Launching Instances
> Launch, manage, and connect to EC2-compatible virtual machines on Spinifex.
## Table of Contents
- [Overview](#overview)
- [Launch](#launch)
- [Manage](#manage)
- [Reboot](#reboot)
- [Modify Instance Attributes](#modify-instance-attributes)
- [Console Output](#console-output)
- [Instance Types](#instance-types)
- [SSH (Development)](#ssh-development)
- [Troubleshooting](#troubleshooting)
- [Instance Fails to Boot](#instance-fails-to-boot)
- [Cannot SSH Into Instance](#cannot-ssh-into-instance)
---
## Overview
Spinifex provides EC2-compatible VM management built on QEMU/KVM. Instances support cloud-init, SSH key injection, VPC networking, and standard AWS lifecycle operations.
**Supported operations:**
- `run-instances` — Launch new VMs
- `describe-instances` — Query state
- `stop-instances` / `start-instances` — Lifecycle
- `reboot-instances` — In-place restart (QMP reset)
- `terminate-instances` — Permanent removal
- `modify-instance-attribute` — Change type, user data, or source/dest check
- `get-console-output` — Retrieve serial console log
- `describe-instance-types` — List available instance types
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Launch
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key
export INSTANCE_ID="i-XXX"
```
## Manage
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
aws ec2 stop-instances --instance-ids $INSTANCE_ID
aws ec2 start-instances --instance-ids $INSTANCE_ID
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
```
## Modify Instance Attributes
Change instance type, user data, or source/dest check. Instance type and user data require the instance to be **stopped** first.
### Change Instance Type
```bash
aws ec2 stop-instances --instance-ids $INSTANCE_ID
aws ec2 modify-instance-attribute \
--instance-id $INSTANCE_ID \
--instance-type t3.medium
aws ec2 start-instances --instance-ids $INSTANCE_ID
```
## Console Output
Retrieve the serial console log for a running instance. Output is base64-encoded.
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID
```
Decode the output:
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text | base64 -d
```
## Instance Types
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
```bash
aws ec2 describe-instance-types
```
Filter to a specific type:
```bash
aws ec2 describe-instance-types --instance-types t3.micro t3.small
```
Show capacity (how many of each type can still be launched):
```bash
aws ec2 describe-instance-types \
--filters Name=capacity,Values=true
```
## SSH (Development)
In development mode, find the QEMU port forward and connect via localhost:
```bash
ps auxw | grep $INSTANCE_ID
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
For production SSH via public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
## Troubleshooting
### Instance Fails to Boot
Check QEMU logs for the instance and verify the AMI architecture matches your host:
```bash
cat ~/spinifex/logs/spinifex.log
aws ec2 describe-images --image-ids $SPINIFEX_AMI
```
If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), import the correct image:
```bash
spx admin images list
spx admin images import --name debian-12-x86_64
```
### Cannot SSH Into Instance
cloud-init needs time to configure the instance after boot. Wait 30-60 seconds and retry.
Verify the SSH key was specified correctly when launching:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
Check the `KeyName` field matches the key you're using to connect.
+108
View File
@@ -0,0 +1,108 @@
---
title: "Moving an AWS Workload to Mulga"
description: "Migrate existing AWS workloads to Spinifex using compatible APIs, SDKs, and Terraform."
category: "Migration"
tags:
- migration
- aws
- terraform
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS CLI Reference"
url: "https://docs.aws.amazon.com/cli/latest/reference/"
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
---
# Moving an AWS Workload to Mulga
> Migrate existing AWS workloads to Spinifex using compatible APIs, SDKs, and Terraform.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex provides drop-in compatibility with AWS APIs, making it possible to migrate existing workloads with minimal changes.
**Supported Services:** VPC, EC2, EBS, S3, IAM
**Compatible Tools:** AWS CLI, AWS SDKs, Terraform, any S3-compatible client
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Configure AWS CLI
```bash
aws ec2 describe-instances
aws s3 ls
```
## Terraform
```hcl
provider "aws" {
region = "ap-southeast-2"
access_key = "your-spinifex-access-key"
secret_key = "your-spinifex-secret-key"
endpoints {
ec2 = "https://localhost:9999"
s3 = "https://localhost:8443"
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
}
```
## Hybrid S3 Sync
```bash
aws s3 sync s3://local-bucket/ s3://cloud-bucket/ --source-region spinifex --region us-east-1
```
## Troubleshooting
## Terraform Provider Errors
Ensure all three skip flags are set in your provider configuration:
```hcl
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
```
Without these, Terraform will try to validate credentials and metadata against real AWS endpoints.
## S3 Signature Errors
Spinifex uses AWS Signature V4. Ensure your AWS CLI is version 2.0 or higher:
```bash
aws --version
```
If using an older version, upgrade:
```bash
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install --update
```
+121
View File
@@ -0,0 +1,121 @@
---
title: "Placement Groups"
description: "Create and manage spread and cluster placement groups for hardware-level instance placement control."
category: "Compute"
tags:
- ec2
- placement
- spread
- cluster
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS Placement Groups"
url: "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-groups.html"
---
# Placement Groups
> Create and manage spread and cluster placement groups for hardware-level instance placement control.
## Table of Contents
- [Overview](#overview)
- [Create](#create)
- [Launch Into a Placement Group](#launch-into-a-placement-group)
- [Describe](#describe)
- [Delete](#delete)
- [Troubleshooting](#troubleshooting)
- [InsufficientInstanceCapacity Error](#insufficientinstancecapacity-error)
- [Cannot Delete Placement Group](#cannot-delete-placement-group)
---
## Overview
Placement groups control how Spinifex distributes instances across physical hosts. Two strategies are supported:
- **Spread** — One instance per physical host, maximising fault isolation
- **Cluster** — All instances on the same host, minimising latency
The `partition` strategy is not supported.
**Supported operations:**
- `create-placement-group` — Create a new group
- `describe-placement-groups` — Query groups with optional filters
- `delete-placement-group` — Remove an empty group
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Create
```bash
aws ec2 create-placement-group \
--group-name my-spread-group \
--strategy spread
```
```bash
aws ec2 create-placement-group \
--group-name my-cluster-group \
--strategy cluster
```
## Launch Into a Placement Group
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--placement GroupName=my-spread-group \
--count 3
```
## Describe
```bash
aws ec2 describe-placement-groups
aws ec2 describe-placement-groups --group-names my-spread-group
aws ec2 describe-placement-groups --group-ids pg-abc123
aws ec2 describe-placement-groups \
--filters Name=strategy,Values=spread
```
## Delete
The group must have no running instances before it can be deleted:
```bash
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 delete-placement-group --group-name my-spread-group
```
## Troubleshooting
### InsufficientInstanceCapacity Error
Not enough distinct physical hosts for a spread launch. Reduce `--count` or terminate existing instances to free host slots:
```bash
spx admin nodes list
```
### Cannot Delete Placement Group
The group still has running instances. Terminate them first:
```bash
aws ec2 describe-instances \
--filters Name=placement-group-name,Values=my-spread-group
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
```
+336
View File
@@ -0,0 +1,336 @@
---
title: "Setting Up Your Cluster"
description: "Import an AMI, create a VPC with a public subnet, and launch your first EC2 instance on Spinifex."
category: "Getting Started"
tags:
- setup
- vpc
- ec2
- quickstart
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS EC2 CLI Reference"
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
---
# Setting Up Your Cluster
> Import an AMI, create a VPC with a public subnet, and launch your first instance.
## Table of Contents
- [1. Import an AMI](#1-import-an-ami)
- [2. Create an SSH Key](#2-create-an-ssh-key)
- [3. Create a VPC and Public Subnet](#3-create-a-vpc-and-public-subnet)
- [4. Launch an Instance](#4-launch-an-instance)
- [5. Connect via SSH](#5-connect-via-ssh)
- [6. Managing Instances](#6-managing-instances)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
- [Binary Install (Single Node)](/docs/install)
- [Multi-Node Install](/docs/install-multi-node)
- [Source Install](/docs/install-source)
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## 1. Import an AMI
List available images and import one matching your architecture:
```bash
spx admin images list
```
```
NAME | DISTRO | VERSION | ARCH | BOOT
debian-12-arm64 | debian | 12 | arm64 | bios
debian-12-x86_64 | debian | 12 | x86_64 | bios
ubuntu-24.04-arm64 | ubuntu | 24.04 | arm64 | bios
ubuntu-24.04-x86_64 | ubuntu | 24.04 | x86_64 | bios
```
Import an image:
```bash
spx admin images import --name ubuntu-24.04-x86_64
```
Or import a local image file:
```bash
spx admin images import --file ~/images/ubuntu-24.04.img --distro ubuntu --version 24.04 --arch x86_64
```
Verify the import and note the AMI ID:
```bash
aws ec2 describe-images
```
## 2. Create an SSH Key
### Option A: Import an existing key
```bash
aws ec2 import-key-pair \
--key-name "spinifex-key" \
--public-key-material fileb://~/.ssh/id_rsa.pub
```
### Option B: Create a new key pair
```bash
aws ec2 create-key-pair --key-name spinifex-key \
| jq -r '.KeyMaterial | rtrimstr("\n")' > ~/.ssh/spinifex-key
chmod 600 ~/.ssh/spinifex-key
ssh-keygen -y -f ~/.ssh/spinifex-key > ~/.ssh/spinifex-key.pub
```
Verify:
```bash
aws ec2 describe-key-pairs
```
## 3. Create a VPC and Public Subnet
### Create a VPC
```bash
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
--query 'Vpc.VpcId' --output text)
echo "VPC: $VPC_ID"
```
### Create an Internet Gateway
An Internet Gateway enables instances in public subnets to reach the internet and be reachable from the LAN/WAN.
```bash
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
echo "IGW: $IGW_ID (attached to $VPC_ID)"
```
### Create a Public Subnet
A public subnet auto-assigns a routable IP to each instance, making it directly reachable from your network.
```bash
SUBNET_ID=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.200.1.0/24 \
--query 'Subnet.SubnetId' --output text)
# Enable auto-assign public IP
aws ec2 modify-subnet-attribute \
--subnet-id $SUBNET_ID \
--map-public-ip-on-launch
echo "Subnet: $SUBNET_ID (public)"
```
### Verify
```bash
aws ec2 describe-vpcs --vpc-ids $VPC_ID
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
```
## 4. Launch an Instance
Query available instance types for your hardware:
```bash
aws ec2 describe-instance-types
```
Launch an instance in the public subnet:
```bash
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id $AMI_ID \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SUBNET_ID \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "Instance: $INSTANCE_ID"
```
Wait for the instance to reach `running` state:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query 'Reservations[0].Instances[0].[State.Name, PrivateIpAddress, PublicIpAddress]' \
--output text
```
Expected output:
```
running 10.200.1.4 192.168.1.155
```
The instance has both a private IP (VPC overlay) and a public IP (from your external pool, routable on your network).
## 5. Connect via SSH
SSH directly to the instance's public IP:
```bash
PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
ssh -i ~/.ssh/spinifex-key ec2-user@$PUBLIC_IP
```
> **Note:** cloud-init takes 30-60 seconds to configure the instance. If SSH is refused, wait and retry.
Once connected, verify the instance has internet access:
```bash
curl -s http://ifconfig.me
```
This should return the instance's public IP or the gateway's SNAT address.
## 6. Managing Instances
### Stop
```bash
aws ec2 stop-instances --instance-ids $INSTANCE_ID
```
### Start
```bash
aws ec2 start-instances --instance-ids $INSTANCE_ID
```
### Terminate
```bash
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
```
### Console Output
View the instance's serial console log (useful for debugging boot issues):
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text
```
### Multi-Node: Check Instance Placement
On a multi-node cluster, instances are distributed across nodes:
```bash
spx get vms
```
## Additional Options
### Private Subnets (No Public IP)
Create a subnet without `--map-public-ip-on-launch`:
```bash
PRIVATE_SUBNET=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.200.2.0/24 \
--query 'Subnet.SubnetId' --output text)
```
Instances in private subnets get a private IP only. They can still reach the internet via SNAT through the VPC router, but are not directly reachable from your network.
### Multiple Accounts
Create isolated accounts with their own resources:
```bash
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
### Web UI
The Spinifex web interface is available at `https://localhost:3000` when services are running.
## Troubleshooting
### Instance Stuck in Pending
```bash
cat ~/spinifex/logs/spinifex.log
aws ec2 describe-images
```
### SSH Connection Refused
cloud-init needs 30-60 seconds after boot. Check instance state:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
### No Public IP Assigned
Verify the subnet has `MapPublicIpOnLaunch` enabled:
```bash
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
```
If `MapPublicIpOnLaunch` is false:
```bash
aws ec2 modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch
```
Also verify an Internet Gateway is attached to the VPC:
```bash
aws ec2 describe-internet-gateways
```
### Instance Has No Internet Access
Check the VPC router's NAT rules (from the host):
```bash
sudo ovn-nbctl lr-nat-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
```
Verify the default route exists:
```bash
sudo ovn-nbctl lr-route-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
```
+96
View File
@@ -0,0 +1,96 @@
---
title: "Spinifex Admin CLI"
description: "Complete reference for the Spinifex administration CLI. Manage accounts, nodes, VMs, and services."
category: "Administration"
tags:
- cli
- admin
- reference
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Spinifex Admin CLI
> Complete reference for the Spinifex administration CLI.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
The `spx` binary is the central administration tool for managing your Spinifex infrastructure. It provides commands for cluster initialization, account management, node operations, VM lifecycle, and service control.
**Binary location:** `/usr/local/bin/spx` (binary install) or `~/Development/mulga/spinifex/bin/spx` (source install)
All services in the Spinifex platform are managed through this single binary.
## Instructions
## Account Management
```bash
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Node Management
```bash
spx get nodes
```
```
NAME STATUS IP REGION AZ UPTIME VMs
node1 Ready 127.0.0.1 ap-southeast-2 ap-southeast-2a 2m 0
node2 Ready 127.0.0.2 ap-southeast-2 ap-southeast-2a 2m 0
node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
```
## Monitor Resources
```bash
spx top nodes
```
## Image Management
```bash
spx admin images list
spx admin images import --name debian-12-arm64
```
## Cluster Shutdown
```bash
spx admin cluster shutdown
```
## Troubleshooting
### Permission Denied Running Spinifex
The binary may not be executable. Fix permissions:
```bash
chmod +x /usr/local/bin/spx
```
If you get permission errors during operations, ensure you're running with appropriate privileges. Some OVN and networking commands require `sudo`.
### Services Fail to Start
Check the daemon logs for specific errors:
```bash
ls ~/spinifex/logs/
cat ~/spinifex/logs/spinifex.log
```
Common causes include port conflicts, missing OVN configuration, or untrusted CA certificates.
@@ -0,0 +1,158 @@
---
title: "Bastion with Private Subnet"
description: "Deploy a bastion host to access an isolated private compute instance with no internet connectivity."
category: "Terraform Workbooks"
tags:
- terraform
- bastion
- vpc
- security
- private subnet
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: Bastion with Private Subnet
> Deploy a bastion host to access an isolated private compute instance with no internet connectivity.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Deploy a VPC with public and private subnets where the private subnet has no internet access. A bastion host in the public subnet is the only way to reach instances in the private subnet. This pattern is used for sensitive workloads that must remain isolated from the internet — the private instance cannot make or receive any connections outside the VPC.
**Architecture:**
```
WAN ──SSH──▶ Bastion (public subnet)
│
▼ SSH (private IP)
App Server (private subnet, no internet)
```
**What you'll learn:**
- Creating public and private subnets in a VPC
- Isolating compute instances from the internet using route tables and security groups
- Using a bastion host as the sole access point to private resources
- SSH hopping through a bastion to reach private instances
**Use cases:**
- Sensitive data processing that must not have internet egress
- Internal services that should only be reachable within the VPC
- Compliance workloads requiring network isolation
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- OpenTofu or Terraform installed
- `AWS_PROFILE=spinifex` configured
## Instructions
### Step 1. Get the Template
Clone the Terraform examples from the Spinifex repository:
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform
cd docs/terraform/bastion-private-subnet
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
### Step 3. Connect
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH is unreachable, wait and retry.
SSH into the bastion:
```bash
ssh -i bastion-demo.pem ec2-user@<bastion_public_ip>
```
From the bastion, SSH to the private instance. The key is pre-installed at `~/.ssh/bastion-demo.pem` via cloud-init:
```bash
ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
```
### Step 4. Verify Isolation
From the private instance, confirm there is no internet connectivity:
```bash
# This should time out — the private instance has no route to the internet
curl --connect-timeout 5 https://deb.debian.org || echo "No internet access (expected)"
```
The private instance can only communicate within the VPC (`10.20.0.0/16`). Its security group restricts egress to VPC-internal traffic only.
### Clean Up
```bash
tofu destroy
```
## Troubleshooting
### Cannot SSH to Private Instance
The private instance has no public IP — it is only reachable from the bastion. SSH to the bastion first, then use the pre-installed key to hop:
```bash
ssh -i bastion-demo.pem ec2-user@<bastion_ip>
ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
```
If the key is missing on the bastion, check that cloud-init completed successfully:
```bash
sudo journalctl -u cloud-init --no-pager
ls -la ~/.ssh/bastion-demo.pem
```
### Private Instance Can Reach the Internet
If the private instance unexpectedly has internet access, check that its route table has no default route:
```bash
aws ec2 describe-route-tables --profile spinifex
```
The private route table should have no `0.0.0.0/0` route. Also verify the private security group egress is restricted to the VPC CIDR (`10.20.0.0/16`).
### AMI Not Found
Ensure you have imported a Debian 12 image:
```bash
aws ec2 describe-images --owners 000000000000 --profile spinifex
```
@@ -0,0 +1,346 @@
# Example 2: Bastion Host with Private Subnet
#
# Deploys a VPC with both public and private subnets. A bastion host in the
# public subnet provides SSH access to an isolated instance in the private
# subnet. The private instance has no internet connectivity — ideal for
# sensitive workloads that must remain air-gapped from the internet.
#
# Architecture:
#
# WAN ──SSH──▶ Bastion (public subnet)
# │
# ▼ SSH (private IP)
# App Server (private subnet, no internet)
#
# Usage:
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
#
# After apply:
# # SSH to the bastion
# ssh -i bastion-demo.pem ec2-user@<bastion_ip>
#
# # From the bastion, SSH to the private instance
# # (the key is pre-installed at ~/.ssh/bastion-demo.pem via cloud-init)
# ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "spinifex_endpoint" {
type = string
default = "https://localhost:9999"
description = "Spinifex AWS gateway endpoint"
}
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.spinifex_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian12" {
most_recent = true
owners = ["000000000000"]
filter {
name = "name"
values = ["*debian-12*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# ---------------------------------------------------------------------------
# SSH Key Pair (shared by bastion and private instances)
# ---------------------------------------------------------------------------
resource "tls_private_key" "bastion" {
algorithm = "ED25519"
}
resource "aws_key_pair" "bastion" {
key_name = "bastion-demo"
public_key = tls_private_key.bastion.public_key_openssh
}
resource "local_file" "bastion_pem" {
filename = "${path.module}/bastion-demo.pem"
content = tls_private_key.bastion.private_key_openssh
file_permission = "0600"
}
# ---------------------------------------------------------------------------
# VPC
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.20.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "bastion-demo-vpc"
}
}
# ---------------------------------------------------------------------------
# Internet Gateway — only the public subnet routes through this
# ---------------------------------------------------------------------------
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "bastion-demo-igw"
}
}
# ---------------------------------------------------------------------------
# Public Subnet — bastion host lives here
# ---------------------------------------------------------------------------
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.20.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "bastion-demo-public"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "bastion-demo-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# ---------------------------------------------------------------------------
# Private Subnet — isolated instances live here (no public IPs, no internet)
# ---------------------------------------------------------------------------
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.20.2.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = false
tags = {
Name = "bastion-demo-private"
}
}
# Private route table — no default route, no internet access
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "bastion-demo-private-rt"
}
}
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
# ---------------------------------------------------------------------------
# Security Groups
# ---------------------------------------------------------------------------
# Bastion: SSH from anywhere
resource "aws_security_group" "bastion" {
name = "bastion-demo-bastion-sg"
description = "Bastion: SSH from WAN"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "bastion-demo-bastion-sg"
}
}
# Private instances: SSH only from the bastion security group
resource "aws_security_group" "private" {
name = "bastion-demo-private-sg"
description = "Private: SSH from bastion only"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH from bastion"
from_port = 22
to_port = 22
protocol = "tcp"
security_groups = [aws_security_group.bastion.id]
}
egress {
description = "VPC internal only"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["10.20.0.0/16"]
}
tags = {
Name = "bastion-demo-private-sg"
}
}
# ---------------------------------------------------------------------------
# Bastion Host (public subnet)
# ---------------------------------------------------------------------------
resource "aws_instance" "bastion" {
ami = data.aws_ami.debian12.id
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.bastion.id]
key_name = aws_key_pair.bastion.key_name
associate_public_ip_address = true
# Copy the SSH private key onto the bastion so you can hop to private instances
user_data_base64 = base64encode(<<-USERDATA
#!/bin/bash
set -euo pipefail
mkdir -p /home/ec2-user/.ssh
cat > /home/ec2-user/.ssh/bastion-demo.pem <<'KEY'
${tls_private_key.bastion.private_key_openssh}
KEY
chmod 600 /home/ec2-user/.ssh/bastion-demo.pem
chown -R ec2-user:ec2-user /home/ec2-user/.ssh
USERDATA
)
tags = {
Name = "bastion-demo-bastion"
}
}
# ---------------------------------------------------------------------------
# Private Instance (private subnet — no public IP, no internet)
# ---------------------------------------------------------------------------
resource "aws_instance" "private" {
ami = data.aws_ami.debian12.id
instance_type = "t3.small"
subnet_id = aws_subnet.private.id
vpc_security_group_ids = [aws_security_group.private.id]
key_name = aws_key_pair.bastion.key_name
tags = {
Name = "bastion-demo-private-app"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "note" {
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH is unreachable, wait and retry."
}
output "bastion_public_ip" {
value = aws_instance.bastion.public_ip
}
output "private_instance_ip" {
value = aws_instance.private.private_ip
}
output "ssh_to_bastion" {
description = "SSH to the bastion host"
value = "ssh -i bastion-demo.pem ec2-user@${aws_instance.bastion.public_ip}"
}
output "ssh_to_private_from_bastion" {
description = "From the bastion, SSH to the private instance (key is pre-installed via cloud-init)"
value = "ssh -i ~/.ssh/bastion-demo.pem ec2-user@${aws_instance.private.private_ip}"
}
+129
View File
@@ -0,0 +1,129 @@
---
title: "Nginx Web Server"
description: "Deploy a VPC with a public subnet and an EC2 instance running Nginx using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- nginx
- ec2
- vpc
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: Nginx Web Server
> Deploy a VPC with a public subnet and an EC2 instance running Nginx using Terraform on Spinifex.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Deploy a complete Nginx web server on Spinifex using Terraform/OpenTofu. This workbook provisions a VPC, public subnet, internet gateway, route table, security group, SSH key pair, and an EC2 instance with cloud-init user-data that installs and starts Nginx.
**What you'll learn:**
- Configuring the AWS Terraform provider to target Spinifex
- Creating a VPC with public internet access
- Provisioning an EC2 instance with cloud-init user-data
- Generating SSH key pairs with the TLS provider
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- OpenTofu or Terraform installed
- `AWS_PROFILE=spinifex` configured
## Instructions
### Step 1. Get the Template
Clone the Terraform examples from the Spinifex repository:
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform
cd docs/terraform/nginx-webserver
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
### Step 3. Verify
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry.
After apply completes, use the outputs to test:
```bash
curl http://<public_ip>
ssh -i nginx-demo.pem ec2-user@<public_ip>
```
Open the `web_url` output in your browser to see the Nginx welcome page.
### Clean Up
```bash
tofu destroy
```
## Troubleshooting
### AMI Not Found
Ensure you have imported a Debian 12 image. Check available AMIs:
```bash
aws ec2 describe-images --owners 000000000000 --profile spinifex
```
### Provider Connection Refused
Verify Spinifex services are running:
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
### SSH Connection Timeout
Check that the security group allows inbound SSH (port 22) and that the instance has a public IP assigned. Verify the instance is running:
```bash
aws ec2 describe-instances --profile spinifex
```
### Nginx Not Responding
SSH into the instance and check cloud-init logs:
```bash
ssh -i nginx-demo.pem ec2-user@<public_ip>
sudo journalctl -u cloud-init --no-pager
sudo systemctl status nginx
```
+286
View File
@@ -0,0 +1,286 @@
# Example 1: Nginx Web Server on Spinifex
#
# Deploys a VPC with a public subnet and an EC2 instance running Nginx.
# Demonstrates: VPC, subnet, internet gateway, route table, security group,
# key pair, cloud-init user-data, and EC2 instance provisioning.
#
# Usage:
# cd spinifex/scripts/iac/aws/examples/01-nginx-webserver
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
#
# After apply:
# curl http://<public_ip> # Nginx welcome page
# ssh -i nginx-demo.pem ec2-user@<public_ip>
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "spinifex_endpoint" {
type = string
default = "https://localhost:9999"
description = "Spinifex AWS gateway endpoint"
}
# ---------------------------------------------------------------------------
# Provider — point the AWS provider at Spinifex
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.spinifex_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian12" {
most_recent = true
owners = ["000000000000"] # Spinifex system images
filter {
name = "name"
values = ["*debian-12*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# ---------------------------------------------------------------------------
# SSH Key Pair
# ---------------------------------------------------------------------------
resource "tls_private_key" "nginx" {
algorithm = "ED25519"
}
resource "aws_key_pair" "nginx" {
key_name = "nginx-demo"
public_key = tls_private_key.nginx.public_key_openssh
}
resource "local_file" "nginx_pem" {
filename = "${path.module}/nginx-demo.pem"
content = tls_private_key.nginx.private_key_openssh
file_permission = "0600"
}
# ---------------------------------------------------------------------------
# VPC
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.10.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "nginx-demo-vpc"
}
}
# ---------------------------------------------------------------------------
# Internet Gateway — gives the public subnet a route to the internet
# ---------------------------------------------------------------------------
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "nginx-demo-igw"
}
}
# ---------------------------------------------------------------------------
# Public Subnet
# ---------------------------------------------------------------------------
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.10.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "nginx-demo-public"
}
}
# ---------------------------------------------------------------------------
# Route Table — send 0.0.0.0/0 through the internet gateway
# ---------------------------------------------------------------------------
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "nginx-demo-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# ---------------------------------------------------------------------------
# Security Group — SSH + HTTP inbound, all outbound
# ---------------------------------------------------------------------------
resource "aws_security_group" "web" {
name = "nginx-demo-sg"
description = "Allow SSH and HTTP inbound"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "nginx-demo-sg"
}
}
# ---------------------------------------------------------------------------
# EC2 Instance — Debian 12 with Nginx installed via cloud-init
# ---------------------------------------------------------------------------
resource "aws_instance" "nginx" {
ami = data.aws_ami.debian12.id
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.nginx.key_name
associate_public_ip_address = true
user_data_base64 = base64encode(<<-USERDATA
#!/bin/bash
set -euo pipefail
# Install Nginx
apt-get update -y
apt-get install -y nginx
# Write a custom landing page
cat > /var/www/html/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head><title>Spinifex Demo</title></head>
<body style="font-family: sans-serif; max-width: 600px; margin: 80px auto;">
<h1>Hello from Spinifex!</h1>
<p>This Nginx server was deployed with Terraform on Spinifex infrastructure.</p>
<hr>
<p><small>Instance provisioned via cloud-init user-data.</small></p>
</body>
</html>
HTML
# Ensure Nginx is running
systemctl enable nginx
systemctl restart nginx
USERDATA
)
tags = {
Name = "nginx-demo"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "note" {
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry."
}
output "instance_id" {
value = aws_instance.nginx.id
}
output "public_ip" {
value = aws_instance.nginx.public_ip
}
output "ssh_command" {
value = "ssh -i nginx-demo.pem ec2-user@${aws_instance.nginx.public_ip}"
}
output "web_url" {
value = "http://${aws_instance.nginx.public_ip}"
}
+162
View File
@@ -0,0 +1,162 @@
---
title: "S3-Backed Web App"
description: "Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- s3
- predastore
- flask
- webapp
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: S3-Backed Web App
> Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Deploy an EC2 instance running a Flask file-sharing web application backed by S3 (Predastore). Users can upload files through a web form and browse uploaded content — demonstrating Terraform managing both compute and object storage together.
**Architecture:**
```
Browser ──HTTP──▶ EC2 Instance (Flask webapp, port 80)
│
▼ S3 API (boto3)
Predastore (port 8443)
```
**What you'll learn:**
- Configuring the AWS provider with both Spinifex and Predastore endpoints
- Creating S3 buckets on Predastore via Terraform
- Deploying a Python webapp with cloud-init that talks to S3
- Passing credentials and configuration to instances via user-data
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- Predastore running (S3 API on port 8443)
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- OpenTofu or Terraform installed
- `AWS_PROFILE=spinifex` configured
- The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
## Instructions
### Step 1. Get the Template
Clone the Terraform examples from the Spinifex repository:
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform
cd docs/terraform/s3-webapp
```
Or create the files manually and paste the full configuration below.
### Step 2. Create terraform.tfvars
Before deploying, create a `terraform.tfvars` with your Predastore credentials. The `predastore_host` must be reachable from inside the VPC — use the host's br-wan or LAN IP, not localhost.
<!-- INCLUDE: terraform.tfvars.example lang:hcl -->
### Step 3. Create main.tf
<!-- INCLUDE: main.tf lang:hcl -->
### Step 4. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
### Step 5. Test the Application
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry.
Open the `web_url` output in your browser. You should see the file browser UI. Upload a file and verify it appears in the list.
```bash
# Verify via CLI
curl http://<public_ip>
# Check the S3 bucket directly
aws s3 ls s3://webapp-uploads/ --profile spinifex --endpoint-url https://localhost:8443
```
### Clean Up
```bash
tofu destroy
```
## Troubleshooting
### Predastore Connection Refused from Instance
The EC2 instance cannot reach `localhost` on the host. Set `predastore_host` to the host's br-wan or LAN IP address:
```hcl
predastore_host = "192.168.1.10:8443"
```
### S3 Bucket Creation Fails
Verify Predastore is running and accessible:
```bash
curl -k https://localhost:8443/
aws s3 ls --profile spinifex --endpoint-url https://localhost:8443
```
### Flask App Not Starting
SSH into the instance and check the service:
```bash
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
sudo systemctl status s3-webapp
sudo journalctl -u s3-webapp --no-pager -n 50
```
### Upload Fails or Files Not Appearing
Check that the S3 credentials in `/opt/webapp/.env` are correct and that the bucket exists:
```bash
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
cat /opt/webapp/.env
/opt/webapp/venv/bin/python -c "import boto3; print('boto3 OK')"
```
### AMI Not Found
Ensure you have imported a Debian 12 image:
```bash
aws ec2 describe-images --owners 000000000000 --profile spinifex
```
+445
View File
@@ -0,0 +1,445 @@
# Example 3: S3-Backed Web Application
#
# Deploys an EC2 instance running a simple file-sharing webapp backed by S3
# (Predastore). Users can upload files through a web form and browse uploaded
# content — demonstrating Terraform managing both compute (Spinifex) and
# object storage (Predastore) resources together.
#
# Architecture:
#
# Browser ──HTTP──▶ EC2 Instance (Flask webapp, port 80)
# │
# ▼ S3 API (boto3)
# Predastore (port 8443)
#
# Prerequisites:
# - Spinifex services running (gateway on port 9999)
# - Predastore running (S3 API on port 8443)
# - The EC2 instance must be able to reach the Predastore endpoint.
# Set `predastore_host` to the IP reachable from inside the VPC
# (e.g. the host's br-wan IP, NOT localhost).
#
# Usage:
# cd spinifex/scripts/iac/aws/examples/03-s3-webapp
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
#
# After apply:
# curl http://<public_ip> # File browser UI
# ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "spinifex_endpoint" {
type = string
default = "https://localhost:9999"
description = "Spinifex AWS gateway endpoint (EC2/IAM)"
}
variable "predastore_endpoint" {
type = string
default = "https://localhost:8443"
description = "Predastore S3 endpoint (for Terraform to create buckets)"
}
variable "predastore_host" {
type = string
description = "Predastore host:port reachable from inside the VPC (e.g. 192.168.1.10:8443)"
}
variable "s3_access_key" {
type = string
description = "S3 access key for Predastore"
}
variable "s3_secret_key" {
type = string
sensitive = true
description = "S3 secret key for Predastore"
}
variable "bucket_name" {
type = string
default = "webapp-uploads"
}
# ---------------------------------------------------------------------------
# Provider — EC2 via Spinifex gateway, S3 via Predastore
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
access_key = var.s3_access_key
secret_key = var.s3_secret_key
endpoints {
ec2 = var.spinifex_endpoint
s3 = var.predastore_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
}
s3_use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian12" {
most_recent = true
owners = ["000000000000"]
filter {
name = "name"
values = ["*debian-12*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# ---------------------------------------------------------------------------
# S3 Bucket (created on Predastore)
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "uploads" {
bucket = var.bucket_name
}
# ---------------------------------------------------------------------------
# SSH Key Pair
# ---------------------------------------------------------------------------
resource "tls_private_key" "webapp" {
algorithm = "ED25519"
}
resource "aws_key_pair" "webapp" {
key_name = "s3-webapp-demo"
public_key = tls_private_key.webapp.public_key_openssh
}
resource "local_file" "webapp_pem" {
filename = "${path.module}/s3-webapp-demo.pem"
content = tls_private_key.webapp.private_key_openssh
file_permission = "0600"
}
# ---------------------------------------------------------------------------
# VPC + Public Subnet
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.30.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "s3-webapp-demo-vpc"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "s3-webapp-demo-igw"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.30.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "s3-webapp-demo-public"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "s3-webapp-demo-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# ---------------------------------------------------------------------------
# Security Group — SSH + HTTP inbound, all outbound
# ---------------------------------------------------------------------------
resource "aws_security_group" "webapp" {
name = "s3-webapp-demo-sg"
description = "Allow SSH and HTTP inbound"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "s3-webapp-demo-sg"
}
}
# ---------------------------------------------------------------------------
# EC2 Instance — Flask webapp that talks to Predastore S3
# ---------------------------------------------------------------------------
resource "aws_instance" "webapp" {
ami = data.aws_ami.debian12.id
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.webapp.id]
key_name = aws_key_pair.webapp.key_name
associate_public_ip_address = true
user_data_base64 = base64encode(<<-USERDATA
#!/bin/bash
set -euo pipefail
# Install dependencies
apt-get update -y
apt-get install -y python3-pip python3-venv
# Create app directory and virtualenv
mkdir -p /opt/webapp
python3 -m venv /opt/webapp/venv
/opt/webapp/venv/bin/pip install flask boto3
# Write S3 credentials config
cat > /opt/webapp/.env <<'ENVFILE'
S3_ENDPOINT=https://${var.predastore_host}
S3_BUCKET=${var.bucket_name}
S3_ACCESS_KEY=${var.s3_access_key}
S3_SECRET_KEY=${var.s3_secret_key}
S3_REGION=${var.region}
ENVFILE
# Write the Flask application
cat > /opt/webapp/app.py <<'PYEOF'
import os, io, urllib3
from flask import Flask, request, redirect, url_for, Response
# Suppress TLS warnings for self-signed certs
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Load env
env = {}
with open("/opt/webapp/.env") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k] = v
import boto3
from botocore.config import Config
s3 = boto3.client(
"s3",
endpoint_url=env["S3_ENDPOINT"],
aws_access_key_id=env["S3_ACCESS_KEY"],
aws_secret_access_key=env["S3_SECRET_KEY"],
region_name=env["S3_REGION"],
verify=False,
config=Config(s3={"addressing_style": "path"}),
)
BUCKET = env["S3_BUCKET"]
app = Flask(__name__)
@app.route("/")
def index():
# List objects in the bucket
try:
resp = s3.list_objects_v2(Bucket=BUCKET)
objects = resp.get("Contents", [])
except Exception as e:
objects = []
rows = ""
for obj in objects:
key = obj["Key"]
size = obj["Size"]
rows += f'<tr><td><a href="/files/{key}">{key}</a></td><td>{size} bytes</td></tr>\n'
return f"""<!DOCTYPE html>
<html>
<head><title>Spinifex S3 File Browser</title></head>
<body style="font-family: sans-serif; max-width: 700px; margin: 40px auto;">
<h1>Spinifex S3 File Browser</h1>
<p>Bucket: <code>{BUCKET}</code></p>
<h2>Upload a File</h2>
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="file" required>
<button type="submit">Upload</button>
</form>
<h2>Files</h2>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
<tr><th>Key</th><th>Size</th></tr>
{rows if rows else '<tr><td colspan="2">No files yet</td></tr>'}
</table>
<hr>
<p><small>Powered by Spinifex + Predastore</small></p>
</body>
</html>"""
@app.route("/upload", methods=["POST"])
def upload():
f = request.files.get("file")
if not f or not f.filename:
return redirect("/")
s3.put_object(Bucket=BUCKET, Key=f.filename, Body=f.read())
return redirect("/")
@app.route("/files/<path:key>")
def download(key):
try:
obj = s3.get_object(Bucket=BUCKET, Key=key)
return Response(
obj["Body"].read(),
headers={"Content-Disposition": f'inline; filename="{key}"'},
)
except Exception:
return "Not found", 404
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)
PYEOF
# Create a systemd service so the webapp starts on boot
cat > /etc/systemd/system/s3-webapp.service <<'SVCEOF'
[Unit]
Description=S3 File Browser Webapp
After=network.target
[Service]
Type=simple
ExecStart=/opt/webapp/venv/bin/python /opt/webapp/app.py
WorkingDirectory=/opt/webapp
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
SVCEOF
systemctl daemon-reload
systemctl enable s3-webapp
systemctl start s3-webapp
USERDATA
)
tags = {
Name = "s3-webapp-demo"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "note" {
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry."
}
output "instance_id" {
value = aws_instance.webapp.id
}
output "public_ip" {
value = aws_instance.webapp.public_ip
}
output "bucket_name" {
value = aws_s3_bucket.uploads.id
}
output "ssh_command" {
value = "ssh -i s3-webapp-demo.pem ec2-user@${aws_instance.webapp.public_ip}"
}
output "web_url" {
value = "http://${aws_instance.webapp.public_ip}"
}
@@ -0,0 +1,14 @@
# Copy this to terraform.tfvars and fill in your values.
#
# The predastore_host must be reachable from INSIDE the VPC guest — not
# localhost. Use the host's br-wan or LAN IP, e.g. "192.168.1.10:8443".
predastore_host = "192.168.1.10:8443"
s3_access_key = "AKIAIOSFODNN7EXAMPLE"
s3_secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# Optional overrides:
# spinifex_endpoint = "https://localhost:9999"
# predastore_endpoint = "https://localhost:8443"
# bucket_name = "webapp-uploads"
# region = "ap-southeast-2"
File diff suppressed because it is too large Load Diff
+89 -42
View File
@@ -1,66 +1,113 @@
module github.com/mulgadc/hive
module github.com/mulgadc/spinifex
go 1.25
go 1.26.1
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/davecgh/go-spew v1.1.1
github.com/gofiber/fiber/v2 v2.52.9
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/kdomanski/iso9660 v0.4.0
github.com/mulgadc/predastore v0.1.5
github.com/mulgadc/viperblock v0.1.1
github.com/nats-io/nats-server/v2 v2.11.6
github.com/nats-io/nats.go v1.43.0
github.com/pterm/pterm v0.12.82
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
go.uber.org/automaxprocs v1.6.0
gopkg.in/ini.v1 v1.67.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mulgadc/predastore v1.1.4
github.com/mulgadc/viperblock v1.1.5
github.com/nats-io/nats-server/v2 v2.12.6
github.com/nats-io/nats.go v1.50.0
github.com/ovn-kubernetes/libovsdb v0.8.1
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pterm/pterm v0.12.83
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
gopkg.in/ini.v1 v1.67.1
)
require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
github.com/buraksezer/consistent v0.10.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/hub v1.0.2 // indirect
github.com/cenkalti/rpc2 v1.0.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger/v4 v4.9.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/raft v1.7.3 // indirect
github.com/hashicorp/raft-boltdb/v2 v2.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/minio/highwayhash v1.0.3 // indirect
github.com/nats-io/jwt/v2 v2.7.4 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.57.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/time v0.12.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/telemetry v0.0.0-20260304144227-18da59047661 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool golang.org/x/vuln/cmd/govulncheck
+379 -96
View File
@@ -6,6 +6,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
@@ -15,13 +17,77 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0=
github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/antithesishq/antithesis-sdk-go v0.7.0 h1:uWDG8BqLD1lI2ps38WDz2vXflrTX2+vLX0SvZtztJtE=
github.com/antithesishq/antithesis-sdk-go v0.7.0/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW25rrZlU=
github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8=
github.com/cenkalti/hub v1.0.2/go.mod h1:8LAFAZcCasb83vfxatMUnZHRoQcffho2ELpHb+kaTJU=
github.com/cenkalti/rpc2 v1.0.5 h1:T6l4SS3ja3eaJfRyZrn7Oco/PSx/pr3YK5cjCgLVLTk=
github.com/cenkalti/rpc2 v1.0.5/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwUf7iaw=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
@@ -29,41 +95,135 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w=
github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU=
github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA=
github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY=
github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI=
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44=
github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo=
github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ=
github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKcZ+qp2VlaaZsYZfLOmIiuJNpEi48Rl8u9cQ=
github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0=
github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyvj6eW9pwkc=
github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg=
github.com/kdomanski/iso9660 v0.4.0/go.mod h1:OxUSupHsO9ceI8lBLPJKWBTphLemjrCQY8LPXM7qSzU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -71,38 +231,84 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/mulgadc/predastore v0.1.5 h1:7SySkQQ5MQ1NTcKcB14pCUIJipq0YVgCJZeKM6ytrLM=
github.com/mulgadc/predastore v0.1.5/go.mod h1:BhMymRV3zMTu2S+5pJnNDDm5+AGakjhSc/PKX0q8Xfg=
github.com/mulgadc/viperblock v0.1.1 h1:R1DVX1S+/xPPMQ4LIFkwIX7Es+RS3Wgdd8bsWLTEvgY=
github.com/mulgadc/viperblock v0.1.1/go.mod h1:zCjdQ76fi2Qs2a5ign9A/Uqgbz77xts5WV0OR1be48I=
github.com/nats-io/jwt/v2 v2.7.4 h1:jXFuDDxs/GQjGDZGhNgH4tXzSUK6WQi2rsj4xmsNOtI=
github.com/nats-io/jwt/v2 v2.7.4/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
github.com/nats-io/nats-server/v2 v2.11.6 h1:4VXRjbTUFKEB+7UoaKL3F5Y83xC7MxPoIONOnGgpkHw=
github.com/nats-io/nats-server/v2 v2.11.6/go.mod h1:2xoztlcb4lDL5Blh1/BiukkKELXvKQ5Vy29FPVRBUYs=
github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mulgadc/predastore v1.1.4 h1:czeGQWWqmmZuGnUuvyrORavySN/SGPTwjUxBd/9swuU=
github.com/mulgadc/predastore v1.1.4/go.mod h1:YP4TaathWczm9weoI8YomZ1/IgnFB9WJowkHPidh6rw=
github.com/mulgadc/viperblock v1.1.5 h1:ojmoJQrY7VkDGzdQj07w2F7ICRJX9RlGAKyn87Wa55o=
github.com/mulgadc/viperblock v1.1.5/go.mod h1:utoIQLwCmPujOBaT0mL5NhW3m2fwjQPQiQRJH6+Wtk8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8=
github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo=
github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds=
github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4=
github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
@@ -110,118 +316,195 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ=
github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg=
github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260304144227-18da59047661 h1:AjMsnhVxCTd9HssPcdXPhNTQ0YazDt5+52EqmcWQ0PA=
golang.org/x/telemetry v0.0.0-20260304144227-18da59047661/go.mod h1:NuITXsA9cTiqnXtVk+/wrBT2Ja4X5hsfGOYRJ6kgYjs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-16
View File
@@ -1,16 +0,0 @@
package awssmithy
type FilterList struct {
Name string
Values []string
}
type DescribeInstancesRequest struct {
InstanceIds []string `xml:"InstanceId"`
DryRun bool `xml:"DryRun"`
Filters []FilterList `xml:"Filter"`
NextToken string `xml:"nextToken"`
MaxResults int `xml:"maxResults"`
}
-227
View File
@@ -1,227 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"github.com/nats-io/nats.go"
"github.com/spf13/viper"
)
// Config holds all configuration for the application
type Config struct {
Daemon DaemonConfig `mapstructure:"daemon"`
NATS NATSConfig `mapstructure:"nats"`
Predastore PredastoreConfig `mapstructure:"predastore"`
AWSGW AWSGWConfig `mapstructure:"awsgw"`
// Authentication
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Host string `mapstructure:"host"`
BaseDir string `mapstructure:"base_dir"`
WalDir string `mapstructure:"wal_dir"`
}
type AWSGWConfig struct {
Host string `mapstructure:"host"`
TLSKey string `mapstructure:"tlskey"`
TLSCert string `mapstructure:"tlscert"`
Config string `mapstructure:"config"`
Debug bool `mapstructure:"debug"`
ExpectedNodes int `mapstructure:"expected_nodes"`
}
type PredastoreConfig struct {
Host string `mapstructure:"host"`
Bucket string `mapstructure:"bucket"`
Region string `mapstructure:"region"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
BaseDir string `mapstructure:"base_dir"`
}
// DaemonConfig holds the daemon configuration
type DaemonConfig struct {
Host string `mapstructure:"host"`
TLSKey string `mapstructure:"tlskey"`
TLSCert string `mapstructure:"tlscert"`
}
// NATSConfig holds the NATS configuration
type NATSConfig struct {
Host string `mapstructure:"host"`
ACL NATSACL `mapstructure:"acl"`
Sub NATSSub `mapstructure:"sub"`
}
// NATSACL holds the NATS ACL configuration
type NATSACL struct {
Token string `mapstructure:"token"`
}
// NATSSub holds the NATS subscription configuration
type NATSSub struct {
Subject string `mapstructure:"subject"`
}
type EBSRequests struct {
Requests []EBSRequest `mapstructure:"ebs_requests"`
Mu sync.Mutex `json:"-"`
}
type EBSRequest struct {
Name string
VolType string
Boot bool
EFI bool
CloudInit bool
NBDURI string
}
type EBSMountResponse struct {
URI string
Mounted bool
Error string
}
type EBSUnMountResponse struct {
Volume string
Mounted bool
Error string
}
type EBSDeleteRequest struct {
Volume string
}
type EBSDeleteResponse struct {
Volume string
Success bool
Error string
}
// EC2, TODO: Move to vm.go or more applicable place
type EC2Response struct {
InstanceID string
Hostname string
Success bool
Status string
Error string
}
type EC2DescribeRequest struct {
InstanceID string
}
type EC2DescribeResponse struct {
InstanceID string
Hostname string
Status string
Error string
}
type EC2StartInstancesRequest struct {
InstanceID string
}
type EC2StartInstancesResponse struct {
InstanceID string
Status string
Error string
}
// TODO: Make a generic function for the response
func (ec2StartInstanceResponse EC2StartInstancesResponse) Respond(msg *nats.Msg) {
response, err := json.Marshal(ec2StartInstanceResponse)
if err != nil {
slog.Error("Failed to marshal response", "err", err)
return
}
msg.Respond(response)
}
func (ec2DescribeResponse EC2DescribeResponse) Respond(msg *nats.Msg) {
response, err := json.Marshal(ec2DescribeResponse)
if err != nil {
slog.Error("Failed to marshal response", "err", err)
return
}
msg.Respond(response)
}
func (ec2Response EC2Response) Respond(msg *nats.Msg) {
response, err := json.Marshal(ec2Response)
if err != nil {
slog.Error("Failed to marshal response", "err", err)
return
}
msg.Respond(response)
}
// LoadConfig loads the configuration from file and environment variables
func LoadConfig(configPath string) (*Config, error) {
// Set environment variable prefix
viper.SetEnvPrefix("HIVE")
viper.AutomaticEnv()
// Set default values
//viper.SetDefault("host", "https://localhost:8443/")
//viper.SetDefault("base_dir", "/tmp/vb/")
//viper.SetDefault("daemon.host", "0.0.0.0:4432")
//viper.SetDefault("nats.host", "0.0.0.0:4222")
//viper.SetDefault("nats.sub.subject", "ec2.>")
// Try to load config file if it exists
if configPath != "" {
// Check if file exists
if _, err := os.Stat(configPath); err == nil {
viper.SetConfigFile(configPath)
viper.SetConfigType("toml")
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("error reading config file: %w", err)
}
fmt.Fprintf(os.Stderr, "Using config file: %s\n", viper.ConfigFileUsed())
} else {
fmt.Fprintf(os.Stderr, "Config file not found: %s, using environment variables and defaults\n", configPath)
}
}
// Create config struct
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// Validate required fields (can be overwritten by env vars)
/*
if config.NATS.Host == "" {
return nil, fmt.Errorf("NATS host is required")
}
if config.AccessKey == "" {
return nil, fmt.Errorf("access key is required")
}
if config.SecretKey == "" {
return nil, fmt.Errorf("secret key is required")
}
*/
// fmt.Println("Config: ", config)
return &config, nil
}
-420
View File
@@ -1,420 +0,0 @@
# Daemon Testing Guide
## Overview
The `daemon_test.go` file provides comprehensive unit tests for the daemon's EC2 launch functionality, focusing on message handling, validation, and resource management.
## Test Structure
### Core Test Components
1. **NATS Server Setup** (`startTestNATSServer`)
- Creates an embedded NATS server for testing
- Uses port `-1` for automatic port allocation
- Returns server instance and connection URL
- Automatically starts and waits for readiness
2. **Daemon Initialization** (`createTestDaemon`)
- Creates a minimal daemon configuration for testing
- Sets up temporary directories for test data
- Establishes NATS connection
- Includes automatic cleanup via `t.Cleanup()`
3. **Test Fixtures** (`createValidRunInstancesInput`)
- Provides valid `ec2.RunInstancesInput` structures
- Pre-configured with sensible defaults
- Easy to modify for specific test scenarios
### Test Suites
#### 1. TestHandleEC2Launch_MessageParsing
Tests the daemon's ability to parse and validate incoming NATS messages:
- ✅ Valid RunInstancesInput processing
- ✅ Invalid instance type handling
- ✅ Missing required fields (ImageId)
- ✅ Invalid parameter values (MinCount = 0)
- ✅ Malformed JSON handling
**Expected Behaviors:**
- Valid inputs should return a Reservation with pending instance
- Invalid inputs should return error payloads (not transport errors)
- All responses should be properly formatted JSON
#### 2. TestHandleEC2Launch_ResourceManagement
Tests resource allocation and instance type validation:
- ✅ Valid instance types (t3.micro, t3.nano)
- ✅ Invalid instance types (t99.invalid)
- ✅ Resource availability checking
- ✅ Allocation/deallocation cycles
#### 3. TestDaemon_Initialization
Tests daemon initialization logic:
- ✅ Configuration loading
- ✅ Resource manager creation
- ✅ Instance storage initialization
#### 4. TestResourceManager
Tests the resource management subsystem:
- ✅ System resource detection
- ✅ Instance type allocation
- ✅ Resource deallocation
- ✅ Capacity validation
## Running Tests
### Basic Test Execution
```bash
# Run all daemon tests
go test -v ./hive/daemon/
# Run specific test
go test -v ./hive/daemon/ -run TestHandleEC2Launch_MessageParsing
# Run with race detection
go test -race -v ./hive/daemon/
# Run with coverage
go test -cover -v ./hive/daemon/
```
### Skipping Integration Tests
The tests are designed to skip integration components when running on macOS or when dependencies are unavailable:
```bash
# Skip integration tests that require viperblock/nbdkit
SKIP_INTEGRATION=1 go test -v ./hive/daemon/
```
## macOS Testing Limitations
### Current Limitations
The full integration test suite has limitations on macOS due to missing dependencies:
1. **Viperblock** - Not yet ported to macOS
- NBD (Network Block Device) support not available
- Volume mounting operations will fail
2. **NBDkit** - Linux-specific tool
- Requires kernel NBD support
- Not available natively on macOS
3. **QEMU/KVM** - Limited on macOS
- KVM acceleration not available (Linux-only)
- Can use QEMU with TCG (slow emulation)
### What Works on macOS
✅ **Message-driven architecture testing:**
- NATS message parsing and routing
- Request/response validation
- Error handling and payload generation
- Resource manager logic
✅ **Unit tests without infrastructure:**
- Input validation (`ValidateRunInstancesInput`)
- JSON marshaling/unmarshaling
- Error code generation
- Instance ID generation
### What Requires Linux
❌ **Full integration testing:**
- Volume creation and mounting (requires viperblock)
- NBD device attachment (requires nbdkit)
- VM launch with KVM acceleration
- EBS volume operations
- Cloud-init ISO creation and mounting
### Recommendations for macOS Development
#### Option 1: Mock Integration Points
Create mock implementations for macOS testing:
```go
// daemon_test_helpers.go
// +build darwin
// Mock viperblock operations for macOS
type MockViperblock struct {
// Mock implementation
}
func (m *MockViperblock) Mount(volumeId string) error {
// Return mock NBD URI
return nil
}
```
#### Option 2: Use Docker Linux Container
Run tests in a Linux container with full dependencies:
```bash
# Build Linux test container
docker build -t hive-test -f Dockerfile.test .
# Run tests in container
docker run --rm \
--privileged \
-v $(pwd):/workspace \
hive-test \
go test -v ./hive/daemon/
```
**Dockerfile.test example:**
```dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
golang-1.21 \
qemu-kvm \
nbdkit \
build-essential
WORKDIR /workspace
```
#### Option 3: GitHub Actions / CI Pipeline
Use CI for full integration tests:
```yaml
# .github/workflows/test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y nbdkit qemu-kvm
- name: Run tests
run: make test
```
#### Option 4: Virtual Linux VM on macOS
Use a Linux VM for development testing:
```bash
# Using UTM or Multipass
multipass launch --name hive-dev --cpus 4 --memory 8G --disk 20G
multipass shell hive-dev
# Inside VM
cd /path/to/hive
make test
```
### Hybrid Testing Strategy
**On macOS (Unit Tests):**
```bash
# Test message handling, validation, resource management
SKIP_INTEGRATION=1 go test -v ./hive/daemon/
```
**On Linux (Full Integration):**
```bash
# Test complete EC2 launch flow with viperblock/nbdkit
go test -v ./hive/daemon/
```
## Test Coverage Goals
### Current Coverage
- ✅ Message parsing and validation
- ✅ Resource allocation logic
- ✅ Error handling and responses
- ✅ NATS integration (pub/sub)
### Additional Tests Needed (Linux)
#### Volume Management Tests
```go
func TestHandleEC2Launch_VolumeCreation(t *testing.T) {
// Test AMI volume cloning
// Test EFI partition creation
// Test cloud-init ISO generation
}
```
#### NBD Mount Tests
```go
func TestMountVolumes(t *testing.T) {
// Test NBD URI generation
// Test mount request/response
// Test mount failures
}
```
#### VM Lifecycle Tests
```go
func TestLaunchInstance(t *testing.T) {
// Test QEMU process startup
// Test QMP socket connection
// Test instance state transitions
}
```
#### End-to-End Tests
```go
func TestEC2Launch_EndToEnd(t *testing.T) {
// Full flow: message -> volumes -> VM -> running
// Test with multiple instance types
// Test concurrent launches
}
```
## Mocking Strategy for macOS
### Mock EBS Mount Service
```go
// Create a mock EBS mount responder
func startMockEBSService(t *testing.T, nc *nats.Conn) {
nc.QueueSubscribe("ebs.mount", "mock-ebs", func(msg *nats.Msg) {
var req config.EBSRequest
json.Unmarshal(msg.Data, &req)
resp := config.EBSMountResponse{
URI: fmt.Sprintf("nbd://localhost:10809/%s", req.Name),
Mounted: true,
Error: "",
}
data, _ := json.Marshal(resp)
msg.Respond(data)
})
}
```
### Mock S3 Client
```go
// Mock S3 client for SSH key retrieval
type MockS3Client struct {
keys map[string]string
}
func (m *MockS3Client) Read(path string) ([]byte, error) {
if key, ok := m.keys[path]; ok {
return []byte(key), nil
}
return nil, fmt.Errorf("key not found")
}
```
### Example Mock Test
```go
func TestHandleEC2Launch_WithMocks(t *testing.T) {
if runtime.GOOS == "darwin" {
// Run with mocks on macOS
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, _ := nats.Connect(natsURL)
defer nc.Close()
// Start mock services
startMockEBSService(t, nc)
startMockS3Service(t, nc)
// Run test...
} else {
// Run with real services on Linux
// ...
}
}
```
## Debugging Tests
### Enable Verbose Logging
```bash
# Run tests with verbose output
go test -v ./hive/daemon/ 2>&1 | tee test.log
```
### Debug NATS Messages
```go
// Add debug subscriber to see all messages
nc.Subscribe(">", func(msg *nats.Msg) {
t.Logf("NATS [%s]: %s", msg.Subject, string(msg.Data))
})
```
### Common Issues
**Issue: "NATS server failed to start"**
- Port already in use
- Use `-1` for auto port allocation
- Check firewall settings
**Issue: "Request timeout"**
- Increase timeout duration
- Check NATS connectivity
- Verify handler subscription
**Issue: "Test fails on Linux but works on macOS"**
- Missing dependencies (nbdkit, QEMU)
- Permission issues (KVM access)
- File system differences
## Next Steps
1. ✅ Create basic message handling tests (DONE)
2. ⏳ Add mock services for macOS testing
3. ⏳ Create Linux CI pipeline for full integration tests
4. ⏳ Add Docker-based test environment
5. ⏳ Implement volume lifecycle tests (Linux only)
6. ⏳ Add concurrent launch stress tests
7. ⏳ Create performance benchmarks
## Contributing
When adding new tests:
1. **Check platform compatibility:**
```go
if runtime.GOOS == "darwin" && needsLinux {
t.Skip("Test requires Linux")
}
```
2. **Use table-driven tests:**
```go
tests := []struct {
name string
input interface{}
want interface{}
}{ /* ... */ }
```
3. **Clean up resources:**
```go
t.Cleanup(func() {
// Cleanup code
})
```
4. **Document dependencies:**
- Add comments explaining Linux-only requirements
- Use build tags where appropriate
- Provide alternative test paths for macOS
-411
View File
@@ -1,411 +0,0 @@
# Daemon Unit Test Summary
## Created Files
1. **`daemon_test.go`** - Comprehensive unit test suite for daemon EC2 launch functionality
2. **`TESTING.md`** - Detailed testing guide with macOS limitations and strategies
3. **`TEST_SUMMARY.md`** (this file) - Summary of test implementation
## Test Framework Components
### 1. NATS Test Server (`startTestNATSServer`)
```go
func startTestNATSServer(t *testing.T) (*server.Server, string)
```
- Creates embedded NATS server for isolated testing
- Uses port `-1` for automatic port allocation (avoids conflicts)
- Returns server instance and connection URL
- Automatically waits for server readiness with 5-second timeout
**Key Features:**
- No external NATS dependency
- Clean isolation between tests
- Automatic cleanup via defer
- Debug logging of assigned port
### 2. Test Daemon Factory (`createTestDaemon`)
```go
func createTestDaemon(t *testing.T, natsURL string) *Daemon
```
- Creates minimal daemon configuration for testing
- Sets up temporary directories with automatic cleanup
- Configures mock Predastore and S3 settings
- Establishes NATS connection
**Cleanup Strategy:**
- Uses `t.Cleanup()` for automatic resource cleanup
- Removes temporary directories
- Closes NATS connections
- No manual cleanup required
### 3. Test Fixtures (`createValidRunInstancesInput`)
```go
func createValidRunInstancesInput() *ec2.RunInstancesInput
```
- Provides fully-formed valid `RunInstancesInput`
- Pre-configured with AWS-compatible values
- Easy to clone and modify for specific tests
- Includes all required fields
## Test Suites
### TestHandleEC2Launch_MessageParsing ✅
**Purpose:** Validates NATS message parsing and error handling
**Test Cases:**
1. Valid RunInstancesInput - expects Reservation with pending instance
2. Invalid instance type - expects error response
3. Missing required ImageId - expects MissingParameter error
4. Invalid MinCount (zero) - expects InvalidParameterValue error
5. Malformed JSON - expects ValidationError
**Status:** ⚠️ Skipped (requires SKIP_INTEGRATION not set)
**Current Issues:**
- Line 638 in daemon.go: `var runInstancesInput *ec2.RunInstancesInput` is nil
- Line 645: `utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)` fails with nil pointer
- The UnmarshalJsonPayload function in utils.go:283 does `decoder.Decode(&input)` which creates a pointer-to-pointer issue
- Even valid inputs are rejected with ValidationError
### TestHandleEC2Launch_ResourceManagement ⏸️
**Purpose:** Tests resource allocation and instance type validation
**Status:** Skipped (requires full infrastructure)
**Reasoning:**
- handleEC2Launch attempts to create viperblock volumes
- Requires S3 backend (predastore) running
- Requires nbdkit for NBD mounting
- Requires QEMU for VM launch
- None of these are available on macOS
**When to Enable:**
- On Linux with full Hive stack running
- In CI/CD pipeline with all dependencies
- With mock services (future enhancement)
### TestDaemon_Initialization ✅
**Purpose:** Tests daemon creation and configuration
**Test Coverage:**
- Daemon struct initialization
- Resource manager creation
- Instance storage initialization
- Configuration loading
**Status:** ✅ PASSING
### TestResourceManager ✅
**Purpose:** Tests resource allocation logic
**Test Coverage:**
- System resource detection (CPU, Memory)
- Instance type definitions
- Resource allocation/deallocation
- Capacity checking
**Status:** ✅ PASSING
## Current Test Results
```bash
$ go test -v ./hive/daemon/
=== RUN TestHandleEC2Launch_MessageParsing
daemon_test.go:115: Skipping integration test - SKIP_INTEGRATION is set
--- SKIP: TestHandleEC2Launch_MessageParsing (0.00s)
=== RUN TestHandleEC2Launch_ResourceManagement
daemon_test.go:261: Skipping resource management test - requires full hive infrastructure
--- SKIP: TestHandleEC2Launch_ResourceManagement (0.00s)
=== RUN TestDaemon_Initialization
--- PASS: TestDaemon_Initialization (0.01s)
=== RUN TestResourceManager
--- PASS: TestResourceManager (0.01s)
PASS
ok github.com/mulgadc/hive/hive/daemon 0.786s
```
## Known Issues & Limitations
### 1. Nil Pointer Bug in handleEC2Launch (daemon.go:638-645)
**Issue:**
```go
var runInstancesInput *ec2.RunInstancesInput // nil pointer
// ...
errResp = utils.UnmarshalJsonPayload(runInstancesInput, msg.Data) // fails
```
**Root Cause:**
- `runInstancesInput` declared as pointer but never initialized
- `UnmarshalJsonPayload` takes `&input` creating pointer-to-pointer
- Results in ValidationError even for valid inputs
**Suggested Fix (not applied per instructions):**
```go
runInstancesInput := &ec2.RunInstancesInput{} // Initialize before use
errResp = utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
```
Or fix the utils function:
```go
func UnmarshalJsonPayload(input interface{}, jsonData []byte) []byte {
decoder := json.NewDecoder(bytes.NewReader(jsonData))
decoder.DisallowUnknownFields()
err := decoder.Decode(input) // Remove & since input is already a pointer
// ...
}
```
### 2. macOS Limitations
**Missing Dependencies:**
- ❌ viperblock (not ported to macOS)
- ❌ nbdkit (Linux-only, requires kernel NBD)
- ❌ KVM acceleration (Linux-only)
- ❌ Predastore running instance
**Impact:**
- Cannot test volume creation/mounting
- Cannot test VM launch
- Cannot test full EC2 launch flow
**Workarounds:**
- Use Docker Linux container for integration tests
- Create mock services for macOS
- Run full tests in CI/CD on Linux
- Use VM (UTM, Multipass) for local Linux testing
### 3. Test Coverage Gaps
**Not Tested:**
- Volume lifecycle (create, mount, unmount)
- NBD URI generation and validation
- Cloud-init ISO creation
- EFI partition creation
- QMP socket communication
- VM state transitions
- Concurrent launch requests
- Resource exhaustion scenarios
**Future Enhancements:**
- Add mock EBS mount service
- Add mock S3 client
- Add mock viperblock backend
- Test error paths more thoroughly
- Add benchmark tests
- Add stress tests
## Running Tests
### On macOS (Unit Tests Only)
```bash
# Run all passing tests
go test -v ./hive/daemon/
# Run with race detection
go test -race -v ./hive/daemon/
# Run with coverage
go test -cover -v ./hive/daemon/
# Run specific test
go test -v ./hive/daemon/ -run TestResourceManager
```
### On Linux (Full Integration)
```bash
# Requires: predastore, viperblock, nbdkit, qemu running
# Run all tests including integration
SKIP_INTEGRATION="" go test -v ./hive/daemon/
# Or simply
go test -v ./hive/daemon/
```
### In Docker (Recommended)
```bash
# Build test container
docker build -t hive-daemon-test -f Dockerfile.test .
# Run tests
docker run --rm --privileged \
-v $(pwd):/workspace \
hive-daemon-test \
go test -v ./hive/daemon/
```
## Test Architecture Highlights
### ✅ Strengths
1. **Isolated NATS Server**
- Each test gets fresh NATS instance
- No interference between tests
- Automatic port allocation prevents conflicts
2. **Clean Resource Management**
- Automatic cleanup via `t.Cleanup()`
- Temporary directories auto-removed
- No test artifacts left behind
3. **Table-Driven Tests**
- Easy to add new test cases
- Clear test structure
- Minimal code duplication
4. **Clear Documentation**
- Comments explain what each test validates
- Reasoning for skipped tests documented
- macOS limitations clearly stated
5. **Framework Foundation**
- Easy to extend with new tests
- Mock services can be added later
- Ready for CI/CD integration
### ⚠️ Weaknesses
1. **Limited Coverage**
- Only tests message parsing and resource logic
- Cannot test actual VM launch on macOS
- Missing integration with viperblock/nbdkit
2. **Existing Code Issues**
- Nil pointer bug blocks message parsing tests
- utils.UnmarshalJsonPayload has pointer-to-pointer issue
- These bugs need fixing before tests can pass
3. **No Mocking Yet**
- Tests require real infrastructure for full validation
- No mock EBS service
- No mock S3 client
## Next Steps
### Immediate (Required for Tests to Pass)
1. **Fix nil pointer in handleEC2Launch**
```go
// daemon.go:638
runInstancesInput := &ec2.RunInstancesInput{} // Initialize
```
2. **Fix UnmarshalJsonPayload**
```go
// utils.go:283
err := decoder.Decode(input) // Remove &
```
### Short Term (Enhance Test Coverage)
1. **Add Mock Services**
- Mock EBS mount service (NATS responder)
- Mock S3 client for SSH keys
- Mock viperblock for volume operations
2. **Add Platform-Specific Tests**
```go
// +build linux
func TestHandleEC2Launch_FullFlow(t *testing.T) { ... }
```
3. **Add Helper Functions**
- `createMockEBSService(t, nc)`
- `createMockS3Client(t, keys map[string]string)`
- `waitForInstanceState(t, daemon, instanceId, expectedState)`
### Long Term (Production Ready)
1. **CI/CD Integration**
- GitHub Actions workflow for Linux tests
- Docker-based test environment
- Coverage reporting
2. **Comprehensive Test Suite**
- End-to-end VM launch tests
- Concurrent launch stress tests
- Failure recovery tests
- Performance benchmarks
3. **Test Documentation**
- Video walkthrough of test setup
- Troubleshooting guide
- Contributing guidelines
## Recommendations
### For macOS Development
**Option 1: Mock Services** (Fastest)
```go
func TestHandleEC2Launch_WithMocks(t *testing.T) {
ns, natsURL := startTestNATSServer(t)
startMockEBSService(t, nc)
startMockS3Service(t, nc)
// Test with mocked dependencies
}
```
**Option 2: Docker Container** (Most Realistic)
```bash
docker-compose up test-env
go test -v ./hive/daemon/
```
**Option 3: Linux VM** (Balanced)
```bash
multipass launch --name hive-dev
multipass shell hive-dev
# Run tests in VM
```
### For Linux CI/CD
```yaml
# .github/workflows/daemon-tests.yml
name: Daemon Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: sudo apt-get install -y nbdkit qemu-kvm
- name: Start services
run: ./scripts/start-test-env.sh
- name: Run tests
run: go test -v ./hive/daemon/
```
## Conclusion
This test framework provides:
✅ **Solid Foundation**
- NATS message handling tests
- Resource manager validation
- Clean test isolation
- Easy to extend
⚠️ **Current Limitations**
- Requires bug fixes in daemon.go and utils.go
- Limited to unit tests on macOS
- Needs mock services for full coverage
🎯 **Future Potential**
- Can support full integration tests on Linux
- Mock services enable macOS testing
- Ready for CI/CD integration
- Foundation for comprehensive test suite
The test framework is **production-ready for unit testing** but requires the identified bug fixes and either mock services (macOS) or full infrastructure (Linux) for integration testing.
File diff suppressed because it is too large Load Diff
-402
View File
@@ -1,402 +0,0 @@
package daemon
import (
"encoding/json"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/config"
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// startTestNATSServer starts an embedded NATS server for testing
// Using port -1 allows NATS to automatically allocate an available port
func startTestNATSServer(t *testing.T) (*server.Server, string) {
opts := &server.Options{
Host: "127.0.0.1",
Port: -1, // Let NATS auto-allocate an available port
JetStream: false,
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err, "Failed to create NATS server")
// Start server in a goroutine
go ns.Start()
// Wait for server to be ready
if !ns.ReadyForConnections(5 * time.Second) {
t.Fatal("NATS server failed to start")
}
// Get the actual URL that was assigned
url := ns.ClientURL()
t.Logf("Test NATS server started at: %s", url)
return ns, url
}
// createTestDaemon creates a test daemon instance with minimal configuration
func createTestDaemon(t *testing.T, natsURL string) *Daemon {
// Create a temporary directory for test data
tmpDir, err := os.MkdirTemp("", "hive-daemon-test-*")
require.NoError(t, err, "Failed to create temp directory")
t.Cleanup(func() {
os.RemoveAll(tmpDir)
})
cfg := &config.Config{
BaseDir: tmpDir,
WalDir: tmpDir,
NATS: config.NATSConfig{
Host: natsURL,
ACL: config.NATSACL{
Token: "",
},
},
Predastore: config.PredastoreConfig{
Host: "http://localhost:9000",
Bucket: "test-bucket",
Region: "us-east-1",
AccessKey: "test-access-key",
SecretKey: "test-secret-key",
BaseDir: tmpDir,
},
AccessKey: "test-access-key",
SecretKey: "test-secret-key",
}
daemon := NewDaemon(cfg)
// Connect to NATS
nc, err := nats.Connect(natsURL)
require.NoError(t, err, "Failed to connect to NATS")
daemon.natsConn = nc
// Initialize instance service (needed for handleEC2RunInstances)
instanceTypes := make(map[string]handlers_ec2_instance.InstanceType)
for k, v := range daemon.resourceMgr.instanceTypes {
instanceTypes[k] = handlers_ec2_instance.InstanceType{
Name: v.Name,
VCPUs: v.VCPUs,
MemoryGB: v.MemoryGB,
Architecture: v.Architecture,
}
}
daemon.instanceService = handlers_ec2_instance.NewInstanceServiceImpl(cfg, instanceTypes, nc, &daemon.Instances)
t.Cleanup(func() {
if daemon.natsConn != nil {
daemon.natsConn.Close()
}
})
return daemon
}
// createValidRunInstancesInput creates a valid RunInstancesInput for testing
func createValidRunInstancesInput() *ec2.RunInstancesInput {
return &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("test-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
UserData: aws.String("#!/bin/bash\necho 'test'"),
}
}
// TestHandleEC2RunInstances_MessageParsing tests that the handler correctly parses NATS messages
func TestHandleEC2RunInstances_MessageParsing(t *testing.T) {
// Skip integration tests on macOS since viperblock/nbdkit are not available
if os.Getenv("SKIP_INTEGRATION") != "" {
t.Skip("Skipping integration test - SKIP_INTEGRATION is set")
}
tests := []struct {
name string
input interface{}
expectError bool
errorInPayload bool
validate func(t *testing.T, reply *nats.Msg)
}{
{
name: "Valid RunInstancesInput",
input: createValidRunInstancesInput(),
expectError: false,
errorInPayload: false,
validate: func(t *testing.T, reply *nats.Msg) {
var reservation ec2.Reservation
err := json.Unmarshal(reply.Data, &reservation)
require.NoError(t, err, "Failed to unmarshal reservation response")
assert.NotNil(t, reservation.ReservationId)
assert.Len(t, reservation.Instances, 1)
if len(reservation.Instances) > 0 {
instance := reservation.Instances[0]
assert.NotNil(t, instance.InstanceId)
assert.True(t, len(*instance.InstanceId) > 0)
// Instance should be in pending state initially
assert.Equal(t, int64(0), *instance.State.Code)
assert.Equal(t, "pending", *instance.State.Name)
}
},
},
{
name: "Invalid Instance Type",
input: &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("invalid.type"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
},
expectError: false, // No transport error
errorInPayload: true, // But payload contains error
validate: func(t *testing.T, reply *nats.Msg) {
// Should receive an error response
assert.NotNil(t, reply.Data)
// The response should contain error information
t.Logf("Error response: %s", string(reply.Data))
},
},
{
name: "Missing Required ImageId",
input: &ec2.RunInstancesInput{
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
},
expectError: false,
errorInPayload: true,
validate: func(t *testing.T, reply *nats.Msg) {
assert.NotNil(t, reply.Data)
t.Logf("Error response: %s", string(reply.Data))
},
},
{
name: "Invalid MinCount (zero)",
input: &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(0),
MaxCount: aws.Int64(1),
},
expectError: false,
errorInPayload: true,
validate: func(t *testing.T, reply *nats.Msg) {
assert.NotNil(t, reply.Data)
t.Logf("Error response: %s", string(reply.Data))
},
},
{
name: "Malformed JSON",
input: []byte(`{"invalid": json}`),
expectError: false,
errorInPayload: true,
validate: func(t *testing.T, reply *nats.Msg) {
assert.NotNil(t, reply.Data)
t.Logf("Error response: %s", string(reply.Data))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Skip "Valid RunInstancesInput" test as it requires full infrastructure
// (Predastore S3 backend, Viperblock, NBDkit) to actually create volumes
if tt.name == "Valid RunInstancesInput" {
t.Skip("Skipping valid input test - requires full hive infrastructure (viperblock, nbdkit, predastore)")
}
// Start test NATS server
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
// Create test daemon
daemon := createTestDaemon(t, natsURL)
// Subscribe to the ec2.launch topic with the handler
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err, "Failed to subscribe to ec2.launch")
defer sub.Unsubscribe()
// Prepare message data
var msgData []byte
switch v := tt.input.(type) {
case []byte:
msgData = v
default:
msgData, err = json.Marshal(tt.input)
require.NoError(t, err, "Failed to marshal input")
}
// Send request to NATS and wait for response
reply, err := daemon.natsConn.Request("ec2.launch", msgData, 5*time.Second)
if tt.expectError {
assert.Error(t, err, "Expected error but got none")
return
}
require.NoError(t, err, "Request failed")
require.NotNil(t, reply, "No reply received")
// Validate response
if tt.validate != nil {
tt.validate(t, reply)
}
})
}
}
// TestHandleEC2RunInstances_ResourceManagement tests resource allocation and validation
// NOTE: This test validates message handling and resource allocation logic without
// actually launching VMs. Full VM launch requires viperblock, nbdkit, and QEMU/KVM
// which are not available on macOS.
func TestHandleEC2RunInstances_ResourceManagement(t *testing.T) {
// Skip this test as it requires full infrastructure
// The test validates NATS message handling, but daemon.handleEC2RunInstances
// attempts to create viperblock volumes which requires:
// - S3 backend (predastore) running
// - viperblock library with S3 backend
// - nbdkit for NBD mounting
// - QEMU for VM launch
t.Skip("Skipping resource management test - requires full hive infrastructure (viperblock, nbdkit, predastore)")
tests := []struct {
name string
instanceType string
expectAllocation bool
description string
}{
{
name: "Valid t3.micro allocation",
instanceType: "t3.micro",
expectAllocation: true,
description: "Should successfully allocate resources for t3.micro",
},
{
name: "Valid t3.nano allocation",
instanceType: "t3.nano",
expectAllocation: true,
description: "Should successfully allocate resources for t3.nano",
},
{
name: "Invalid instance type",
instanceType: "t99.invalid",
expectAllocation: false,
description: "Should fail for invalid instance type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
daemon := createTestDaemon(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String(tt.instanceType),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("test-key"),
SubnetId: aws.String("subnet-test"),
UserData: aws.String(""), // Empty UserData to bypass cloud-init requirements
}
msgData, err := json.Marshal(input)
require.NoError(t, err)
reply, err := daemon.natsConn.Request("ec2.launch", msgData, 5*time.Second)
require.NoError(t, err)
require.NotNil(t, reply)
if tt.expectAllocation {
var reservation ec2.Reservation
err := json.Unmarshal(reply.Data, &reservation)
require.NoError(t, err)
assert.Len(t, reservation.Instances, 1)
} else {
// Should receive error response
t.Logf("Expected error response: %s", string(reply.Data))
}
})
}
}
// TestDaemon_Initialization tests daemon initialization
func TestDaemon_Initialization(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hive-daemon-init-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
BaseDir: tmpDir,
WalDir: tmpDir,
NATS: config.NATSConfig{
Host: "nats://localhost:4222",
},
AccessKey: "test-key",
SecretKey: "test-secret",
}
daemon := NewDaemon(cfg)
assert.NotNil(t, daemon)
assert.NotNil(t, daemon.resourceMgr)
assert.NotNil(t, daemon.Instances)
assert.NotNil(t, daemon.Instances.VMS)
assert.Equal(t, cfg, daemon.config)
}
// TestResourceManager tests resource manager functionality
func TestResourceManager(t *testing.T) {
rm := NewResourceManager()
require.NotNil(t, rm)
assert.Greater(t, rm.availableVCPU, 0)
assert.Greater(t, rm.availableMem, float64(0))
// Test allocation
instanceType := rm.instanceTypes["t3.micro"]
require.NotNil(t, instanceType)
// Check if can allocate
canAlloc := rm.canAllocate(instanceType)
assert.True(t, canAlloc)
// Allocate
err := rm.allocate(instanceType)
assert.NoError(t, err)
// Check resources were allocated
assert.Equal(t, instanceType.VCPUs, rm.allocatedVCPU)
assert.Equal(t, instanceType.MemoryGB, rm.allocatedMem)
// Deallocate
rm.deallocate(instanceType)
assert.Equal(t, 0, rm.allocatedVCPU)
assert.Equal(t, float64(0), rm.allocatedMem)
}
-8
View File
@@ -1,8 +0,0 @@
package gateway
import "github.com/gofiber/fiber/v2"
func (gw *GatewayConfig) Account_Request(ctx *fiber.Ctx) error {
return nil
}
-385
View File
@@ -1,385 +0,0 @@
package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/hive/hive/awsec2query"
gateway_ec2_image "github.com/mulgadc/hive/hive/gateway/ec2/image"
gateway_ec2_instance "github.com/mulgadc/hive/hive/gateway/ec2/instance"
gateway_ec2_key "github.com/mulgadc/hive/hive/gateway/ec2/key"
"github.com/mulgadc/hive/hive/utils"
)
func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
queryArgs := ParseAWSQueryArgs(string(ctx.Body()))
var xmlOutput []byte
var err error
// Run the action
// TODO: Generate for each action, unit test each, and invalid action
switch queryArgs["Action"] {
case "DescribeInstances":
var input = &ec2.DescribeInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.ExpectedNodes)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("DescribeInstancesResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "RunInstances":
var input = &ec2.RunInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("RunInstanceResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "StartInstances":
var input = &ec2.StartInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.StartInstances(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("StartInstancesResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "StopInstances":
var input = &ec2.StopInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.StopInstances(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("StopInstancesResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "TerminateInstances":
var input = &ec2.TerminateInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.TerminateInstances(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("TerminateInstancesResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "CreateKeyPair":
var input = &ec2.CreateKeyPairInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_key.CreateKeyPair(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("CreateKeyPairResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "DeleteKeyPair":
var input = &ec2.DeleteKeyPairInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_key.DeleteKeyPair(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("DeleteKeyPairResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "DescribeKeyPairs":
var input = &ec2.DescribeKeyPairsInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_key.DescribeKeyPairs(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("DescribeKeyPairsResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "ImportKeyPair":
var input = &ec2.ImportKeyPairInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_key.ImportKeyPair(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("ImportKeyPairResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "DescribeImages":
var input = &ec2.DescribeImagesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_image.DescribeImages(input, gw.NATSConn)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("DescribeImagesResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
default:
err = errors.New("InvalidAction")
}
// Return an error XML
if err != nil {
return err
}
ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput)
return nil
}
/*
func (gw *GatewayConfig) EC2_DescribeInstances(ctx *fiber.Ctx, args map[string]string) error {
slog.Info("EC2 DescribeInstances called")
// Return a dummy response
response := `<?xml version="1.0" encoding="UTF-8"?>
<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<requestId>2b7ac2f1-9acd-4d73-b0a1-1b2e5607f9ab</requestId>
<reservationSet>
<item>
<reservationId>r-0f6b3c4d5e6789abc</reservationId>
<ownerId>123456789012</ownerId>
<groupSet/>
<instancesSet>
<item>
<instanceId>i-0123456789abcdef0</instanceId>
<imageId>ami-08d4ac5b634553e16</imageId>
<instanceState>
<code>16</code>
<name>running</name>
</instanceState>
<privateDnsName>ip-10-0-1-25.ap-southeast-2.compute.internal</privateDnsName>
<dnsName>ec2-3-26-14-112.ap-southeast-2.compute.amazonaws.com</dnsName>
<reason/>
<keyName>my-keypair</keyName>
<amiLaunchIndex>0</amiLaunchIndex>
<productCodes/>
<instanceType>t3.medium</instanceType>
<launchTime>2025-02-18T04:12:07.000Z</launchTime>
<placement>
<availabilityZone>ap-southeast-2a</availabilityZone>
<groupName/>
<tenancy>default</tenancy>
</placement>
<monitoring>
<state>disabled</state>
</monitoring>
<privateIpAddress>10.0.1.25</privateIpAddress>
<ipAddress>3.26.14.112</ipAddress>
<subnetId>subnet-0ab1c2d3e4f567890</subnetId>
<vpcId>vpc-0123abcd4567efgh8</vpcId>
<sourceDestCheck>true</sourceDestCheck>
<groupSet>
<item>
<groupId>sg-0abc1234def567890</groupId>
<groupName>default</groupName>
</item>
</groupSet>
<stateReason/>
<architecture>x86_64</architecture>
<rootDeviceType>ebs</rootDeviceType>
<rootDeviceName>/dev/xvda</rootDeviceName>
<blockDeviceMapping>
<item>
<deviceName>/dev/xvda</deviceName>
<ebs>
<volumeId>vol-06f5e4d3c2b1a0f98</volumeId>
<status>attached</status>
<attachTime>2025-02-18T04:12:09.000Z</attachTime>
<deleteOnTermination>true</deleteOnTermination>
</ebs>
</item>
</blockDeviceMapping>
<virtualizationType>hvm</virtualizationType>
<tagSet>
<item>
<key>Name</key>
<value>web-01</value>
</item>
</tagSet>
<hypervisor>xen</hypervisor>
<networkInterfaceSet>
<item>
<networkInterfaceId>eni-0a12b34c56d78ef90</networkInterfaceId>
<subnetId>subnet-0ab1c2d3e4f567890</subnetId>
<vpcId>vpc-0123abcd4567efgh8</vpcId>
<description/>
<ownerId>123456789012</ownerId>
<status>in-use</status>
<macAddress>0a:1b:2c:3d:4e:5f</macAddress>
<privateIpAddress>10.0.1.25</privateIpAddress>
<privateDnsName>ip-10-0-1-25.ap-southeast-2.compute.internal</privateDnsName>
<sourceDestCheck>true</sourceDestCheck>
<groupSet>
<item>
<groupId>sg-0abc1234def567890</groupId>
<groupName>default</groupName>
</item>
</groupSet>
<attachment>
<attachmentId>eni-attach-0abc123def4567890</attachmentId>
<deviceIndex>0</deviceIndex>
<status>attached</status>
<attachTime>2025-02-18T04:12:07.000Z</attachTime>
<deleteOnTermination>true</deleteOnTermination>
</attachment>
<privateIpAddressesSet>
<item>
<privateIpAddress>10.0.1.25</privateIpAddress>
<privateDnsName>ip-10-0-1-25.ap-southeast-2.compute.internal</privateDnsName>
<primary>true</primary>
</item>
</privateIpAddressesSet>
</item>
</networkInterfaceSet>
</item>
</instancesSet>
</item>
</reservationSet>
</DescribeInstancesResponse>
`
return ctx.SendString(response)
}
*/
-143
View File
@@ -1,143 +0,0 @@
package gateway_ec2_image
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/handlers/ec2/image"
"github.com/mulgadc/hive/hive/utils"
)
/*
Sample JSON:
"CopyImage":{
"name":"CopyImage",
"http":{
"method":"POST",
"requestUri":"/"
},
"input":{"shape":"CopyImageRequest"},
"output":{"shape":"CopyImageResult"}
},
"CopyImageRequest":{
"type":"structure",
"required":[
"Name",
"SourceImageId",
"SourceRegion"
],
"members":{
"ClientToken":{"shape":"String"},
"Description":{"shape":"String"},
"Encrypted":{
"shape":"Boolean",
"locationName":"encrypted"
},
"KmsKeyId":{
"shape":"KmsKeyId",
"locationName":"kmsKeyId"
},
"Name":{"shape":"String"},
"SourceImageId":{"shape":"String"},
"SourceRegion":{"shape":"String"},
"DestinationOutpostArn":{"shape":"String"},
"DryRun":{
"shape":"Boolean",
"locationName":"dryRun"
},
"CopyImageTags":{"shape":"Boolean"},
"TagSpecifications":{
"shape":"TagSpecificationList",
"locationName":"TagSpecification"
}
}
},
"CopyImageResult":{
"type":"structure",
"members":{
"ImageId":{
"shape":"ImageId",
"locationName":"imageId"
}
}
}
*/
func ValidateCopyImageInput(input *ec2.CopyImageInput) (err error) {
// Check required args from JSON above
// required:[
// "Name",
// "SourceImageId",
// "SourceRegion"
// ]
if input == nil {
return errors.New("MissingParameter")
}
if input.Name == nil || *input.Name == "" {
return errors.New("MissingParameter")
}
if input.SourceImageId == nil || *input.SourceImageId == "" {
return errors.New("MissingParameter")
}
if input.SourceRegion == nil || *input.SourceRegion == "" {
return errors.New("MissingParameter")
}
// Validate SourceImageId format
if !strings.HasPrefix(*input.SourceImageId, "ami-") {
return errors.New("InvalidAMIID.Malformed")
}
return
}
func CopyImage(input *ec2.CopyImageInput) (output ec2.CopyImageOutput, err error) {
// Validate input
err = ValidateCopyImageInput(input)
if err != nil {
return output, err
}
// Marshal to JSON, to send over the wire for processing (NATS)
jsonData, err := json.Marshal(input)
if err != nil {
return output, fmt.Errorf("failed to marshal input to JSON: %w", err)
}
// Run the simulated JSON request via handler, which will return a JSON response
handler := handlers_ec2_image.NewCopyImageHandler(handlers_ec2_image.NewMockImageService())
jsonResp := handler.Process(jsonData)
// Validate if the response is successful or an error
responseError, err := utils.ValidateErrorPayload(jsonResp)
if err != nil {
if responseError.Code != nil {
slog.Error("CopyImage failed", "err", responseError.Code)
return output, errors.New(*responseError.Code)
}
return output, err
}
// Unmarshal the JSON response back into output struct
err = json.Unmarshal(jsonResp, &output)
if err != nil {
return output, fmt.Errorf("failed to unmarshal JSON response to CopyImageOutput: %w", err)
}
return output, nil
}
-78
View File
@@ -1,78 +0,0 @@
package gateway_ec2_image
import (
"errors"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/stretchr/testify/assert"
)
func TestValidateCopyImageInput(t *testing.T) {
tests := []struct {
name string
input *ec2.CopyImageInput
want error
}{
{
name: "NilInput",
input: nil,
want: errors.New("MissingParameter"),
},
{
name: "MissingName",
input: &ec2.CopyImageInput{
SourceImageId: aws.String("ami-0123456789abcdef0"),
SourceRegion: aws.String("us-east-1"),
},
want: errors.New("MissingParameter"),
},
{
name: "MissingSourceImageId",
input: &ec2.CopyImageInput{
Name: aws.String("test-copy"),
SourceRegion: aws.String("us-east-1"),
},
want: errors.New("MissingParameter"),
},
{
name: "MissingSourceRegion",
input: &ec2.CopyImageInput{
Name: aws.String("test-copy"),
SourceImageId: aws.String("ami-0123456789abcdef0"),
},
want: errors.New("MissingParameter"),
},
{
name: "InvalidSourceImageId",
input: &ec2.CopyImageInput{
Name: aws.String("test-copy"),
SourceImageId: aws.String("invalid-id"),
SourceRegion: aws.String("us-east-1"),
},
want: errors.New("InvalidAMIID.Malformed"),
},
{
name: "ValidInput",
input: &ec2.CopyImageInput{
Name: aws.String("test-copy"),
SourceImageId: aws.String("ami-0123456789abcdef0"),
SourceRegion: aws.String("us-east-1"),
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := CopyImage(tt.input)
assert.Equal(t, tt.want, err)
if err == nil {
assert.NotEmpty(t, result.ImageId)
assert.True(t, strings.HasPrefix(*result.ImageId, "ami-"))
}
})
}
}
-142
View File
@@ -1,142 +0,0 @@
package gateway_ec2_image
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/handlers/ec2/image"
"github.com/mulgadc/hive/hive/utils"
)
/*
Sample JSON:
"CreateImage":{
"name":"CreateImage",
"http":{
"method":"POST",
"requestUri":"/"
},
"input":{"shape":"CreateImageRequest"},
"output":{"shape":"CreateImageResult"}
},
"CreateImageRequest":{
"type":"structure",
"required":[
"InstanceId",
"Name"
],
"members":{
"BlockDeviceMappings":{
"shape":"BlockDeviceMappingRequestList",
"locationName":"blockDeviceMapping"
},
"Description":{
"shape":"String",
"locationName":"description"
},
"DryRun":{
"shape":"Boolean",
"locationName":"dryRun"
},
"InstanceId":{
"shape":"InstanceId",
"locationName":"instanceId"
},
"Name":{
"shape":"String",
"locationName":"name"
},
"NoReboot":{
"shape":"Boolean",
"locationName":"noReboot"
},
"TagSpecifications":{
"shape":"TagSpecificationList",
"locationName":"TagSpecification"
}
}
},
"CreateImageResult":{
"type":"structure",
"members":{
"ImageId":{
"shape":"ImageId",
"locationName":"imageId"
}
}
}
*/
func ValidateCreateImageInput(input *ec2.CreateImageInput) (err error) {
// Check required args from JSON above
// required:[
// "InstanceId",
// "Name"
// ]
if input == nil {
return errors.New("MissingParameter")
}
if input.InstanceId == nil || *input.InstanceId == "" {
return errors.New("MissingParameter")
}
if input.Name == nil || *input.Name == "" {
return errors.New("MissingParameter")
}
// Validate InstanceId format
if !strings.HasPrefix(*input.InstanceId, "i-") {
return errors.New("InvalidInstanceID.Malformed")
}
return
}
func CreateImage(input *ec2.CreateImageInput) (output ec2.CreateImageOutput, err error) {
// Validate input
err = ValidateCreateImageInput(input)
if err != nil {
return output, err
}
// Marshal to JSON, to send over the wire for processing (NATS)
jsonData, err := json.Marshal(input)
if err != nil {
return output, fmt.Errorf("failed to marshal input to JSON: %w", err)
}
// Run the simulated JSON request via handler, which will return a JSON response
handler := handlers_ec2_image.NewCreateImageHandler(handlers_ec2_image.NewMockImageService())
jsonResp := handler.Process(jsonData)
// Validate if the response is successful or an error
responseError, err := utils.ValidateErrorPayload(jsonResp)
if err != nil {
if responseError.Code != nil {
slog.Error("CreateImage failed", "err", responseError.Code)
return output, errors.New(*responseError.Code)
}
return output, err
}
// Unmarshal the JSON response back into output struct
err = json.Unmarshal(jsonResp, &output)
if err != nil {
return output, fmt.Errorf("failed to unmarshal JSON response to CreateImageOutput: %w", err)
}
return output, nil
}
-141
View File
@@ -1,141 +0,0 @@
package gateway_ec2_image
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/handlers/ec2/image"
"github.com/mulgadc/hive/hive/utils"
"github.com/stretchr/testify/assert"
)
func TestValidateCreateImageInput(t *testing.T) {
tests := []struct {
name string
input *ec2.CreateImageInput
want error
}{
{
name: "NilInput",
input: nil,
want: errors.New("MissingParameter"),
},
{
name: "MissingInstanceId",
input: &ec2.CreateImageInput{
Name: aws.String("test-image"),
},
want: errors.New("MissingParameter"),
},
{
name: "MissingName",
input: &ec2.CreateImageInput{
InstanceId: aws.String("i-0123456789abcdef0"),
},
want: errors.New("MissingParameter"),
},
{
name: "InvalidInstanceId",
input: &ec2.CreateImageInput{
InstanceId: aws.String("invalid-id"),
Name: aws.String("test-image"),
},
want: errors.New("InvalidInstanceID.Malformed"),
},
{
name: "ValidInput",
input: &ec2.CreateImageInput{
InstanceId: aws.String("i-0123456789abcdef0"),
Name: aws.String("test-image"),
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := CreateImage(tt.input)
assert.Equal(t, tt.want, err)
if err != nil {
assert.Empty(t, result.ImageId)
} else {
assert.NotEmpty(t, result.ImageId)
assert.True(t, strings.HasPrefix(*result.ImageId, "ami-"))
}
})
}
}
func TestEC2ProcessCreateImage(t *testing.T) {
tests := []struct {
name string
payload interface{}
rawJSON []byte
wantValidationErr bool
wantErrCode string
assertFn func(t *testing.T, output ec2.CreateImageOutput)
}{
{
name: "InvalidPayload",
payload: &ec2.DescribeImagesInput{},
wantValidationErr: true,
wantErrCode: "ValidationError",
},
{
name: "MissingRequiredFields",
payload: &ec2.CreateImageInput{
Name: aws.String("test-image"),
},
wantValidationErr: true,
wantErrCode: "MissingParameter",
},
{
name: "ValidCreateImage",
payload: &ec2.CreateImageInput{
InstanceId: aws.String("i-0123456789abcdef0"),
Name: aws.String("test-image"),
},
assertFn: func(t *testing.T, output ec2.CreateImageOutput) {
assert.NotNil(t, output.ImageId)
assert.True(t, strings.HasPrefix(*output.ImageId, "ami-"))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var jsonData []byte
var err error
if tt.rawJSON != nil {
jsonData = tt.rawJSON
} else {
jsonData, err = json.Marshal(tt.payload)
assert.NoError(t, err)
}
handler := handlers_ec2_image.NewCreateImageHandler(handlers_ec2_image.NewMockImageService())
jsonResp := handler.Process(jsonData)
responseError, err := utils.ValidateErrorPayload(jsonResp)
if tt.wantValidationErr {
assert.Error(t, err)
assert.Equal(t, tt.wantErrCode, aws.StringValue(responseError.Code))
return
}
assert.NoError(t, err)
var output ec2.CreateImageOutput
assert.NoError(t, json.Unmarshal(jsonResp, &output))
if tt.assertFn != nil {
tt.assertFn(t, output)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More