Compare commits

...

150 Commits

Author SHA1 Message Date
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
686 changed files with 69908 additions and 21273 deletions
+184 -48
View File
@@ -1,80 +1,216 @@
name: Hive E2E Test
name: Spinifex E2E Tests
on:
push:
branches:
- main
- e2e
- dev
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- tests/e2e/**
- scripts/**
- .github/workflows/e2e.yml
workflow_dispatch:
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
build_e2e_image:
name: Build E2E Image
runs-on: self-hosted
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: Checkout Code
uses: actions/checkout@v6
- name: Clean workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
path: hive
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- name: Clone Dependencies
run: ./hive/scripts/clone-deps.sh
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Build E2E Image
- 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: |
docker build \
-t hive-e2e:latest \
-f hive/tests/e2e/Dockerfile.e2e \
.
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
hive_e2e_test:
name: Hive Single-Node E2E Test
runs-on: self-hosted
needs: build_e2e_image
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env3 >> "$TOFU_LOG" 2>&1
steps:
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
run: |
docker run --privileged --rm \
-v /dev/kvm:/dev/kvm \
--name hive-e2e-test \
hive-e2e:latest
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'
hive_multinode_e2e_test:
name: Hive Multi-Node E2E Test
runs-on: self-hosted
needs: build_e2e_image
- 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: 25
env:
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
steps:
- name: Run Multi-Node E2E Tests
- 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: |
docker run --privileged --rm \
-v /dev/kvm:/dev/kvm \
--cap-add=NET_ADMIN \
--name hive-multinode-e2e-test \
hive-e2e:latest \
./tests/e2e/run-multinode-e2e.sh
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
cleanup:
name: Cleanup
runs-on: self-hosted
needs: [hive_e2e_test, hive_multinode_e2e_test]
if: always()
- 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 && ./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: Remove Stale Containers
run: |
docker rm -f hive-e2e-test 2>/dev/null || true
docker rm -f hive-multinode-e2e-test 2>/dev/null || true
- name: Clean workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- name: Prune Docker Images
run: docker image prune -f
- 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" \
./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}"
- 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
-103
View File
@@ -1,103 +0,0 @@
name: Hive Build and Test
on:
push:
branches:
- main
- dev
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- .github/workflows/hive.yml
pull_request:
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- .github/workflows/hive.yml
workflow_dispatch:
jobs:
build_and_test:
name: Hive Build and Test
runs-on: ubuntu-24.04
steps:
- name: Checkout Hive
uses: actions/checkout@v6
with:
path: hive
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go Workspace
run: go work init ./hive ./viperblock ./predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "hive/go.sum"
go-version-file: "hive/go.mod"
- name: Build Hive
run: make -C hive build
- name: Run Tests
run: make -C hive test
lint_and_security:
name: Hive Lint and Security
runs-on: ubuntu-24.04
steps:
- name: Checkout Hive
uses: actions/checkout@v6
with:
path: hive
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go Workspace
run: go work init ./hive ./viperblock ./predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "hive/go.sum"
go-version-file: "hive/go.mod"
- name: Check Format
run: make -C hive check-format
- name: Go Vet
run: make -C hive vet
- name: Security Checks
run: make -C hive security-check
+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@v4
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@v4
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@v4
with:
name: distro-amd64
path: artifacts
- name: Download arm64 Artifacts
uses: actions/download-artifact@v4
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@v4
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@v4
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
+133
View File
@@ -0,0 +1,133 @@
name: Spinifex Build and Test
on:
push:
branches:
- main
- dev
paths:
- "**/*.go"
- go.mod
- go.sum
- Makefile
- .github/workflows/spinifex.yml
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: Check Format
run: make -C spinifex check-format
- name: Check Modernize
run: make -C spinifex check-modernize
- name: Go Vet
run: make -C spinifex vet
- name: Security Checks
run: make -C spinifex security-check
+12 -13
View File
@@ -4,13 +4,9 @@ on:
push:
branches:
- main
- ui
- dev
paths:
- hive/services/hiveui/frontend/**
- .github/workflows/ui.yml
pull_request:
paths:
- hive/services/hiveui/frontend/**
- spinifex/services/spinifexui/frontend/**
- .github/workflows/ui.yml
workflow_dispatch:
@@ -20,31 +16,34 @@ jobs:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: hive/services/hiveui/frontend
working-directory: spinifex/services/spinifexui/frontend
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
sparse-checkout: hive/services/hiveui/frontend
sparse-checkout: spinifex/services/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
package_json_file: hive/services/hiveui/frontend/package.json
package_json_file: spinifex/services/spinifexui/frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: hive/services/hiveui/frontend/pnpm-lock.yaml
node-version-file: hive/services/hiveui/frontend/.nvmrc
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 Biome Check
- name: Run Check
run: pnpm check
- name: Run Tests with Coverage
run: pnpm test:coverage
- name: Build Frontend
run: pnpm build
+1 -1
View File
@@ -13,6 +13,7 @@ go.work.sum
# Build outputs
bin/
dist/
tmp/
*.test
*.out
@@ -34,7 +35,6 @@ Thumbs.db
docs/INTERNAL.md
docs/PRIVATE.md
docs/DEVNOTES.md
docs/HIVE_DEVELOPMENT_PLAN.md
docs/PLAN.md
docs/TODO.md
docs/development/
+2 -926
View File
@@ -1,927 +1,3 @@
# Hive Installation
# Spinifex Installation
Notes for development environment installation.
## Dependencies
The currently supported operating systems that are validated and tested include:
- Ubuntu 22.04
- Ubuntu 24.04
- Ubuntu 25.10
- Debian 12.13
For the development preview, please use one of the supported versions above.
## Download
Create the base directory for the Hive development environment.
```bash
mkdir -p ~/Development/mulga/
cd ~/Development/mulga/
git clone https://github.com/mulgadc/hive.git
```
### Quick Install
To bootstrap the dependencies of Hive in one simple step (QEMU, Go, AWS CLI):
```bash
sudo make -C hive quickinstall
```
Ensure Go is available in your `PATH` if not previously installed:
```bash
export PATH=$PATH:/usr/local/go/bin/
```
### Manual Install
Alternatively, run the following steps to manually set up the required dependencies.
```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 make gcc unzip xz-utils file
```
Ensure the Go toolkit is installed for version 1.25.7 or higher. Recommended to install the latest directly from [https://go.dev/dl/](https://go.dev/dl/).
Confirm Go is correctly installed, and set in your $PATH.
```bash
go version
```
Hive provides AWS API/SDK layer functionality, which requires the AWS CLI tool to be installed to interface with the system.
```bash
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
```
Confirm awscli version > 2.0 is installed.
[https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)
```bash
aws --version
```
## Build
Setup the dev environment and package dependencies on [viperblock](https://github.com/mulgadc/viperblock/) and [predastore](https://github.com/mulgadc/predastore/).
```bash
cd hive
./scripts/clone-deps.sh # Clone viperblock + predastore repositories
./scripts/dev-setup.sh # Setup complete development environment
```
Once complete, confirm `./bin/hive` exists and is executable.
# Single Node Installation
For rapid development and testing, `hive` can be installed locally as a single node instance. Follow the instructions below for a complete working environment.
## Init
When running Hive for the first time, run the init function to create the default directories for data, config files and layout required.
Example single node installation to get started, this will create a new region `ap-southeast-2` and availability zone `ap-southeast-2a` on your local instance:
```bash
./bin/hive admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
```
## Trust CA Certificate (Required)
Hive generates a local Certificate Authority (CA) during initialization. For AWS CLI and other tools to trust Hive services over HTTPS, you must add the CA certificate to your system's trust store.
```bash
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
sudo update-ca-certificates
```
Verify the certificate was added:
```bash
ls -la /usr/local/share/ca-certificates/hive-ca.crt
```
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 for development.
```bash
./scripts/start-dev.sh
```
## Create SSH Key
For first install, 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
```
If no key exists, generate one using `ssh-keygen` and repeat the command above.
```bash
ssh-keygen -t rsa
```
### Create New Key
Alternatively, create a new key pair using the AWS CLI tool 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 for the key for SSH to 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
```
### Verify New Key
```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
The Hive CLI tool offers 2 ways of importing an image. You can use automatic image importing, and select from our range of images. Alternatively, provide your own image source file (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 (after validating the image contains UEFI/BIOS boot capability).
**Note:** When downloading OS images, use platforms that support the `cloud-init` feature to automatically bootstrap when using the Hive EC2 functionality to access SSH and networking services.
### Automatic Image 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 for 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. Choose the appropriate `x86_64` or `arm64` architecture depending on your platform.
```bash
./bin/hive admin images import --name debian-12-arm64
```
### Manual Image 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
```
### Verify Image Import
Export the AMI.
```bash
export HIVE_AMI="ami-XXX"
```
Next, verify available disk images to confirm the import was successful.
```bash
aws ec2 describe-images --image-ids $HIVE_AMI
```
## Run Instance
Once Hive is successfully installed and bootstrapped with a system AMI and SSH keys, proceed to run an instance.
### Query Instance Types
Depending on the host platform Hive is installed, different compute instances will be available.
Query available instance types and choose an instance type available on your host:
```bash
aws ec2 describe-instance-types
```
```json
{
"InstanceTypes": [
{
"InstanceType": "t3.small",
"CurrentGeneration": true,
"SupportedRootDeviceTypes": [
"ebs"
],
"SupportedVirtualizationTypes": [
"hvm"
],
"Hypervisor": "kvm",
"ProcessorInfo": {
"SupportedArchitectures": [
"x86_64"
]
},
"VCpuInfo": {
"DefaultVCpus": 2
},
"MemoryInfo": {
"SizeInMiB": 2048
},
"BurstablePerformanceSupported": false
},
]
}
```
Export the instance-type:
```sh
export HIVE_INSTANCE="t3.small"
```
### Run Instance
Next, 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 $HIVE_INSTANCE \
--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.
```bash
export INSTANCE_ID="i-XXX"
```
### Verify Instance
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 forwarding 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 permanently 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 on 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.
## UI Management Panel
The Hive Platform also offers a web interface for managing hive services. Simply have the hive server running and go to [https://localhost:3000](https://localhost:3000) in your browser to continue.
**Note:** If you are not viewing the website on the same machine that is running the hive server, you will need to go to [https://localhost:9999](https://localhost:9999) and [https://localhost:8443](https://localhost:8443) and accept the certificates. If you have added the CA to your local machine this is not needed.
# Multi-Node Configuration
Hive is designed for distributed, multi-server deployments. A Hive cluster operates as a fully distributed infrastructure region — similar to an AWS Region — where multiple nodes work together to provide high availability, data durability, and fault tolerance across the platform.
When deploying a multi-node cluster, you define a **region** (e.g., `ap-southeast-2`, `us-east-1`) and **availability zones** to organize your infrastructure. Services are distributed across nodes: NATS provides a clustered message bus for request routing and JetStream replication, Predastore uses Raft consensus with Reed-Solomon erasure coding (RS 2+1) for durable S3-compatible object storage, and the Hive daemon on each node can independently serve AWS API requests. The result is a platform where compute, storage, and API services are replicated — an AMI stored on one node is available from any node, an EC2 instance launched via any gateway is visible cluster-wide, and the loss of a single node does not compromise data or availability.
Cluster formation is automatic. When initializing with `--nodes 3`, the init node starts a formation server and waits for other nodes to join. Once all nodes have registered, each node receives the full cluster topology — credentials, CA certificates, NATS routes, Predastore peer lists — and generates its own configuration files. No manual configuration synchronization is required.
## Choose a Deployment Mode
| | Simulated (Option A) | Real Multi-Server (Option B) |
|---|---|---|
| **Servers** | 1 machine, loopback IPs | 3+ physical/virtual servers |
| **Use case** | Development & testing | Production & staging |
| **Network** | `127.0.0.1/2/3` (no setup) | Real LAN IPs |
| **Data dirs** | `~/node1/`, `~/node2/`, `~/node3/` | `~/hive/` on each server |
Both options use `HIVE_NODE1`, `HIVE_NODE2`, and `HIVE_NODE3` environment variables. Set them in the option you choose, then all subsequent sections (verification, usage, shutdown) reference them.
### Option A: Simulated Multi-Node (Single Server)
For development and testing, a 3-node cluster can be simulated on a single machine using loopback addresses. Linux handles the full `127.0.0.0/8` range natively, so `127.0.0.1`, `127.0.0.2`, and `127.0.0.3` all work without any network configuration. Each node uses a separate data directory to isolate configuration, logs, and state.
#### 1. Build and Set Variables
```bash
make build
export HIVE_NODE1=127.0.0.1
export HIVE_NODE2=127.0.0.2
export HIVE_NODE3=127.0.0.3
```
#### 2. Form the Cluster
The formation process requires running init and join commands concurrently. The init node blocks until all expected nodes have joined.
**Terminal 1 — Initialize the leader node:**
```bash
./bin/hive admin init \
--node node1 \
--nodes 3 \
--bind $HIVE_NODE1 \
--cluster-bind $HIVE_NODE1 \
--port 4432 \
--hive-dir ~/node1/ \
--config-dir ~/node1/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a
```
This starts a formation server on `$HIVE_NODE1:4432` and waits for 2 more nodes to join.
**Terminal 2 — Join node 2 (while init is running):**
```bash
./bin/hive admin join \
--node node2 \
--bind $HIVE_NODE2 \
--cluster-bind $HIVE_NODE2 \
--host $HIVE_NODE1:4432 \
--data-dir ~/node2/ \
--config-dir ~/node2/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a
```
**Terminal 3 — Join node 3 (while init is running):**
```bash
./bin/hive admin join \
--node node3 \
--bind $HIVE_NODE3 \
--cluster-bind $HIVE_NODE3 \
--host $HIVE_NODE1:4432 \
--data-dir ~/node3/ \
--config-dir ~/node3/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a
```
All three processes will exit with a cluster summary once formation is complete.
#### 3. Start Services
Start services on each node (in separate terminals or background):
```bash
HIVE_SKIP_BUILD=true UI=false ./scripts/start-dev.sh ~/node1/
HIVE_SKIP_BUILD=true UI=false ./scripts/start-dev.sh ~/node2/
HIVE_SKIP_BUILD=true UI=false ./scripts/start-dev.sh ~/node3/
```
### Option B: Real Multi-Node (Physical Servers)
For production or production-like deployments across multiple physical servers or VMs.
#### Prerequisites
On **each server**:
1. Install Hive dependencies (see [Dependencies](#dependencies) above)
2. Clone the repository and build:
```bash
mkdir -p ~/Development/mulga/
cd ~/Development/mulga/
git clone https://github.com/mulgadc/hive.git
cd hive
./scripts/clone-deps.sh
make build
```
3. Identify the bind IP for each server:
```bash
ip addr show | grep "inet "
```
#### 1. Set Variables
Export the IPs for your three servers (replace with your actual IPs) on each server:
```bash
export HIVE_NODE1=192.168.1.10
export HIVE_NODE2=192.168.1.11
export HIVE_NODE3=192.168.1.12
```
#### 2. Form the Cluster
**Server 1 — Initialize the cluster:**
```bash
cd ~/Development/mulga/hive
./bin/hive admin init \
--node node1 \
--nodes 3 \
--bind $HIVE_NODE1 \
--cluster-bind $HIVE_NODE1 \
--port 4432 \
--hive-dir ~/hive/ \
--config-dir ~/hive/config/ \
--region us-east-1 \
--az us-east-1a
```
**Server 2 — Join the cluster (while init is running):**
```bash
cd ~/Development/mulga/hive
./bin/hive admin join \
--node node2 \
--bind $HIVE_NODE2 \
--cluster-bind $HIVE_NODE2 \
--host $HIVE_NODE1:4432 \
--data-dir ~/hive/ \
--config-dir ~/hive/config/ \
--region us-east-1 \
--az us-east-1a
```
**Server 3 — Join the cluster (while init is running):**
```bash
cd ~/Development/mulga/hive
./bin/hive admin join \
--node node3 \
--bind $HIVE_NODE3 \
--cluster-bind $HIVE_NODE3 \
--host $HIVE_NODE1:4432 \
--data-dir ~/hive/ \
--config-dir ~/hive/config/ \
--region us-east-1 \
--az us-east-1a
```
All three processes will exit with a cluster summary once formation is complete.
#### 3. Start Services
After formation completes, start services on **all servers**:
```bash
./scripts/start-dev.sh
```
## Verify the Cluster
From any node, view cluster status and resource capacity:
```bash
./bin/hive get nodes
# NAME STATUS IP REGION AZ UPTIME VMs SERVICES
# node1 Ready $HIVE_NODE1 ... ... 2m 0 nats,predastore,viperblock,daemon,...
# node2 Ready $HIVE_NODE2 ... ... 2m 0 nats,predastore,viperblock,daemon
# node3 Ready $HIVE_NODE3 ... ... 2m 0 nats,predastore,viperblock,daemon
./bin/hive top nodes
# NAME CPU (used/total) MEM (used/total) VMs
# node1 0/16 0.0Gi/30.6Gi 0
# node2 0/16 0.0Gi/30.6Gi 0
# node3 0/16 0.0Gi/30.6Gi 0
```
All nodes should show `Ready` status. Use `hive get vms` to see running instances across the cluster.
Check individual daemon health endpoints:
```bash
curl -s http://$HIVE_NODE1:4432/health
curl -s http://$HIVE_NODE2:4432/health
curl -s http://$HIVE_NODE3:4432/health
```
Check NATS cluster routing (from any node):
```bash
grep -i "route\|cluster" ~/node1/logs/nats.log # simulated
grep -i "route\|cluster" ~/hive/logs/nats.log # real multi-server
```
Check Predastore Raft consensus:
```bash
grep -i "leader\|election" ~/node1/logs/predastore.log # simulated
grep -i "leader\|election" ~/hive/logs/predastore.log # real multi-server
```
## Trust CA Certificate
The CA certificate is generated by the init node and distributed to all joining nodes during formation. On **each node** (or the single machine for simulated mode), trust the CA:
```bash
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
sudo update-ca-certificates
```
For simulated mode, the config directory is under each node's data directory — copy from any node since the CA is the same:
```bash
sudo cp ~/node1/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
sudo update-ca-certificates
```
Set the AWS profile:
```bash
export AWS_PROFILE=hive
```
## Using the Cluster
Connect to any node's AWS Gateway:
```bash
AWS_ENDPOINT_URL=https://$HIVE_NODE1:9999 aws ec2 describe-instances
AWS_ENDPOINT_URL=https://$HIVE_NODE2:9999 aws ec2 describe-instances
```
Check logs on each node for debugging. Log locations depend on your deployment mode — `~/node{1,2,3}/logs/` for simulated, `~/hive/logs/` for real multi-server.
## Advanced Configuration
### Service Co-location
Every node that runs EC2 instances (compute) **must** also run the viperblock service. EBS volumes are mounted locally via Unix domain sockets (NBD) — the daemon publishes to a node-specific NATS topic (`ebs.{nodeName}.mount`) which is handled by the viperblock instance on the same server. Nodes without viperblock cannot run EC2 instances.
In the default deployment, every node runs all services (NATS, Predastore, Viperblock, Daemon, Gateway), which satisfies this requirement.
### Dual NIC Configuration
If servers have separate management and cluster network interfaces, use `--cluster-bind` to specify the cluster network IP:
```bash
# Server 1: management=192.168.1.10, cluster=10.0.0.10
./bin/hive admin init \
--node node1 \
--nodes 3 \
--bind 192.168.1.10 \
--cluster-bind 10.0.0.10 \
--port 4432 \
--hive-dir ~/hive/ \
--config-dir ~/hive/config/ \
--region us-east-1 \
--az us-east-1a
```
The `--bind` IP is used for the formation server, daemon, and AWS gateway. The `--cluster-bind` IP is used for NATS cluster routing and Predastore Raft consensus. If `--cluster-bind` is not specified, it defaults to the `--bind` IP.
### IP Aliases (Simulated Mode)
For a more production-like simulation using dedicated IP addresses on a network interface:
```bash
sudo ip addr add 10.11.12.1/24 dev eth0
sudo ip addr add 10.11.12.2/24 dev eth0
sudo ip addr add 10.11.12.3/24 dev eth0
```
Replace `eth0` with your network interface name (e.g., `enp0s3`, `ens33`). Then export these as your `HIVE_NODE` variables instead of the loopback addresses.
To remove the aliases:
```bash
sudo ip addr del 10.11.12.1/24 dev eth0
sudo ip addr del 10.11.12.2/24 dev eth0
sudo ip addr del 10.11.12.3/24 dev eth0
```
## Shutdown
When running in the multi-server cluster mode a graceful shutdown is required to synchronize and coordinate nodes.
On any node, issue the following command to shutdown nodes cluster wide:
```sh
./bin/hive admin cluster shutdown
```
Example output:
```bash
Starting coordinated cluster shutdown (3 nodes)
Phases: gate -> drain -> storage -> persist -> infra
Timeout per phase: 2m0s
[GATE] Sending to 3 node(s)...
[GATE] node1: stopped awsgw, hive-ui
[GATE] node3: stopped awsgw, hive-ui
[GATE] node2: stopped awsgw, hive-ui
[GATE] Complete (3/3 nodes, 2.005s)
[DRAIN] Sending to 3 node(s)...
[DRAIN] node1: 0/0 VMs remaining
[DRAIN] node1: 0/0 VMs remaining
[DRAIN] node2: 0/0 VMs remaining
[DRAIN] node2: 0/0 VMs remaining
[DRAIN] node3: 0/0 VMs remaining
[DRAIN] node3: 0/0 VMs remaining
[DRAIN] node1: OK
[DRAIN] node3: OK
[DRAIN] node2: OK
[DRAIN] Complete (3/3 nodes, 5ms)
[STORAGE] Sending to 3 node(s)...
[STORAGE] node1: stopped viperblock
[STORAGE] node3: stopped viperblock
[STORAGE] node2: stopped viperblock
[STORAGE] Complete (3/3 nodes, 1.09s)
[PERSIST] Sending to 3 node(s)...
[PERSIST] node1: stopped predastore
[PERSIST] node3: stopped predastore
[PERSIST] node2: stopped predastore
[PERSIST] Complete (3/3 nodes, 1.003s)
[INFRA] Sending final shutdown to all nodes...
[INFRA] Complete
Cluster shutdown complete (6.104s)
```
If EC2 instances are running, the stop process will gracefully terminate them, unmount attached EBS volumes (via NBD), and flush the write-ahead-log (WAL) to the S3 server (Predastore). This may take a few minutes depending on instance volume sizes.
To fully remove a simulated cluster's data:
```bash
rm -rf ~/node1/ ~/node2/ ~/node3/
```
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/>.
+108 -47
View File
@@ -1,4 +1,4 @@
GO_PROJECT_NAME := hive
GO_PROJECT_NAME := spx
SHELL := /bin/bash
# Detect architecture for cross-platform support
@@ -33,18 +33,35 @@ 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 hive-ui frontend (requires pnpm)
# Build spinifex-ui frontend (requires pnpm)
build-ui:
@echo -e "\n....Building hive-ui frontend...."
cd hive/services/hiveui/frontend && pnpm build
@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 -e "\n....Building $(GO_PROJECT_NAME)"
go build $(GO_BUILD_MOD) -ldflags "-s -w" -o ./bin/$(GO_PROJECT_NAME) cmd/hive/main.go
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
go_run:
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
@@ -52,13 +69,30 @@ go_run:
# Preflight — runs the same checks as GitHub Actions (format + lint + security + tests).
# Use this before committing to catch CI failures locally.
preflight: check-format vet security-check test
preflight:
@$(MAKE) --no-print-directory QUIET=1 check-format check-modernize vet security-check test-cover diff-coverage test-race
@echo -e "\n ✅ Preflight passed — safe to commit."
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -v -timeout 120s ./...
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 -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
@@ -71,20 +105,23 @@ run:
clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf hive/services/hiveui/frontend/dist
rm -rf spinifex/services/spinifexui/frontend/dist
install-system:
@echo -e "\n....Installing system dependencies for $(ARCH)...."
@echo "QEMU packages: $(QEMU_PACKAGES)"
apt-get update && sudo apt-get install -y \
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
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch
install-go:
@echo -e "\n....Installing Go 1.26.0 for $(ARCH) ($(GO_ARCH))...."
@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.0.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
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
@@ -125,53 +162,77 @@ check-format:
# Go vet (fails on issues, matches CI)
vet:
@echo "Running go vet..."
go vet ./...
$(_Q)go vet ./...
@echo " go vet ok"
# Excluded: newexpr (replaces aws.String with new, not idiomatic for AWS SDK)
# Excluded: stringsbuilder (replaces string += in loops with strings.Builder, not worth the complexity for small loops)
GOFIX_EXCLUDE := -newexpr=false -stringsbuilder=false
# Apply go fix modernizations
modernize:
@echo "Applying go fix modernizations..."
go fix $(GOFIX_EXCLUDE) ./...
@echo " go fix applied"
# Check that code is modernized (CI-compatible, fails on diff)
check-modernize:
@echo "Checking go fix modernizations..."
@DIFF=$$(go fix $(GOFIX_EXCLUDE) -diff ./...); \
if [ -n "$$DIFF" ]; then \
echo "$$DIFF"; \
echo "Run 'make modernize' to fix."; \
exit 1; \
fi
@echo " go fix ok"
# Security checks — each tool fails the build on findings (matches CI).
# Reports are also saved to tests/ for review.
security-check:
@echo -e "\n....Running security checks for $(GO_PROJECT_NAME)...."
set -o pipefail && go tool govulncheck ./... 2>&1 | tee tests/govulncheck-report.txt
$(_Q)set -o pipefail && go tool govulncheck ./... $(_SECQ) tests/govulncheck-report.txt $(if $(QUIET),|| { cat tests/govulncheck-report.txt; exit 1; })
@echo " govulncheck ok"
set -o pipefail && go tool gosec -exclude=G204,G304,G402 -exclude-generated -exclude-dir=cmd ./... 2>&1 | tee tests/gosec-report.txt
$(_Q)set -o pipefail && go tool gosec -quiet -exclude=G204,G304,G402,G117,G703,G705,G706 -exclude-generated -exclude-dir=cmd ./... $(_SECQ) tests/gosec-report.txt $(if $(QUIET),|| { cat tests/gosec-report.txt; exit 1; })
@echo " gosec ok"
set -o pipefail && go tool staticcheck -checks="all,-ST1000,-ST1003,-ST1016,-ST1020,-ST1021,-ST1022,-SA1019,-SA9005" ./... 2>&1 | tee tests/staticcheck-report.txt
$(_Q)set -o pipefail && go tool staticcheck -checks="all,-ST1000,-ST1003,-ST1016,-ST1020,-ST1021,-ST1022,-SA1019,-SA9005" ./... $(_SECQ) tests/staticcheck-report.txt $(if $(QUIET),|| { cat tests/staticcheck-report.txt; exit 1; })
@echo " staticcheck ok"
# Docker E2E tests (mirrors GitHub Actions e2e.yml)
# Usage: make test-docker # both suites
# make test-docker-single # single-node only
# make test-docker-multi # multi-node only
PARENT_DIR := $(shell cd .. && pwd)
E2E_IMAGE := hive-e2e:latest
# 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
test-docker-build:
@echo -e "\n....Building E2E Docker image...."
@for dep in viperblock predastore; do \
if [ ! -d "$(PARENT_DIR)/$$dep" ]; then \
echo "Missing sibling repo $$dep — running clone-deps.sh"; \
./scripts/clone-deps.sh; \
break; \
fi; \
done
docker build -t $(E2E_IMAGE) -f tests/e2e/Dockerfile.e2e $(PARENT_DIR)
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
test-docker-single: test-docker-build
@echo -e "\n....Running Single-Node E2E Tests...."
docker run --privileged --rm -v /dev/kvm:/dev/kvm $(E2E_IMAGE)
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
test-docker-multi: test-docker-build
@echo -e "\n....Running Multi-Node E2E Tests...."
docker run --privileged --rm -v /dev/kvm:/dev/kvm --cap-add=NET_ADMIN $(E2E_IMAGE) ./tests/e2e/run-multinode-e2e.sh
distro-clean:
rm -rf dist/
test-docker: test-docker-build
@echo -e "\n....Running Single-Node E2E Tests...."
docker run --privileged --rm -v /dev/kvm:/dev/kvm $(E2E_IMAGE)
@echo -e "\n....Running Multi-Node E2E Tests...."
docker run --privileged --rm -v /dev/kvm:/dev/kvm --cap-add=NET_ADMIN $(E2E_IMAGE) ./tests/e2e/run-multinode-e2e.sh
.PHONY: build build-ui go_build go_run preflight test bench run clean \
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
install-system install-go install-aws quickinstall \
format check-format vet security-check \
test-docker-build test-docker-single test-docker-multi test-docker
format check-format modernize check-modernize vet security-check \
distro distro-amd64 distro-arm64 distro-clean
+30 -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
@@ -52,44 +52,45 @@ AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, o
- 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.
## 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
cd hive
# Setup dependencies and development environment
./scripts/clone-deps.sh # Clone viperblock + predastore repositories
./scripts/dev-setup.sh # Setup complete development environment
export AWS_PROFILE=spinifex
export AWS_REGION=ap-southeast-2-sc
```
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.
```bash
curl https://install.mulgadc.com/ | bash
spx admin init --region $AWS_REGION
aws ec2 describe-instance-types
```
### 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 +101,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 +117,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/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 Apache 2.0 License. You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
+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
}
+22
View File
@@ -0,0 +1,22 @@
[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
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
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
+21
View File
@@ -0,0 +1,21 @@
[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
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
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
+22
View File
@@ -0,0 +1,22 @@
[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
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
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
+21
View File
@@ -0,0 +1,21 @@
[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
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
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
+19
View File
@@ -0,0 +1,19 @@
[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
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
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
-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"] },
]
@@ -19,6 +19,7 @@ import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -31,19 +32,24 @@ import (
"strings"
"time"
"github.com/mulgadc/hive/hive/admin"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/formation"
"github.com/mulgadc/hive/hive/utils"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/formation"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/s3"
"github.com/mulgadc/viperblock/viperblock/v_utils"
"github.com/nats-io/nats.go"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)
//go:embed templates/hive.toml
var hiveTomlTemplate string
//go:embed templates/spinifex.toml
var spinifexTomlTemplate string
//go:embed templates/awsgw.toml
var awsgwTomlTemplate string
@@ -71,8 +77,8 @@ var supportedPlatforms = map[string]bool{
var adminCmd = &cobra.Command{
Use: "admin",
Short: "Administrative commands for Hive platform management",
Long: `Administrative commands for initializing and managing the Hive platform.`,
Short: "Administrative commands for Spinifex platform management",
Long: `Administrative commands for initializing and managing the Spinifex platform.`,
}
var clusterCmd = &cobra.Command{
@@ -92,17 +98,17 @@ Each phase waits for all nodes to ACK before proceeding to the next.`,
var adminInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize Hive platform configuration",
Long: `Initialize Hive platform by creating configuration files, generating SSL certificates,
Short: "Initialize Spinifex platform configuration",
Long: `Initialize Spinifex 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.`,
configuration files in ~/spinifex/config.`,
Run: runAdminInit,
}
var adminJoinCmd = &cobra.Command{
Use: "join",
Short: "Join an existing Hive cluster",
Long: `Join an existing Hive cluster by connecting to a leader node and retrieving
Short: "Join an existing Spinifex cluster",
Long: `Join an existing Spinifex cluster by connecting to a leader node and retrieving
the cluster configuration. This command will configure the local node to join
the cluster and participate in distributed operations.`,
Run: runAdminJoin,
@@ -128,21 +134,43 @@ var imagesListCmd = &cobra.Command{
Run: runimagesListCmd,
}
var accountCmd = &cobra.Command{
Use: "account",
Short: "Manage Spinifex accounts",
Long: `Create and manage Spinifex accounts. Each account namespaces IAM users, policies, and resources.`,
}
var accountCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new account with an admin user",
Long: `Create a new Spinifex account. This creates an account with a sequential 12-digit ID,
an admin user, and an AdministratorAccess policy attached to the admin user.
Requires the cluster to be running (connects to NATS).`,
Run: runAccountCreate,
}
var accountListCmd = &cobra.Command{
Use: "list",
Short: "List all accounts",
Long: `List all Spinifex accounts with their ID, name, status, and creation time.`,
Run: runAccountList,
}
/*
CLI ideas
hive admin images list
spx 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
spx admin images import --name debian-12-x86_64
// List available images
hive admin images list
spx admin images list
// Manually import a path
hive admin images import --file /path/to/image --distro debian --version 12 --arch x86_64
spx admin images import --file /path/to/image --distro debian --version 12 --arch x86_64
-> x <-
*/
@@ -162,12 +190,14 @@ func init() {
imagesCmd.AddCommand(imagesImportCmd)
imagesCmd.AddCommand(imagesListCmd)
homeDir, _ := os.UserHomeDir()
configDir := fmt.Sprintf("%s/hive/config", homeDir)
hiveDir := fmt.Sprintf("%s/hive/", homeDir)
adminCmd.AddCommand(accountCmd)
accountCmd.AddCommand(accountCreateCmd)
accountCmd.AddCommand(accountListCmd)
accountCreateCmd.Flags().String("name", "", "Account name (required)")
accountCreateCmd.MarkFlagRequired("name")
rootCmd.PersistentFlags().String("config-dir", configDir, "Configuration directory")
rootCmd.PersistentFlags().String("hive-dir", hiveDir, "Hive base directory")
rootCmd.PersistentFlags().String("config-dir", DefaultConfigDir(), "Configuration directory")
rootCmd.PersistentFlags().String("spinifex-dir", DefaultDataDir(), "Spinifex base directory")
// Flags for admin init
adminInitCmd.Flags().Bool("force", false, "Force re-initialization (overwrites existing config)")
@@ -182,7 +212,7 @@ func init() {
adminInitCmd.Flags().String("cluster-routes", "", "NATS cluster hosts for routing specify multiple with comma (e.g., 10.11.12.1:4248,10.11.12.2:4248 for multi-node)")
adminInitCmd.Flags().String("predastore-nodes", "", "Comma-separated IPs for multi-node Predastore cluster (e.g., 10.11.12.1,10.11.12.2,10.11.12.3). Requires >= 3 nodes.")
adminInitCmd.Flags().String("formation-timeout", "10m", "Timeout for cluster formation (e.g., 5m, 30s)")
adminInitCmd.Flags().String("cluster-name", "hive", "NATS cluster name")
adminInitCmd.Flags().String("cluster-name", "spinifex", "NATS cluster name")
adminInitCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui")
// Flags for admin join
@@ -190,7 +220,7 @@ func init() {
adminJoinCmd.Flags().String("az", "ap-southeast-2a", "Availability zone for this node")
adminJoinCmd.Flags().String("node", "", "Node name (required)")
adminJoinCmd.Flags().String("host", "", "Leader node host:port (e.g., node1.local:4432) (required)")
adminJoinCmd.Flags().String("data-dir", "", "Data directory for this node (default: ~/hive)")
adminJoinCmd.Flags().String("data-dir", "", "Data directory for this node (default: ~/spinifex)")
adminJoinCmd.Flags().Int("port", 4432, "Port to bind cluster services on")
adminJoinCmd.Flags().String("bind", "0.0.0.0", "IP address to bind services to (e.g., 10.11.12.2 for multi-node on single host)")
adminJoinCmd.Flags().String("cluster-bind", "", "IP address to bind NATS cluster services to (e.g., 10.11.12.1 for multi-node)")
@@ -225,12 +255,11 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// Use default config path
if cfgFile == "" {
homeDir, _ := os.UserHomeDir()
cfgFile = fmt.Sprintf("%s/hive/config/hive.toml", homeDir)
cfgFile = DefaultConfigFile()
}
//configDir, _ := cmd.Flags().GetString("config-dir")
baseDir, _ := cmd.Flags().GetString("hive-dir")
baseDir, _ := cmd.Flags().GetString("spinifex-dir")
// Strip trailing slash
baseDir = filepath.Clean(baseDir)
@@ -266,10 +295,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
//imageStat, err = os.Stat(imageFile)
if err != nil {
fmt.Fprintf(os.Stderr, "File could not be found %s", err)
if _, err := os.Stat(imageFile); err != nil {
fmt.Fprintf(os.Stderr, "File could not be found: %s", err)
os.Exit(1)
}
@@ -339,7 +366,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
}
// 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-*")
tmpDir, err := os.MkdirTemp(ostmpDir, "spinifex-image-tmp-*")
if err != nil {
fmt.Fprintf(os.Stderr, "Could not create temp dir: %v\n", err)
@@ -371,7 +398,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
volumeId := utils.GenerateResourceID("ami")
manifest.AMIMetadata.ImageID = volumeId
manifest.AMIMetadata.Description = fmt.Sprintf("%s cloud image prepared for Hive", manifest.AMIMetadata.Name)
manifest.AMIMetadata.Description = fmt.Sprintf("%s cloud image prepared for Spinifex", manifest.AMIMetadata.Name)
manifest.AMIMetadata.Architecture = image.Arch
manifest.AMIMetadata.PlatformDetails = image.Platform
manifest.AMIMetadata.CreationDate = time.Now()
@@ -421,8 +448,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
VolumeSize: utils.SafeInt64ToUint64(imageStat.Size()),
Bucket: appConfig.Nodes[appConfig.Node].Predastore.Bucket,
Region: appConfig.Nodes[appConfig.Node].Predastore.Region,
AccessKey: appConfig.Nodes[appConfig.Node].AccessKey,
SecretKey: appConfig.Nodes[appConfig.Node].SecretKey,
AccessKey: appConfig.Nodes[appConfig.Node].Predastore.AccessKey,
SecretKey: appConfig.Nodes[appConfig.Node].Predastore.SecretKey,
Host: appConfig.Nodes[appConfig.Node].Predastore.Host,
}
@@ -486,14 +513,14 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
pterm.Println("To install a selected image as an AMI use:")
pterm.Println("hive admin images import --name <image-name>")
pterm.Println("spx admin images import --name <image-name>")
}
// TODO: Move all logic to a module, use minimal application logic in viper commands
func runAdminInit(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
configDir, _ := cmd.Flags().GetString("config-dir")
hiveRoot, _ := cmd.Flags().GetString("hive-dir")
spxRoot, _ := cmd.Flags().GetString("spinifex-dir")
region, _ := cmd.Flags().GetString("region")
az, _ := cmd.Flags().GetString("az")
node, _ := cmd.Flags().GetString("node")
@@ -528,28 +555,23 @@ func runAdminInit(cmd *cobra.Command, args []string) {
clusterBind = bindIP
}
fmt.Printf("Initializing Hive with bind IP: %s, port: %d\n", bindIP, port)
fmt.Printf("Initializing Spinifex with bind IP: %s, port: %d\n", bindIP, port)
// 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")
configDir = DefaultConfigDir()
}
fmt.Println("🚀 Initializing Hive platform...")
fmt.Println("🚀 Initializing Spinifex platform...")
fmt.Printf("Configuration directory: %s\n\n", configDir)
// Check if already initialized
hiveTomlPath := filepath.Join(configDir, "hive.toml")
if !force && admin.FileExists(hiveTomlPath) {
fmt.Println("⚠️ Hive already initialized!")
fmt.Printf("Config file exists: %s\n", hiveTomlPath)
spinifexTomlPath := filepath.Join(configDir, "spinifex.toml")
if !force && admin.FileExists(spinifexTomlPath) {
fmt.Println("⚠️ Spinifex already initialized!")
fmt.Printf("Config file exists: %s\n", spinifexTomlPath)
fmt.Println("\nTo re-initialize, run with --force flag:")
fmt.Println(" hive admin init --force")
fmt.Println(" spx admin init --force")
os.Exit(0)
}
@@ -560,35 +582,62 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Created config directory: %s\n", configDir)
// Generate AWS credentials
accessKey := admin.GenerateAWSAccessKey()
secretKey := admin.GenerateAWSSecretKey()
accountID := admin.GenerateAccountID()
// Generate system credentials (for service-to-service auth in config files)
accessKey, err := admin.GenerateAWSAccessKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err)
os.Exit(1)
}
secretKey, err := admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err)
os.Exit(1)
}
accountID := admin.SystemAccountID()
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 IAM master key (AES-256, used to encrypt secrets in NATS KV)
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey)
fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID())
fmt.Printf(" AWS Profile: spinifex\n")
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
// Generate NATS token
natsToken := admin.GenerateNATSToken()
natsToken, err := admin.GenerateNATSToken()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating NATS token: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔒 Generated NATS authentication token")
if hiveRoot == "" {
// Get home directory for data path
homeDir, _ := os.UserHomeDir()
hiveRoot = filepath.Join(homeDir, "hive")
if spxRoot == "" {
spxRoot = DefaultDataDir()
}
spxRoot = filepath.Clean(spxRoot)
// Determine if this is a multi-node formation
isMultiNode := nodes >= 2 && bindIP != "0.0.0.0"
if isMultiNode {
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
configDir, hiveRoot, certPath, region, az, node, bindIP, clusterBind,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind,
port, nodes, formationTimeoutStr, services)
return
}
@@ -602,9 +651,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
awsgwDir := filepath.Join(configDir, "awsgw")
predastoreDir := filepath.Join(configDir, "predastore")
natsDir := filepath.Join(configDir, "nats")
hiveDir := filepath.Join(configDir, "hive")
spinifexDir := filepath.Join(configDir, "spinifex")
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, hiveDir} {
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, spinifexDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err)
os.Exit(1)
@@ -642,7 +691,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// Generate multi-node predastore.toml
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -662,7 +711,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
AccountID: accountID,
Region: region,
NatsToken: natsToken,
DataDir: hiveRoot,
DataDir: spxRoot,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -674,11 +724,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
PredastoreNodeID: predastoreNodeID,
Services: services,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
// Generate config files
configs := []admin.ConfigFile{
{Name: "hive.toml", Path: hiveTomlPath, Template: hiveTomlTemplate},
{Name: "spinifex.toml", Path: spinifexTomlPath, Template: spinifexTomlTemplate},
{Name: filepath.Join(awsgwDir, "awsgw.toml"), Path: filepath.Join(awsgwDir, "awsgw.toml"), Template: awsgwTomlTemplate},
{Name: filepath.Join(natsDir, "nats.conf"), Path: filepath.Join(natsDir, "nats.conf"), Template: natsConfTemplate},
}
@@ -689,35 +742,44 @@ func runAdminInit(cmd *cobra.Command, args []string) {
})
}
err := admin.GenerateConfigFiles(configs, configSettings)
if err != nil {
if err := admin.GenerateConfigFiles(configs, configSettings); err != nil {
fmt.Fprintf(os.Stderr, "Error generating configuration files: %v\n", err)
os.Exit(1)
}
// Update ~/.aws/credentials and ~/.aws/config
// Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system)
fmt.Println("\n🔧 Configuring AWS credentials...")
if err := admin.SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP); err != nil {
if err := admin.SetupAWSCredentials(bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, certPath, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err)
} else {
fmt.Println("✅ AWS credentials configured")
}
admin.CreateServiceDirectories(hiveRoot)
admin.CreateServiceDirectories(spxRoot)
// In production layout (running as root), fix ownership so the service user
// can read config and write data. SUDO_USER identifies the operator account.
if os.Getuid() == 0 {
sudoUser := os.Getenv("SUDO_USER")
if sudoUser != "" {
admin.ChownRecursive(configDir, sudoUser)
admin.ChownRecursive(spxRoot, sudoUser)
}
}
// Print success message
fmt.Println("\n🎉 Hive initialization complete!")
fmt.Println("\n🎉 Spinifex 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(" export AWS_PROFILE=spinifex")
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.Printf(" Config file: %s\n", spinifexTomlPath)
fmt.Printf(" Data directory: %s\n", spxRoot)
fmt.Println()
}
@@ -725,7 +787,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// It starts a formation server, registers this node, waits for all nodes to join,
// then generates configs with complete cluster topology.
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, natsToken, clusterName,
configDir, hiveRoot, certPath, region, az, node, bindIP, clusterBind string,
configDir, spxRoot, certPath, region, az, node, bindIP, clusterBind string,
port, expectedNodes int, formationTimeoutStr string, services []string) {
formationTimeout, err := time.ParseDuration(formationTimeoutStr)
@@ -734,6 +796,20 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
// Generate IAM master key for the cluster
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
if err != nil {
@@ -747,16 +823,21 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
}
creds := &formation.SharedCredentials{
AccessKey: accessKey,
SecretKey: secretKey,
AccountID: accountID,
NatsToken: natsToken,
ClusterName: clusterName,
Region: region,
AccessKey: accessKey,
SecretKey: secretKey,
AccountID: accountID,
NatsToken: natsToken,
ClusterName: clusterName,
Region: region,
AdminAccessKey: bootstrapResult.AdminAccessKey,
AdminSecretKey: bootstrapResult.AdminSecretKey,
}
fs := formation.NewFormationServer(expectedNodes, creds, string(caCertData), string(caKeyData))
// Include master key in formation server for distribution to joining nodes
fs.SetMasterKey(base64.StdEncoding.EncodeToString(masterKey))
// Register self (init node) as the first node
selfNode := formation.NodeInfo{
Name: node,
@@ -780,7 +861,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Printf("\n📡 Formation server started on %s\n", formationAddr)
fmt.Printf(" Waiting for %d more node(s) to join...\n", expectedNodes-1)
fmt.Printf(" Other nodes should run: hive admin join --host %s --node <name> --bind <ip>\n\n", formationAddr)
fmt.Printf(" Other nodes should run: spx admin join --host %s --node <name> --bind <ip>\n\n", formationAddr)
// Wait for all nodes to register
if err := fs.WaitForCompletion(formationTimeout); err != nil {
@@ -802,9 +883,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
awsgwDir := filepath.Join(configDir, "awsgw")
predastoreDir := filepath.Join(configDir, "predastore")
natsDir := filepath.Join(configDir, "nats")
hiveDir := filepath.Join(configDir, "hive")
spinifexDir := filepath.Join(configDir, "spinifex")
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, hiveDir} {
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, spinifexDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err)
os.Exit(1)
@@ -816,7 +897,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
// Generate multi-node predastore config
var predastoreNodeID int
if len(predastoreNodes) >= 3 {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -832,7 +913,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Printf("✅ Created: multi-node predastore.toml (node ID: %d)\n", predastoreNodeID)
}
hiveTomlPath := filepath.Join(configDir, "hive.toml")
spinifexTomlPath := filepath.Join(configDir, "spinifex.toml")
configSettings := admin.ConfigSettings{
AccessKey: accessKey,
@@ -840,7 +921,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
AccountID: accountID,
Region: region,
NatsToken: natsToken,
DataDir: hiveRoot,
DataDir: spxRoot,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -853,11 +935,15 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
PredastoreNodeID: predastoreNodeID,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node),
// Init node runs ovn-central locally
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
// Generate config files
configs := []admin.ConfigFile{
{Name: "hive.toml", Path: hiveTomlPath, Template: hiveTomlTemplate},
{Name: "spinifex.toml", Path: spinifexTomlPath, Template: spinifexTomlTemplate},
{Name: filepath.Join(awsgwDir, "awsgw.toml"), Path: filepath.Join(awsgwDir, "awsgw.toml"), Template: awsgwTomlTemplate},
{Name: filepath.Join(natsDir, "nats.conf"), Path: filepath.Join(natsDir, "nats.conf"), Template: natsConfTemplate},
}
@@ -873,15 +959,15 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
// Update ~/.aws/credentials and ~/.aws/config
// Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system)
fmt.Println("\n🔧 Configuring AWS credentials...")
if err := admin.SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP); err != nil {
if err := admin.SetupAWSCredentials(bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, certPath, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err)
} else {
fmt.Println("✅ AWS credentials configured")
}
admin.CreateServiceDirectories(hiveRoot)
admin.CreateServiceDirectories(spxRoot)
// Keep formation server running briefly so joining nodes can fetch complete status
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
@@ -926,6 +1012,13 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Extract leader IP for OVN NB/SB DB address (strip port from host:port)
leaderIP, _, err := net.SplitHostPort(leaderHost)
if err != nil {
// leaderHost might be an IP without port
leaderIP = leaderHost
}
// Validate IP address format
if net.ParseIP(bindIP) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
@@ -945,15 +1038,10 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
// Set default data directory
if dataDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error getting home directory: %v\n", err)
os.Exit(1)
}
dataDir = filepath.Join(homeDir, "hive")
dataDir = DefaultDataDir()
}
fmt.Println("🚀 Joining Hive cluster...")
fmt.Println("🚀 Joining Spinifex cluster...")
fmt.Printf("Node: %s\n", node)
fmt.Printf("Leader: %s\n", leaderHost)
fmt.Printf("Region: %s\n", region)
@@ -985,7 +1073,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
resp, err := client.Post(joinURL, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error connecting to formation server: %v\n", err)
fmt.Fprintf(os.Stderr, "Make sure the leader node has run 'hive admin init' and is accessible at %s\n", leaderHost)
fmt.Fprintf(os.Stderr, "Make sure the leader node has run 'spx admin init' and is accessible at %s\n", leaderHost)
os.Exit(1)
}
@@ -1051,12 +1139,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
// Set up 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")
configDir = DefaultConfigDir()
}
if err := os.MkdirAll(configDir, 0700); err != nil {
@@ -1083,6 +1166,23 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ CA certificate received from leader: %s\n", caCertPath)
// Extract and write master key from formation server
if statusResp.MasterKey == "" {
fmt.Fprintf(os.Stderr, "❌ Error: formation server did not return master key\n")
os.Exit(1)
}
masterKeyBytes, err := base64.StdEncoding.DecodeString(statusResp.MasterKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error decoding master key: %v\n", err)
os.Exit(1)
}
if err := writeBootstrapFilesWithAdmin(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ IAM master key received from leader")
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(configDir, "bootstrap.json"))
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
@@ -1100,9 +1200,9 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
awsgwDir := filepath.Join(configDir, "awsgw")
predastoreDir := filepath.Join(configDir, "predastore")
natsDir := filepath.Join(configDir, "nats")
hiveDir := filepath.Join(configDir, "hive")
spinifexDir := filepath.Join(configDir, "spinifex")
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, hiveDir} {
for _, dir := range []string{awsgwDir, predastoreDir, natsDir, spinifexDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err)
os.Exit(1)
@@ -1116,7 +1216,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
hasPredastoreConfig := len(predastoreNodes) >= 3
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1136,7 +1236,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Printf("✅ Created: multi-node predastore.toml (node ID: %d)\n", predastoreNodeID)
}
hiveTomlPath := filepath.Join(configDir, "hive.toml")
spinifexTomlPath := filepath.Join(configDir, "spinifex.toml")
configSettings := admin.ConfigSettings{
AccessKey: creds.AccessKey,
@@ -1145,6 +1245,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
Region: creds.Region,
NatsToken: creds.NatsToken,
DataDir: dataDir,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -1157,11 +1258,15 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
PredastoreNodeID: predastoreNodeID,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
// Joining nodes connect to the init node's OVN NB/SB DB
OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP),
OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP),
}
// Generate config files
configs := []admin.ConfigFile{
{Name: "hive.toml", Path: hiveTomlPath, Template: hiveTomlTemplate},
{Name: "spinifex.toml", Path: spinifexTomlPath, Template: spinifexTomlTemplate},
{Name: filepath.Join(awsgwDir, "awsgw.toml"), Path: filepath.Join(awsgwDir, "awsgw.toml"), Template: awsgwTomlTemplate},
{Name: filepath.Join(natsDir, "nats.conf"), Path: filepath.Join(natsDir, "nats.conf"), Template: natsConfTemplate},
}
@@ -1178,9 +1283,9 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Update ~/.aws/credentials and ~/.aws/config
// Update ~/.aws/credentials and ~/.aws/config with admin credentials (not system)
fmt.Println("\n🔧 Configuring AWS credentials...")
if err := admin.SetupAWSCredentials(creds.AccessKey, creds.SecretKey, creds.Region, caCertPath, bindIP); err != nil {
if err := admin.SetupAWSCredentials(creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, caCertPath, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err)
} else {
fmt.Println("✅ AWS credentials configured")
@@ -1202,7 +1307,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
// buildRemoteNodes converts formation NodeInfo into RemoteNode entries,
// excluding the local node. This puts all cluster members into hive.toml
// excluding the local node. This puts all cluster members into spinifex.toml
// so config is the source of truth for expected cluster membership.
func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string) []admin.RemoteNode {
var remote []admin.RemoteNode
@@ -1223,3 +1328,235 @@ func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string)
})
return remote
}
// initIAMServiceFromConfig loads config, connects to NATS, loads the master
// key, and returns an initialised IAMServiceImpl. Callers must defer nc.Close().
func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterConfig, *nats.Conn, func(), error) {
cfg, nc, err := loadConfigAndConnect()
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("connect to cluster: %w", err)
}
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
if err != nil {
nc.Close()
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
}
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey, len(cfg.Nodes))
if err != nil {
nc.Close()
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
}
return svc, cfg, nc, func() { nc.Close() }, nil
}
// adminAccessPolicyDocument is the AdministratorAccess policy document that
// grants full access to all actions and resources.
const adminAccessPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
func runAccountCreate(cmd *cobra.Command, args []string) {
name, _ := cmd.Flags().GetString("name")
svc, cfg, nc, cleanup, err := initIAMServiceFromConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer cleanup()
// 1. Create the account
account, err := svc.CreateAccount(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating account: %v\n", err)
os.Exit(1)
}
accountID := account.AccountID
// Create default VPC for the new account (belt-and-suspenders: daemon also
// does this via iam.account.created event, but daemon may not be running).
nodeConfig := cfg.Nodes[cfg.Node]
vpcSvc, vpcErr := handlers_ec2_vpc.NewVPCServiceImplWithNATS(&nodeConfig, nc)
if vpcErr != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create default VPC service: %v\n", vpcErr)
} else if vpcErr = vpcSvc.EnsureDefaultVPC(accountID); vpcErr != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create default VPC: %v\n", vpcErr)
}
// 2. Create admin user
_, err = svc.CreateUser(accountID, &iam.CreateUserInput{
UserName: aws.String("admin"),
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating admin user: %v\n", err)
os.Exit(1)
}
// 3. Create access key for admin user
akOut, err := svc.CreateAccessKey(accountID, &iam.CreateAccessKeyInput{
UserName: aws.String("admin"),
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating access key: %v\n", err)
os.Exit(1)
}
// 4. Create AdministratorAccess policy scoped to this account
policyARN := fmt.Sprintf("arn:aws:iam::%s:policy/AdministratorAccess", accountID)
_, err = svc.CreatePolicy(accountID, &iam.CreatePolicyInput{
PolicyName: aws.String("AdministratorAccess"),
PolicyDocument: aws.String(adminAccessPolicyDocument),
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating admin policy: %v\n", err)
os.Exit(1)
}
// 5. Attach policy to admin user
_, err = svc.AttachUserPolicy(accountID, &iam.AttachUserPolicyInput{
UserName: aws.String("admin"),
PolicyArn: aws.String(policyARN),
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error attaching policy: %v\n", err)
os.Exit(1)
}
// Configure AWS CLI profile automatically
profileName := "spinifex-" + strings.ToLower(strings.ReplaceAll(name, " ", "-"))
homeDir, _ := os.UserHomeDir()
endpointHost := "localhost"
certPath := filepath.Join(cfg.NodeBaseDir(), "config", "ca.pem")
nodeConfig = cfg.Nodes[cfg.Node]
if h, _, err := net.SplitHostPort(nodeConfig.AWSGW.Host); err == nil {
if h != "" && h != "0.0.0.0" {
endpointHost = h
}
}
endpointURL := fmt.Sprintf("https://%s:9999", endpointHost)
credPath := filepath.Join(homeDir, ".aws", "credentials")
configPath := filepath.Join(homeDir, ".aws", "config")
if err := admin.UpdateAWSINIFile(credPath, profileName, map[string]string{
"aws_access_key_id": *akOut.AccessKey.AccessKeyId,
"aws_secret_access_key": *akOut.AccessKey.SecretAccessKey,
}); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not update AWS credentials: %v\n", err)
}
region := cfg.Nodes[cfg.Node].Region
if region == "" {
region = "ap-southeast-2"
}
if err := admin.UpdateAWSINIFile(configPath, "profile "+profileName, map[string]string{
"region": region,
"endpoint_url": endpointURL,
"ca_bundle": certPath,
"output": "json",
}); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not update AWS config: %v\n", err)
}
// Print credentials
fmt.Println("\nAccount created successfully!")
fmt.Printf(" Account ID: %s\n", accountID)
fmt.Printf(" Account Name: %s\n", name)
fmt.Printf(" Admin User: admin\n")
fmt.Printf(" Access Key ID: %s\n", *akOut.AccessKey.AccessKeyId)
fmt.Printf(" Secret Access Key: %s\n", *akOut.AccessKey.SecretAccessKey)
fmt.Printf(" AWS Profile: %s\n", profileName)
fmt.Println("\nUse with:")
fmt.Printf(" AWS_PROFILE=%s aws ec2 describe-instances\n", profileName)
}
func runAccountList(cmd *cobra.Command, args []string) {
svc, _, _, cleanup, err := initIAMServiceFromConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer cleanup()
accounts, err := svc.ListAccounts()
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing accounts: %v\n", err)
os.Exit(1)
}
if len(accounts) == 0 {
fmt.Println("No accounts found.")
return
}
fmt.Printf("%-14s %-20s %-10s %s\n", "ACCOUNT ID", "NAME", "STATUS", "CREATED")
fmt.Printf("%-14s %-20s %-10s %s\n", "----------", "----", "------", "-------")
for _, a := range accounts {
created := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
created = t.Format("2006-01-02 15:04")
}
fmt.Printf("%-14s %-20s %-10s %s\n", a.AccountID, a.AccountName, a.Status, created)
}
}
// writeBootstrapResult holds the admin credentials so callers can
// write them to ~/.aws/credentials instead of the system credentials.
type writeBootstrapResult struct {
AdminAccessKey string
AdminSecretKey string
}
// writeBootstrapFiles generates new admin credentials and writes the bootstrap
// files (master.key + bootstrap.json). Used by init flows (single and multi-node).
func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) {
adminAccessKey, err := admin.GenerateAWSAccessKey()
if err != nil {
return nil, fmt.Errorf("generate admin access key: %w", err)
}
adminSecretKey, err := admin.GenerateAWSSecretKey()
if err != nil {
return nil, fmt.Errorf("generate admin secret key: %w", err)
}
if err := writeBootstrapFilesWithAdmin(configDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil {
return nil, err
}
return &writeBootstrapResult{
AdminAccessKey: adminAccessKey,
AdminSecretKey: adminSecretKey,
}, nil
}
// writeBootstrapFilesWithAdmin writes the bootstrap files using the provided
// admin credentials. Used by join flows where admin creds come from the leader.
func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error {
if err := handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), masterKey); err != nil {
return fmt.Errorf("saving master key: %w", err)
}
encryptedSecret, err := handlers_iam.EncryptSecret(secretKey, masterKey)
if err != nil {
return fmt.Errorf("encrypting system secret: %w", err)
}
adminEncryptedSecret, err := handlers_iam.EncryptSecret(adminSecretKey, masterKey)
if err != nil {
return fmt.Errorf("encrypting admin secret: %w", err)
}
bd := &handlers_iam.BootstrapData{
AccessKeyID: accessKey,
EncryptedSecret: encryptedSecret,
AccountID: accountID,
Admin: &handlers_iam.AdminBootstrapData{
AccountID: admin.DefaultAccountID(),
AccountName: admin.DefaultAccountName(),
UserName: "admin",
AccessKeyID: adminAccessKey,
EncryptedSecret: adminEncryptedSecret,
},
}
return handlers_iam.SaveBootstrapData(filepath.Join(configDir, "bootstrap.json"), bd)
}
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/mulgadc/hive/hive/daemon"
"github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/nats-io/nats.go"
"github.com/spf13/cobra"
)
@@ -77,7 +77,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
// Execute phases sequentially (except INFRA which is fire-and-forget)
for _, phase := range phases {
topic := "hive.cluster.shutdown." + phase
topic := "spinifex.cluster.shutdown." + phase
req := daemon.ShutdownRequest{
Phase: phase,
Force: force,
@@ -106,7 +106,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
// For DRAIN phase, subscribe to progress updates
var progressSub *nats.Subscription
if phase == "drain" {
progressSub, err = nc.Subscribe("hive.cluster.shutdown.progress", func(msg *nats.Msg) {
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
+18 -10
View File
@@ -19,12 +19,14 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/utils"
"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"
@@ -64,8 +66,7 @@ func init() {
func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
cfgPath := viper.GetString("config")
if cfgPath == "" {
homeDir, _ := os.UserHomeDir()
cfgPath = fmt.Sprintf("%s/hive/config/hive.toml", homeDir)
cfgPath = DefaultConfigFile()
}
cfg, err := config.LoadConfig(cfgPath)
@@ -73,6 +74,13 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
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 {
@@ -146,16 +154,16 @@ func runGetNodes(cmd *cobra.Command, args []string) {
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "hive.node.status", 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]config.NodeStatusResponse)
respondedNodes := make(map[string]types.NodeStatusResponse)
for _, data := range responses {
var resp config.NodeStatusResponse
var resp types.NodeStatusResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
@@ -220,21 +228,21 @@ func runGetVMs(cmd *cobra.Command, args []string) {
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "hive.node.vms", 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 {
config.VMInfo
types.VMInfo
Node string
Host string
}
var allVMs []vmRow
for _, data := range responses {
var resp config.NodeVMsResponse
var resp types.NodeVMsResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
+44
View File
@@ -0,0 +1,44 @@
package cmd
import (
"os"
"path/filepath"
)
// DefaultConfigDir returns the default configuration directory.
// Production: /etc/spinifex (when running as root or /etc/spinifex exists)
// Development: ~/spinifex/config
func DefaultConfigDir() string {
if isProductionLayout() {
return "/etc/spinifex"
}
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, "spinifex", "config")
}
// DefaultDataDir returns the default data directory.
// Production: /var/lib/spinifex
// Development: ~/spinifex
func DefaultDataDir() string {
if isProductionLayout() {
return "/var/lib/spinifex"
}
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, "spinifex")
}
// 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, or /etc/spinifex directory exists.
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")
}
}
@@ -19,7 +19,7 @@ import (
"fmt"
"os"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -32,20 +32,17 @@ var (
// 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: `
__/\\\________/\\\__/\\\\\\\\\\\__/\\\________/\\\__/\\\\\\\\\\\\\\\_
_\/\\\_______\/\\\_\/////\\\///__\/\\\_______\/\\\_\/\\\///////////__
_\/\\\_______\/\\\_____\/\\\_____\//\\\______/\\\__\/\\\_____________
_\/\\\\\\\\\\\\\\\_____\/\\\______\//\\\____/\\\___\/\\\\\\\\\\\_____
_\/\\\/////////\\\_____\/\\\_______\//\\\__/\\\____\/\\\///////______
_\/\\\_______\/\\\_____\/\\\________\//\\\/\\\_____\/\\\_____________
_\/\\\_______\/\\\_____\/\\\_________\//\\\\\______\/\\\_____________
_\/\\\_______\/\\\__/\\\\\\\\\\\______\//\\\_______\/\\\\\\\\\\\\\\\_
_\///________\///__\///////////________\///________\///////////////__
_____ _____ _____ _ _ _____ ______ ________ __
/ ____| __ \_ _| \ | |_ _| ____| ____\ \ / /
| (___ | |__) || | | \| | | | | |__ | |__ \ V /
\___ \| ___/ | | | . ‘ | | | | __| | __| > <
____) | | _| |_| |\ |_| |_| | | |____ / . \
|_____/|_| |_____|_| \_|_____|_| |______/_/ \_\
Hive – Open source AWS-compatible platform for secure edge deployments.
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
@@ -67,38 +64,38 @@ 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
@@ -116,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)
}
}
@@ -21,20 +21,21 @@ import (
"os"
"strings"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/service"
"github.com/mulgadc/hive/hive/services/hiveui"
"github.com/mulgadc/hive/hive/services/nats"
"github.com/mulgadc/hive/hive/services/predastore"
"github.com/mulgadc/hive/hive/services/viperblockd"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/service"
"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 Hive services",
Short: "Manage Spinifex services",
}
var predastoreCmd = &cobra.Command{
@@ -52,9 +53,9 @@ var natsCmd = &cobra.Command{
Short: "Manage the nats service",
}
var hiveCmd = &cobra.Command{
Use: "hive",
Short: "Manage the hive service",
var spinifexCmd = &cobra.Command{
Use: "spinifex",
Short: "Manage the spinifex service",
}
var awsgwCmd = &cobra.Command{
@@ -62,10 +63,15 @@ var awsgwCmd = &cobra.Command{
Short: "Manage the awsgw (AWS gateway) service",
}
var hiveUICmd = &cobra.Command{
Use: "hive-ui",
Aliases: []string{"ui", "hiveui"},
Short: "Manage the hive-ui 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{
@@ -142,7 +148,10 @@ var predastoreStartCmd = &cobra.Command{
return
}
service.Start()
if _, err := service.Start(); err != nil {
fmt.Println("Error starting predastore service:", err)
os.Exit(1)
}
fmt.Println("Predastore service started", service)
},
@@ -161,7 +170,10 @@ var predastoreStopCmd = &cobra.Command{
return
}
service.Stop()
if err = service.Stop(); err != nil {
fmt.Println("Error stopping predastore service:", err)
os.Exit(1)
}
fmt.Println("Predastore service stopped")
@@ -195,12 +207,11 @@ var viperblockStartCmd = &cobra.Command{
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
if err != nil {
fmt.Println("Error loading config file:", err)
return
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
natsHost := viper.GetString("nats-host")
@@ -314,7 +325,10 @@ var viperblockStopCmd = &cobra.Command{
return
}
service.Stop()
if err = service.Stop(); err != nil {
fmt.Println("Error stopping viperblock service:", err)
os.Exit(1)
}
fmt.Println("Viperblock service stopped")
@@ -358,7 +372,10 @@ var natsStartCmd = &cobra.Command{
return
}
service.Start()
if _, err = service.Start(); err != nil {
fmt.Println("Error starting nats service:", err)
os.Exit(1)
}
fmt.Println("NATS service started")
},
}
@@ -376,7 +393,10 @@ var natsStopCmd = &cobra.Command{
return
}
service.Stop()
if err = service.Stop(); err != nil {
fmt.Println("Error stopping nats service:", err)
os.Exit(1)
}
fmt.Println("Nats service stopped")
},
@@ -390,12 +410,12 @@ var natsStatusCmd = &cobra.Command{
},
}
// Repeat for hive
var hiveStartCmd = &cobra.Command{
// Repeat for spinifex
var spinifexStartCmd = &cobra.Command{
Use: "start",
Short: "Start the hive service",
Short: "Start the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting hive service...")
fmt.Println("Starting spinifex service...")
cfgFile := viper.GetString("config")
@@ -406,12 +426,11 @@ var hiveStartCmd = &cobra.Command{
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
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")
@@ -432,47 +451,53 @@ var hiveStartCmd = &cobra.Command{
// Apply changes back to cluster config
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
svc, err := service.New("hive", clusterConfig)
svc, err := service.New("spinifex", clusterConfig)
if err != nil {
fmt.Println("Error starting hive service:", err)
fmt.Println("Error starting spinifex service:", err)
return
}
// Set config path for cluster manager
if hiveSvc, ok := svc.(interface{ SetConfigPath(string) }); ok {
hiveSvc.SetConfigPath(cfgFile)
if spxSvc, ok := svc.(interface{ SetConfigPath(string) }); ok {
spxSvc.SetConfigPath(cfgFile)
}
svc.Start()
fmt.Println("HIVE service started")
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting spinifex service:", err)
os.Exit(1)
}
fmt.Println("Spinifex service started")
},
}
var hiveStopCmd = &cobra.Command{
var spinifexStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the hive service",
Short: "Stop the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping hive service...")
fmt.Println("Stopping spinifex service...")
service, err := service.New("hive", &config.ClusterConfig{})
service, err := service.New("spinifex", &config.ClusterConfig{})
if err != nil {
fmt.Println("Error stopping hive service:", err)
fmt.Println("Error stopping spinifex service:", err)
return
}
service.Stop()
if err = service.Stop(); err != nil {
fmt.Println("Error stopping spinifex service:", err)
os.Exit(1)
}
fmt.Println("Hive service stopped")
fmt.Println("Spinifex service stopped")
},
}
var hiveStatusCmd = &cobra.Command{
var spinifexStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the hive service",
Short: "Get status of the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Hive service status: ...")
fmt.Println("Spinifex service status: ...")
},
}
@@ -495,12 +520,11 @@ var awsgwStartCmd = &cobra.Command{
// TODO: Support ENV vars, CLI, otherwise revert to config.LoadConfig()
clusterConfig, err := config.LoadConfig(cfgFile)
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
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")
@@ -524,7 +548,7 @@ var awsgwStartCmd = &cobra.Command{
baseDir := viper.GetString("base-dir")
if awsgwTlsKey != "" {
if baseDir != "" {
fmt.Println("Overwriting awsgw base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
@@ -539,7 +563,10 @@ var awsgwStartCmd = &cobra.Command{
return
}
service.Start()
if _, err = service.Start(); err != nil {
fmt.Println("Error starting awsgw service:", err)
os.Exit(1)
}
fmt.Println("AWSGW service started")
},
}
@@ -557,7 +584,10 @@ var awsgwStopCmd = &cobra.Command{
return
}
service.Stop()
if err = service.Stop(); err != nil {
fmt.Println("Error stopping awsgw service:", err)
os.Exit(1)
}
fmt.Println("AWSGW service stopped")
},
@@ -571,18 +601,18 @@ var awsgwStatusCmd = &cobra.Command{
},
}
var hiveUIStartCmd = &cobra.Command{
var spinifexUIStartCmd = &cobra.Command{
Use: "start",
Short: "Start the hive-ui service",
Short: "Start the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting hive-ui service...")
fmt.Println("Starting spinifex-ui service...")
port := viper.GetInt("hive-ui-port")
host := viper.GetString("hive-ui-host")
tlsCert := viper.GetString("hive-ui-tls-cert")
tlsKey := viper.GetString("hive-ui-tls-key")
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("hive-ui", &hiveui.Config{
svc, err := service.New("spinifex-ui", &spinifexui.Config{
Port: port,
Host: host,
TLSCert: tlsCert,
@@ -590,57 +620,132 @@ var hiveUIStartCmd = &cobra.Command{
})
if err != nil {
fmt.Println("Error starting hive-ui service:", err)
fmt.Println("Error starting spinifex-ui service:", err)
return
}
svc.Start()
fmt.Println("hive-ui service started")
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting spinifex-ui service:", err)
os.Exit(1)
}
fmt.Println("spinifex-ui service started")
},
}
var hiveUIStopCmd = &cobra.Command{
var spinifexUIStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the hive-ui service",
Short: "Stop the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping hive-ui service...")
fmt.Println("Stopping spinifex-ui service...")
svc, err := service.New("hive-ui", &hiveui.Config{})
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
fmt.Println("Error stopping hive-ui service:", err)
fmt.Println("Error stopping spinifex-ui service:", err)
return
}
svc.Stop()
fmt.Println("hive-ui service stopped")
if err = svc.Stop(); err != nil {
fmt.Println("Error stopping spinifex-ui service:", err)
os.Exit(1)
}
fmt.Println("spinifex-ui service stopped")
},
}
var hiveUIStatusCmd = &cobra.Command{
var spinifexUIStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the hive-ui service",
Short: "Get status of the spinifex-ui service",
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.New("hive-ui", &hiveui.Config{})
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
fmt.Println("Error getting hive-ui service status:", err)
fmt.Println("Error getting spinifex-ui service status:", err)
return
}
status, err := svc.Status()
if err != nil {
fmt.Println("Error getting hive-ui service status:", err)
fmt.Println("Error getting spinifex-ui service status:", err)
return
}
fmt.Println("hive-ui service status:", status)
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]
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,
})
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("HIVE") // Prefix for environment variables
viper.SetEnvPrefix("SPINIFEX") // Prefix for environment variables
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv() // Read environment variables automatically
@@ -651,47 +756,47 @@ func init() {
// Predastore Port
predastoreCmd.PersistentFlags().Int("port", 8443, "Predastore (S3) port")
viper.BindEnv("port", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_TLS_CERT")
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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_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", "HIVE_PREDASTORE_NODE_ID")
viper.BindEnv("node-id", "SPINIFEX_PREDASTORE_NODE_ID")
viper.BindPFlag("node-id", predastoreCmd.PersistentFlags().Lookup("node-id"))
// Predastore CPU Profiling
@@ -711,19 +816,19 @@ func init() {
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.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", "HIVE_VIPERBLOCK_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", "HIVE_VIPERBLOCK_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/hive/lib/nbdkit-viperblock-plugin.so", "Pathname to the nbdkit viperblockplugin")
viper.BindEnv("plugin-path", "HIVE_VIPERBLOCK_PLUGIN_PATH")
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)
@@ -739,82 +844,89 @@ func init() {
// Add NATS flags
natsCmd.PersistentFlags().Int("port", 4222, "NATS server port")
viper.BindEnv("port", "HIVE_NATS_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", "HIVE_NATS_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", "HIVE_NATS_DEBUG")
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", "HIVE_NATS_DATA_DIR")
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", "HIVE_NATS_JETSTREAM")
viper.BindEnv("jetstream", "SPINIFEX_NATS_JETSTREAM")
viper.BindPFlag("jetstream", natsCmd.PersistentFlags().Lookup("jetstream"))
// Hive
serviceCmd.AddCommand(hiveCmd)
// Spinifex
serviceCmd.AddCommand(spinifexCmd)
hiveCmd.AddCommand(hiveStartCmd)
hiveCmd.AddCommand(hiveStopCmd)
hiveCmd.AddCommand(hiveStatusCmd)
spinifexCmd.AddCommand(spinifexStartCmd)
spinifexCmd.AddCommand(spinifexStopCmd)
spinifexCmd.AddCommand(spinifexStatusCmd)
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"))
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", "HIVE_AWSGW_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", "HIVE_AWSGW_TLS_CERT")
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", "HIVE_AWSGW_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", "HIVE_AWSGW_DEBUG")
viper.BindEnv("debug", "SPINIFEX_AWSGW_DEBUG")
viper.BindPFlag("debug", awsgwCmd.PersistentFlags().Lookup("debug"))
awsgwCmd.AddCommand(awsgwStartCmd)
awsgwCmd.AddCommand(awsgwStopCmd)
awsgwCmd.AddCommand(awsgwStatusCmd)
// hive-ui
serviceCmd.AddCommand(hiveUICmd)
// spinifex-ui
serviceCmd.AddCommand(spinifexUICmd)
hiveUICmd.PersistentFlags().Int("port", 3000, "hive-ui server port")
viper.BindEnv("hive-ui-port", "HIVE_UI_PORT")
viper.BindPFlag("hive-ui-port", hiveUICmd.PersistentFlags().Lookup("port"))
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"))
hiveUICmd.PersistentFlags().String("host", "0.0.0.0", "hive-ui server host")
viper.BindEnv("hive-ui-host", "HIVE_UI_HOST")
viper.BindPFlag("hive-ui-host", hiveUICmd.PersistentFlags().Lookup("host"))
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"))
hiveUICmd.PersistentFlags().String("tls-cert", "", "TLS certificate path")
viper.BindEnv("hive-ui-tls-cert", "HIVE_UI_TLS_CERT")
viper.BindPFlag("hive-ui-tls-cert", hiveUICmd.PersistentFlags().Lookup("tls-cert"))
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"))
hiveUICmd.PersistentFlags().String("tls-key", "", "TLS key path")
viper.BindEnv("hive-ui-tls-key", "HIVE_UI_TLS_KEY")
viper.BindPFlag("hive-ui-tls-key", hiveUICmd.PersistentFlags().Lookup("tls-key"))
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"))
hiveUICmd.AddCommand(hiveUIStartCmd)
hiveUICmd.AddCommand(hiveUIStopCmd)
hiveUICmd.AddCommand(hiveUIStatusCmd)
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
@@ -1,15 +1,15 @@
# NATS Server Configuration
# Generated by Hive admin init
# Generated by Spinifex admin init
# Server configuration
{{ if .BindIP }}
{{- if .BindIP }}
listen: {{.BindIP}}:4222
{{ else }}
{{- else }}
listen: 0.0.0.0:4222
{{ end }}
{{- end }}
{{- if and .ClusterBindIP .ClusterRoutes }}
{{ if and .ClusterBindIP .ClusterRoutes }}
cluster {
name: {{ .ClusterName }}
listen: {{.ClusterBindIP}}:4248
@@ -20,10 +20,9 @@ cluster {
{{- end }}
]
}
{{ end }}
{{- end }}
server_name: hive-nats-{{.Node}}
server_name: spinifex-nats-{{.Node}}
# Authorization
authorization {
@@ -49,9 +48,9 @@ max_payload: 1MB
max_pending: 64MB
# Monitoring
{{ if .BindIP }}
{{- if .BindIP }}
http: {{.BindIP}}:8222
{{ else }}
{{- else }}
http_port: 8222
{{ end }}
{{- end }}
@@ -11,7 +11,8 @@ debug = false
[rs]
data = 2
parity = 1
{{range .Nodes}}
{{- range .Nodes}}
[[db]]
id = {{.ID}}
host = "{{.Host}}"
@@ -22,14 +23,16 @@ secret_access_key = "{{$.SecretKey}}"
{{- if eq .ID 1}}
leader = true
{{- end}}
{{end}}
{{- end}}
{{- range .Nodes}}
[[nodes]]
id = {{.ID}}
host = "{{.Host}}"
port = 9991
path = "distributed/nodes/node-{{.ID}}/"
{{end}}
{{- end}}
[[buckets]]
name = "predastore"
region = "{{.Region}}"
@@ -43,3 +46,10 @@ 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"
@@ -79,4 +79,11 @@ 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"
@@ -13,13 +13,12 @@ services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
account_id = "{{.AccountID}}"
accesskey = "{{.AccessKey}}"
secretkey = "{{.SecretKey}}"
[nodes.{{.Node}}.daemon]
host = "{{.BindIP}}:{{.Port}}"
tlskey = "config/server.key"
tlscert = "config/server.pem"
dev_networking = true
[nodes.{{.Node}}.awsgw]
host = "{{.BindIP}}:9999"
@@ -39,6 +38,10 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
ovn_sb_addr = "{{.OVNSBAddr}}"
[nodes.{{.Node}}.predastore]
host = "{{.BindIP}}:8443"
region = "{{.Region}}"
@@ -22,7 +22,7 @@ import (
"sort"
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@@ -56,15 +56,15 @@ func runTopNodes(cmd *cobra.Command, args []string) {
defer nc.Close()
timeout, _ := cmd.Flags().GetDuration("timeout")
responses, err := collectResponses(nc, "hive.node.status", 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]config.NodeStatusResponse)
respondedNodes := make(map[string]types.NodeStatusResponse)
for _, data := range responses {
var resp config.NodeStatusResponse
var resp types.NodeStatusResponse
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
+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")
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
package main
import "github.com/mulgadc/hive/cmd/hive/cmd"
import "github.com/mulgadc/spinifex/cmd/spinifex/cmd"
func main() {
cmd.Execute()
+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!"
+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
+200 -79
View File
@@ -1,22 +1,98 @@
# AWS Command Implementation Matrix
# Command Implementation Matrix
## EC2 - Instance Management
## 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** |
### Cluster Operations
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cluster shutdown` | — | Cluster must be running | Gracefully shuts down the entire cluster | 1. Shutdown running cluster<br>2. All nodes stop cleanly | **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`, `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination) | `--security-group-ids`, `--subnet-id`, `--placement`, `--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 → NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → cloud-init injects user-data/keys → 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. DryRun returns validation-only<br>6. Invalid instance type returns error | **DONE** |
| `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) | `--security-group-ids`, `--placement`, `--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 → 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 → 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 | **DONE** |
| `describe-instances` | `--instance-ids` | `--filters`, `--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 | 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 | **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 sends NATS to target node → daemon issues QMP `system_reset` → instance reboots without stopping | 1. Reboot running instance<br>2. Reboot stopped instance (error)<br>3. Verify instance stays in running state after reboot | **NOT STARTED** (parser test exists) |
| `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` | Instance must be stopped | Validate instance stopped → update instance metadata in NATS KV store → changes applied on next start | 1. Change instance type while stopped<br>2. Modify running instance (error)<br>3. Toggle disable-api-termination | **NOT STARTED** |
| `get-console-output` | — | `--instance-id`, `--latest` | Instance must exist | QMP command to read serial console ring buffer → return base64-encoded output with timestamp | 1. Get output from running instance<br>2. Get output from stopped instance<br>3. Verify cloud-init boot logs visible | **NOT STARTED** |
| `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** |
| `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
### EC2 - Key Pair Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -25,12 +101,12 @@
| `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
### EC2 - AMI Image Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-images` | `--image-ids` (format validation only) | `--owners`, `--executable-users`, `--filters`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → 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) | **DONE** |
| `create-image` | — | `--instance-id`, `--name`, `--description`, `--no-reboot`, `--block-device-mappings`, `--tag-specifications` | Instance must exist | Stop instance (unless --no-reboot) → snapshot root volume via viperblock → create AMI metadata in S3 → register as new AMI → restart instance → return ami-ID | 1. Create image from running instance<br>2. Create with --no-reboot<br>3. Invalid instance ID (error)<br>4. Verify new AMI appears in describe-images<br>5. Launch new instance from created AMI | **NOT STARTED** |
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias) | `--executable-users`, `--filters`, `--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 and Owners → 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) | **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** |
@@ -39,20 +115,20 @@
| `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
### 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`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → 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 | **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) | `--iops`, `--encrypted`, `--snapshot-id`, `--tag-specifications` | Valid AZ configured via `hive init` | Gateway validates input → NATS `ec2.CreateVolume` → daemon generates vol-ID via viperblock → creates empty volume 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) | **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`, `--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) → 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 | **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
### EC2 - Snapshot Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -62,7 +138,7 @@
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--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 → filters by snapshot ID if specified → 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) | **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
### EC2 - Tags
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -70,31 +146,31 @@
| `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
### 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 hive 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 hive 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** |
| `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
### 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)
### 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` | — | `--dry-run` | None | Store encryption default flag in JetStream KV → return `EbsEncryptionByDefault=true` | 1. Enable encryption<br>2. Verify via get | **NOT STARTED** (enforcement pending) |
| `disable-ebs-encryption-by-default` | — | `--dry-run` | None | Clear encryption default flag in JetStream KV → return `EbsEncryptionByDefault=false` | 1. Disable encryption<br>2. Verify via get | **NOT STARTED** (enforcement pending) |
| `get-ebs-encryption-by-default` | — | `--dry-run` | None | Read encryption default flag from JetStream KV → return current state | 1. Get default state<br>2. Verify matches last enable/disable | **NOT STARTED** (enforcement pending) |
| `enable-serial-console-access` | — | `--dry-run` | None | Store serial console access flag in JetStream KV → return enabled=true | 1. Enable access<br>2. Verify via get | **NOT STARTED** (enforcement pending) |
| `disable-serial-console-access` | — | `--dry-run` | None | Clear serial console access flag in JetStream KV → return enabled=false | 1. Disable access | **NOT STARTED** (enforcement pending) |
| `get-serial-console-access-status` | — | `--dry-run` | None | Read serial console access flag from JetStream KV | 1. Get current status | **NOT STARTED** (enforcement pending) |
| `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) |
@@ -104,15 +180,17 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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
### 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`, `--client-token`, `--tag-specifications`, `--dry-run` | VPC must exist | NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates record in NATS JetStream KV with state=attached → 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} | **NOT STARTED** |
| `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 KV → returns ReturnCode=true | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **NOT STARTED** |
| `describe-egress-only-internet-gateways` | — | `--egress-only-internet-gateway-ids`, `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from KV bucket → filters by ID if specified → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **NOT STARTED** |
| `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`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **STARTED** (KV only, no OVN) |
## EC2 - Placement Groups
### EC2 - Placement Groups
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -120,7 +198,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `delete-placement-group` | — | `--group-name`, `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists and is empty → delete from KV → return success | 1. Delete empty group<br>2. Delete group with instances (error)<br>3. Delete non-existent group (error) | **NOT STARTED** |
| `describe-placement-groups` | — | `--group-names`, `--group-ids`, `--filters` | None | NATS `ec2.DescribePlacementGroups` → daemon lists groups from KV → return list with strategy and state | 1. List all groups<br>2. Filter by name<br>3. Filter by strategy | **NOT STARTED** |
## EC2 - Dedicated Hosts
### EC2 - Dedicated Hosts
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -128,7 +206,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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
### EC2 - IPv4 Pools
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -136,7 +214,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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
### EC2 - DHCP Options
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -145,7 +223,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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
### EC2 - Capacity Reservations
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -154,7 +232,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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
### EC2 - Elastic IP
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -164,7 +242,7 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing<br>2. Disassociate non-existent (error) | **NOT STARTED** |
| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP | **NOT STARTED** |
## EC2 - NAT Gateway
### EC2 - NAT Gateway
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -174,21 +252,21 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| `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)
### EC2 - VPC Networking (Core)
Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Single AZ for Hive v1.
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket).
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpc` | — | `--cidr-block`, `--tag-specifications`, `--instance-tenancy` | None | Create OVS bridge for VPC → assign vpc-ID → store VPC metadata in NATS KV → configure CIDR range → return VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Overlapping CIDR (error)<br>3. Invalid CIDR format (error)<br>4. Verify in describe-vpcs | **NOT STARTED** |
| `delete-vpc` | — | `--vpc-id` | VPC must be empty (no subnets, gateways, instances) | Verify no dependent resources → delete OVS bridge → remove metadata → return success | 1. Delete empty VPC<br>2. Delete VPC with subnets (error: DependencyViolation)<br>3. Delete non-existent VPC (error) | **NOT STARTED** |
| `describe-vpcs` | — | `--vpc-ids`, `--filters`, `--max-results` | None | Query NATS KV for VPC metadata → return VPC list with CIDR, state, tags | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Filter by CIDR block | **NOT STARTED** |
| `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`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → 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 | **DONE** |
| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support<br>2. Enable DNS hostnames<br>3. Missing VPC ID (error) | **NOT STARTED** |
| `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` | VPC must exist | Validate CIDR within VPC range → create OVS port group with VLAN tag → assign subnet-ID → store metadata | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error)<br>3. Overlapping subnet CIDRs (error) | **NOT STARTED** |
| `delete-subnet` | — | `--subnet-id` | Subnet must be empty (no instances) | Verify no instances in subnet → remove OVS port group → delete metadata | 1. Delete empty subnet<br>2. Delete subnet with instances (error) | **NOT STARTED** |
| `describe-subnets` | — | `--subnet-ids`, `--filters`, `--max-results` | None | Query NATS KV for subnet metadata → return subnet list | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by AZ | **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) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies vpc-id filter if present → 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 | **DONE** |
| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP<br>2. Missing subnet ID (error) | **NOT STARTED** |
| `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** |
@@ -199,13 +277,13 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR | **NOT STARTED** |
| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
| `create-internet-gateway` | — | `--tag-specifications` | None | Create IGW metadata → assign igw-ID → configure OVS NAT bridge for external access | 1. Create IGW<br>2. Verify in describe-internet-gateways | **NOT STARTED** |
| `attach-internet-gateway` | — | `--internet-gateway-id`, `--vpc-id` | IGW and VPC must exist | Link IGW to VPC → configure OVS flows for internet routing → update metadata | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (error)<br>3. Attach to non-existent VPC (error) | **NOT STARTED** |
| `detach-internet-gateway` | — | `--internet-gateway-id`, `--vpc-id` | IGW must be attached to VPC | Unlink IGW from VPC → remove OVS internet routing flows | 1. Detach attached IGW<br>2. Detach unattached IGW (error) | **NOT STARTED** |
| `delete-internet-gateway` | — | `--internet-gateway-id` | IGW must be detached | Verify IGW detached → remove OVS NAT bridge → delete metadata | 1. Delete detached IGW<br>2. Delete attached IGW (error) | **NOT STARTED** |
| `describe-internet-gateways` | — | `--internet-gateway-ids`, `--filters`, `--max-results` | None | Query NATS KV for IGW metadata → return list with attachment info | 1. List all IGWs<br>2. Filter by attachment VPC | **NOT STARTED** |
| `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`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info | **DONE** |
## EC2 - Route Tables
### EC2 - Route Tables
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -218,7 +296,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `associate-route-table` | — | `--route-table-id`, `--subnet-id` or `--gateway-id` | Route table and target must exist | NATS `ec2.AssociateRouteTable` → daemon links route table to subnet/gateway → return rtbassoc-ID | 1. Associate with subnet<br>2. Missing route table ID (error) | **NOT STARTED** |
| `disassociate-route-table` | — | `--association-id` | Association must exist, not main table association | NATS `ec2.DisassociateRouteTable` → daemon unlinks route table → return success | 1. Disassociate existing<br>2. Disassociate main table (error) | **NOT STARTED** |
## EC2 - VPC Peering
### EC2 - VPC Peering
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -229,7 +307,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - VPC Endpoints
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -239,7 +317,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - VPN Gateway & Customer Gateway
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -252,7 +330,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - VPN Connections
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -262,7 +340,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - Network ACLs
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -274,7 +352,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - Prefix Lists
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -285,20 +363,22 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### 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`, `--description`, `--private-ip-address`, `--groups`, `--tag-specifications` | Subnet must exist | NATS `ec2.CreateNetworkInterface` → daemon creates ENI in KV with eni-ID → assign private IP from subnet pool → return ENI | 1. Create in subnet<br>2. Create with specific private IP<br>3. Missing subnet ID (error) | **NOT STARTED** |
| `delete-network-interface` | — | `--network-interface-id`, `--dry-run` | Must not be attached | NATS `ec2.DeleteNetworkInterface` → daemon verifies detached → release IP → delete from KV → return success | 1. Delete detached ENI<br>2. Delete attached ENI (error) | **NOT STARTED** |
| `describe-network-interfaces` | — | `--network-interface-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists ENIs from KV → return list with IP/attachment info | 1. List all ENIs<br>2. Filter by subnet<br>3. Filter by attachment | **NOT STARTED** |
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | NATS `ec2.AttachNetworkInterface` → daemon attaches OVS port to instance QEMU process → configure MAC/IP → return eni-attach-ID | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** |
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | NATS `ec2.DetachNetworkInterface` → daemon detaches OVS port from instance → return success | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** |
| `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`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
| `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
### EC2 - Launch Templates
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -308,7 +388,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EC2 - Misc Operations
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -316,7 +396,7 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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
### EBS Direct API
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -327,9 +407,9 @@ Requires Open vSwitch (`apt install openvswitch-switch openvswitch-common`). Sin
| `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** |
## S3 (via Predastore)
### S3 (via Predastore)
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Hive format for AWS gateway integration.
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Spinifex format for AWS gateway integration.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
@@ -340,19 +420,60 @@ Currently S3 operations are handled directly by Predastore. Consider moving cont
| `s3 rm` (DeleteObject) | — | `s3://bucket/key`, `--recursive` | Object must exist | NATS `s3.deleteobject` → Predastore deletes object shards | 1. Delete single object<br>2. Recursive delete<br>3. Delete non-existent (idempotent) | **NOT STARTED** |
| `s3 sync` | — | `source dest`, `--delete`, `--exclude`, `--include` | Bucket must exist | Compare source and dest → upload changed/new files → optionally delete removed files | 1. Sync local dir to S3<br>2. Sync with --delete<br>3. Sync with exclude filter | **NOT STARTED** |
## IAM
### 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` | None | Store user in JetStream KV → generate AIDA-prefixed UserId → return User with ARN | 1. Create user<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing username (error) | **NOT STARTED** |
| `get-user` | — | `--user-name` | User must exist | Read user from JetStream KV → return User details | 1. Get existing user<br>2. Get non-existent user (NoSuchEntity) | **NOT STARTED** |
| `list-users` | — | `--path-prefix`, `--max-items`, `--marker` | None | List users from JetStream KV → return user list | 1. List all users<br>2. List with path prefix<br>3. Empty list | **NOT STARTED** |
| `delete-user` | — | `--user-name` | User must exist, no access keys/policies | Remove user from JetStream KV → return success | 1. Delete user with no keys<br>2. Delete user with keys (error)<br>3. Delete non-existent (NoSuchEntity) | **NOT STARTED** |
| `create-access-key` | — | `--user-name` | User must exist | Generate access key ID (AKIA prefix) and secret → store in JetStream KV → return credentials | 1. Create key for user<br>2. Max keys exceeded (error) | **NOT STARTED** (gateway stub exists) |
| `delete-access-key` | — | `--access-key-id`, `--user-name` | Key must exist | Remove access key from JetStream KV → return success | 1. Delete existing key<br>2. Delete non-existent key (error) | **NOT STARTED** |
| `list-access-keys` | — | `--user-name`, `--max-items` | None | Query JetStream KV for user's access keys → return list | 1. List keys for user<br>2. No keys returns empty | **NOT STARTED** |
| `create-policy` | — | `--policy-name`, `--policy-document`, `--path`, `--description`, `--tags` | None | Validate JSON policy document → store in JetStream KV → generate policy ARN → return Policy | 1. Create with valid document<br>2. Invalid JSON (error)<br>3. Duplicate name (EntityAlreadyExists) | **NOT STARTED** |
| `delete-policy` | — | `--policy-arn` | Policy must exist, not attached to any user | Remove policy from JetStream KV → return success | 1. Delete unattached policy<br>2. Delete attached policy (error) | **NOT STARTED** |
| `attach-user-policy` | — | `--user-name`, `--policy-arn` | User and policy must exist | Link policy to user in JetStream KV → return success | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent user (NoSuchEntity) | **NOT STARTED** |
| `detach-user-policy` | — | `--user-name`, `--policy-arn` | User and policy must exist, policy attached | Unlink policy from user in JetStream KV → return success | 1. Detach attached policy<br>2. Not attached (error) | **NOT STARTED** |
| `list-attached-user-policies` | — | `--user-name`, `--path-prefix`, `--max-items` | User must exist | List policies attached to user from JetStream KV → return policy list | 1. List attached policies<br>2. No policies returns empty | **NOT STARTED** |
| `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 | 54 EC2 actions mapped to IAM policy strings | **DONE** |
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
+26 -26
View File
@@ -1,6 +1,6 @@
# Hive Architecture
# Spinifex Architecture
Hive 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).
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
@@ -19,7 +19,7 @@ Hive is an AWS-compatible infrastructure platform for bare-metal, edge, and on-p
│ • EC2 Query Protocol parsing • Routes to service handlers │
│ • XML response generation • NATS client connection │
│ │
│ File: hive/services/awsgw/awsgw.go │
│ File: spinifex/services/awsgw/awsgw.go │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ JSON over NATS
@@ -28,7 +28,7 @@ Hive is an AWS-compatible infrastructure platform for bare-metal, edge, and on-p
│ NATS Message Broker │
│ │
│ Topics: Queue Groups: │
│ • ec2.RunInstances • hive-workers (load balanced) │
│ • 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 │
@@ -37,7 +37,7 @@ Hive is an AWS-compatible infrastructure platform for bare-metal, edge, and on-p
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Hive Daemon │ │ Hive Daemon │ │ Hive Daemon │
│ Spinifex Daemon │ │ Spinifex Daemon │ │ Spinifex Daemon │
│ (Node 1) │ │ (Node 2) │ │ (Node N) │
│ │ │ │ │ │
│ • NATS subscriber │ │ • NATS subscriber │ │ • NATS subscriber │
@@ -58,7 +58,7 @@ Hive is an AWS-compatible infrastructure platform for bare-metal, edge, and on-p
### 1. AWS SDK Request
Users interact with Hive using standard AWS SDKs or the AWS CLI by pointing to a custom endpoint:
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 \
@@ -73,7 +73,7 @@ The AWS SDK formats this as an HTTP POST with:
### 2. AWS Gateway
The gateway (`hive/services/awsgw/awsgw.go:57-107`) is the entry point:
The gateway (`spinifex/services/awsgw/awsgw.go:57-107`) is the entry point:
```go
// awsgw.go:74 - Connect to NATS
@@ -89,7 +89,7 @@ gw := gateway.GatewayConfig{
log.Fatal(app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey))
```
Request routing (`hive/gateway/gateway.go:125-156`):
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`)
@@ -109,7 +109,7 @@ case "iam":
### 3. EC2 Handler
The EC2 handler (`hive/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
The EC2 handler (`spinifex/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
```go
// ec2.go - Action routing
@@ -124,7 +124,7 @@ case "DescribeInstances":
### 4. NATS Messaging
The gateway communicates with daemons via NATS request/response (`hive/handlers/ec2/instance/service_nats.go:24-57`):
The gateway communicates with daemons via NATS request/response (`spinifex/handlers/ec2/instance/service_nats.go:24-57`):
```go
// service_nats.go:24-57
@@ -145,13 +145,13 @@ func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.R
### 5. Daemon Processing
Daemons (`hive/daemon/daemon.go`) subscribe to NATS topics and handle requests:
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",
"hive-workers", // Queue group - only one daemon handles each request
"spinifex-workers", // Queue group - only one daemon handles each request
d.handleEC2RunInstances,
)
@@ -162,7 +162,7 @@ d.natsSubscriptions["ec2.DescribeInstances"], err = d.natsConn.Subscribe(
)
```
**Queue Groups**: Topics subscribed with a queue group (`hive-workers`) are load-balanced - only one daemon handles each request. Topics without a queue group fan out to all daemons.
**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
@@ -225,19 +225,19 @@ type ResourceManager struct {
| Topic | Queue Group | Purpose |
|-------|-------------|---------|
| `ec2.RunInstances` | `hive-workers` | Launch new instances |
| `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` | `hive-workers` | Generate SSH keypair |
| `ec2.DescribeKeyPairs` | `hive-workers` | List SSH keypairs |
| `ec2.DescribeImages` | `hive-workers` | List AMIs |
| `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 |
| `hive.admin.{node}.health` | None | Node health checks |
| `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 (`hive/gateway/ec2/instance/DescribeInstances.go`):
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
@@ -288,13 +288,13 @@ Object storage used for:
## Configuration
Cluster configuration (`hive/config/config.go`):
Cluster configuration (`spinifex/config/config.go`):
```go
type ClusterConfig struct {
Epoch uint64 // Incremented on config changes
Node string // This node's identifier
Version string // Hive version
Version string // Spinifex version
Nodes map[string]Config // Configuration for all nodes
}
@@ -312,8 +312,8 @@ type Config struct {
## File Structure
```
hive/
├── hive/
spinifex/
├── spinifex/
│ ├── services/
│ │ └── awsgw/
│ │ └── awsgw.go # AWS Gateway service entry point
@@ -340,7 +340,7 @@ hive/
│ └── qmp/
│ └── qmp.go # QEMU Machine Protocol client
└── cmd/
└── hive/
└── spinifex/
└── main.go # CLI entry point
```
@@ -351,8 +351,8 @@ hive/
1. Start NATS broker
2. Start Predastore (S3)
3. Start Viperblock (EBS)
4. Start Hive Gateway (`hive awsgw`)
5. Start Hive Daemon(s) (`hive daemon`)
4. Start Spinifex Gateway (`spx awsgw`)
5. Start Spinifex Daemon(s) (`spx daemon`)
### Testing with AWS CLI
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/)
+61
View File
@@ -0,0 +1,61 @@
---
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
./bin/spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Verify
```bash
aws sts get-caller-identity
```
## 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
```
+120
View File
@@ -0,0 +1,120 @@
---
title: "Creating an Environment"
description: "End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances."
category: "Environments"
tags:
- environment
- vpc
- ec2
- ssh
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Debian Cloud Images"
url: "https://cloud.debian.org/images/cloud/bookworm/latest/"
---
# Creating an Environment
> End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide walks through the complete workflow for creating a production-ready Spinifex environment.
**Steps:**
1. Create a user account
2. Import or create SSH keys
3. Create a VPC and subnet
4. Select and import an AMI
5. Launch EC2 instances
6. Connect via SSH
7. Monitor instance health
## Instructions
## 1. Import SSH Key
```bash
aws ec2 import-key-pair --key-name "spinifex-key" --public-key-material fileb://~/.ssh/id_rsa.pub
```
## 2. Create VPC
```bash
aws ec2 create-vpc --cidr-block 10.200.0.0/16
export SPINIFEX_VPC="vpc-XXX"
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
export SPINIFEX_SUBNET="subnet-XXX"
```
## 3. Import AMI
```bash
./bin/spx admin images list
./bin/spx admin images import --name debian-12-x86_64
export SPINIFEX_AMI="ami-XXX"
```
## 4. Launch Instance
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 1
```
## 5. Connect via SSH
```bash
ps auxw | grep $INSTANCE_ID
# Look for: hostfwd=tcp:127.0.0.1:<port>-:22
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
## Troubleshooting
## Instance stuck in pending
Check the Spinifex daemon and QEMU logs for boot errors:
```bash
ls ~/spinifex/logs/
cat ~/spinifex/logs/spinifex.log
```
Verify the AMI exists and architecture matches your host:
```bash
aws ec2 describe-images --image-ids $SPINIFEX_AMI
```
## SSH connection refused
cloud-init takes 30-60 seconds to configure networking and SSH after boot. Wait and retry.
Verify the SSH port forwarding is active:
```bash
ps auxw | grep hostfwd
```
Ensure you're using the correct key and port:
```bash
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
+74
View File
@@ -0,0 +1,74 @@
---
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
## 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.
## Permissions errors during sync
Verify your AWS credentials are configured for both the source and destination:
```bash
aws sts get-caller-identity --profile spinifex
aws sts get-caller-identity --profile default
```
Ensure the IAM user has `s3:GetObject`, `s3:PutObject`, and `s3:ListBucket` permissions on both buckets.
@@ -0,0 +1,132 @@
---
title: "Air-Gapped Installation"
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
badge: advanced
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/source-install) 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
```
@@ -0,0 +1,288 @@
---
title: "Multi-Node Installation"
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
badge: availability-zone
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 operates as a fully distributed infrastructure region — similar to an AWS Region — where multiple nodes provide high availability, data durability, and fault tolerance.
**Services distributed across nodes:**
- **NATS** — Clustered message bus for request routing and JetStream replication
- **Predastore** — Raft consensus with Reed-Solomon erasure coding (RS 2+1)
- **Viperblock** — Block storage co-located with compute nodes
- **Spinifex Daemon** — Independent AWS API request serving per node
Cluster formation is automatic. When initializing with `--nodes 3`, the init node starts a formation server and waits for peers to join. Once all nodes register, each receives the full cluster topology — credentials, CA certificates, NATS routes, Predastore peer lists — no manual configuration synchronization required.
**Network requirements:**
- Minimum 1 NIC per server
- 2 NICs recommended for production (management + overlay)
- UDP port 6081 open between hosts (Geneve tunnels)
## Instructions
## Step 1. Install Spinifex on each server
Run the binary installer on **every server** in the cluster:
```bash
curl https://install.mulgadc.com | bash
```
## Step 2. Set node IP variables
On **each server**, export the IPs for all nodes (replace with your actual IPs):
```bash
export SPINIFEX_NODE1=192.168.1.10
export SPINIFEX_NODE2=192.168.1.11
export SPINIFEX_NODE3=192.168.1.12
```
To find your server's IP:
```bash
ip addr show | grep "inet "
```
Next, define the AWS region and AZ you are deploying locally.
```bash
export AWS_REGION=us-east-1
export AWS_AZ=us-east-1a
```
## Step 3. Setup OVN networking
OVN must be configured on every server before forming the cluster. Server 1 runs OVN central (NB and SB databases) and must be set up first.
**Server 1 — OVN central + compute (run first):**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=$SPINIFEX_NODE1
```
Verify OVN central is ready before proceeding:
```bash
sudo ovn-sbctl show
```
**Server 2 — Compute node (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 — Compute node (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 chassis have registered (from server 1):
```bash
sudo ovn-sbctl show
```
You should see 3 chassis entries, one per server, each with a Geneve encap IP.
## Step 4. Form the cluster
The formation process requires running init and join commands concurrently. The init node blocks until all expected nodes have joined.
**Server 1 — Initialize the cluster:**
```bash
sudo spx admin init \
--node node1 \
--nodes 3 \
--bind $SPINIFEX_NODE1 \
--cluster-bind $SPINIFEX_NODE1 \
--port 4432 \
--region $AWS_REGION \
--az $AWS_AZ
```
**Server 2 — Join the cluster (while init is running):**
```bash
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 the cluster (while init is running):**
```bash
spx admin join \
--node node3 \
--bind $SPINIFEX_NODE3 \
--cluster-bind $SPINIFEX_NODE3 \
--host $SPINIFEX_NODE1:4432 \
--region $AWS_REGION \
--az $AWS_AZ
```
All three processes will exit with a cluster summary once formation is complete.
## Step 5. Start services and verify
Start services on **all servers**:
```bash
sudo systemctl start spinifex.target
```
From any node, verify the cluster:
```bash
spx get nodes
```
```
NAME STATUS IP REGION AZ UPTIME VMs
node1 Ready 192.168.1.10 us-east-1 us-east-1a 2m 0
node2 Ready 192.168.1.11 us-east-1 us-east-1a 2m 0
node3 Ready 192.168.1.12 us-east-1 us-east-1a 2m 0
```
## Step 6. Trust the CA certificate
On **each server**, trust the CA generated during init:
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
export AWS_PROFILE=spinifex
```
## Step 7. Launch instances across the cluster
Connect to any node's AWS Gateway — all nodes serve the same cluster state:
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 3
```
Spinifex distributes instances across nodes based on available capacity. Check which node each instance landed on:
```bash
spx get vms
```
## Shutdown
A graceful shutdown coordinates all nodes — drains VMs, flushes storage, and stops services:
```bash
spx admin cluster shutdown
```
## Troubleshooting
## Nodes not joining the cluster
The init command must still be running when you execute join on other servers. If init has already exited, re-run it.
Check that all servers can reach the init node on the formation port:
```bash
curl -s http://$SPINIFEX_NODE1:4432/health
```
## OVN chassis not registering
Compute nodes need to reach OVN central on server 1. Verify from server 1:
```bash
sudo ovn-sbctl show
```
If chassis are missing, check firewall rules for port 6642:
```bash
sudo ss -tlnp | grep 6642
```
Re-run the OVN setup on the affected compute node.
## Cross-host VMs cannot communicate
This indicates a Geneve tunnel issue. Verify tunnels are established:
```bash
sudo ovs-vsctl show | grep -i geneve
```
Ensure UDP port 6081 is open between all hosts:
```bash
sudo ss -ulnp | grep 6081
```
If tunnels are missing, verify `--encap-ip` was set correctly during OVN setup.
## Health check fails on a node
Check if the daemon is running and listening on the correct IP:
```bash
curl -s http://$SPINIFEX_NODE1:4432/health
```
Review the daemon logs for errors:
```bash
cat ~/spinifex/logs/spinifex.log
```
Verify `--bind` was set to the correct IP during cluster formation.
## NATS cluster routing issues
Check NATS logs for connection errors between nodes:
```bash
grep -i "route\|cluster" ~/spinifex/logs/nats.log
```
Ensure `--cluster-bind` IPs are reachable between all servers.
+200
View File
@@ -0,0 +1,200 @@
---
title: "Installing Spinifex"
description: "Install Spinifex on a single server using the binary installer. Get a working region with EC2, VPC, EBS, and S3 in minutes."
category: "Getting Started"
tags:
- install
- single node
- quickstart
badge: 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 Installation](/docs/installing-spinifex-multi-node). To build from source, see [Source Install](/docs/source-install).
**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 https://install.mulgadc.com | bash
```
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
## Step 2. Initialize a region
Create your first region and availability zone, replace `ap-southeast-2` with your desired region:
```bash
spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
```
## Step 3. Trust the CA certificate
Spinifex generates a local CA during initialization. Add it to your system trust store:
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
## Step 4. Start services
```bash
sudo systemctl start spinifex.target
```
Set the AWS profile to use the default admin account:
```bash
export AWS_PROFILE=spinifex
```
## Step 5. Import an AMI
List available images and import one matching your architecture:
```bash
spx admin images list
spx admin images import --name debian-12-arm64
```
## Step 6. Create a VPC and launch an instance
```bash
aws ec2 create-vpc --cidr-block 10.200.0.0/16
export SPINIFEX_VPC="vpc-XXX"
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
export SPINIFEX_SUBNET="subnet-XXX"
aws ec2 import-key-pair --key-name "spinifex-key" --public-key-material fileb://~/.ssh/id_rsa.pub
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 1
```
## Step 7. Connect via SSH
Find the forwarded SSH port and connect:
```bash
ps auxw | grep hostfwd
# Look for: hostfwd=tcp:127.0.0.1:<port>-:22
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
## Troubleshooting
## spx command not found
The binary is not in your PATH. Run the installer again or add the install directory to your PATH:
```bash
export PATH=$PATH:~/spinifex/bin/
```
Add this to your `~/.bashrc` or `~/.zshrc` for persistence.
## OVN services not starting
Check if the OVN controller is active:
```bash
sudo systemctl is-active ovn-controller
sudo ovn-sbctl show
```
If inactive, re-run the OVN setup that the installer performs. Check system logs for details:
```bash
journalctl -u ovn-controller --no-pager -n 20
```
## CA certificate not trusted
AWS CLI will reject HTTPS connections if the Spinifex CA is not trusted. Re-add the certificate:
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
Verify it was added:
```bash
ls -la /usr/local/share/ca-certificates/spinifex-ca.crt
```
## Instance stuck in pending
Check the Spinifex daemon logs for errors:
```bash
ls ~/spinifex/logs/
cat ~/spinifex/logs/spinifex.log
```
Verify the AMI was imported successfully:
```bash
aws ec2 describe-images
```
## SSH connection refused
cloud-init takes 30-60 seconds to configure the instance after boot. Wait and retry.
Verify the SSH port forwarding is active:
```bash
ps auxw | grep hostfwd
```
If no `hostfwd` entry appears, the instance may not have started correctly. Check instance state:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
+98
View File
@@ -0,0 +1,98 @@
---
title: "Launching Instances"
description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex."
category: "Environments"
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)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## 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
- `terminate-instances` — Permanent removal
## Instructions
## Launch
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 1
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
```
## SSH (Development)
```bash
ps auxw | grep $INSTANCE_ID
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
## 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
./bin/spx admin images list
./bin/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.
+102
View File
@@ -0,0 +1,102 @@
---
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
badge: new
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
## Configure AWS CLI
```bash
export AWS_PROFILE=spinifex
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
```
+194
View File
@@ -0,0 +1,194 @@
---
title: "Source Install"
description: "Build Spinifex from source for development, custom builds, or contributing. Includes dependency setup, compilation, and OVN configuration."
category: "Getting Started"
tags:
- install
- source
- development
badge: source
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"
- title: "Go Downloads"
url: "https://go.dev/dl/"
- title: "AWS CLI Install"
url: "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
---
# Source Install
> Build Spinifex from source for development, custom builds, or contributing.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide covers building Spinifex from source code. Use this if you want to contribute to Spinifex, build custom versions, or inspect the codebase. For production deployments, the [binary installer](/docs/installing-spinifex) is recommended.
**Supported Operating Systems:**
- Ubuntu 22.04 / 24.04 / 25.10
- Debian 12.13
**Requirements:**
- Go 1.26.1+
- GCC, make, pkg-config
- QEMU/KVM
- OVN/Open vSwitch
- AWS CLI v2
## Instructions
## Step 1. Install system dependencies
### Quick Install
Bootstrap all dependencies in one step:
```bash
sudo make -C spinifex quickinstall
```
Ensure Go is in your PATH:
```bash
export PATH=$PATH:/usr/local/go/bin/
```
### Manual Install
Alternatively, install packages individually:
```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 make gcc unzip xz-utils file ovn-central ovn-host openvswitch-switch
```
Install Go 1.26.1+ from [https://go.dev/dl/](https://go.dev/dl/) and verify:
```bash
go version
```
Install AWS CLI v2:
```bash
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
aws --version
```
## Step 2. Clone and build
```bash
mkdir -p ~/Development/mulga/
cd ~/Development/mulga/
git clone https://github.com/mulgadc/spinifex.git
cd spinifex
./scripts/clone-deps.sh # Clone viperblock + predastore
./scripts/dev-setup.sh # Setup complete dev environment
```
Confirm `./bin/spx` exists and is executable.
## Step 3. Setup OVN
For a single-node dev environment:
```bash
./scripts/setup-ovn.sh --management
```
This creates the `br-int` integration bridge, starts `ovn-controller`, configures Geneve tunnel endpoints, and enables IP forwarding.
## Step 4. Initialize
Create a region and availability zone:
```bash
./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
```
## Step 5. Trust the CA certificate
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
## Step 6. Start services
```bash
./scripts/start-dev.sh
export AWS_PROFILE=spinifex
```
## Troubleshooting
## Go not found in PATH
After installing Go, it may not be in your current shell session. Add it:
```bash
export PATH=$PATH:/usr/local/go/bin/
```
Add this to your `~/.bashrc` or `~/.zshrc` for persistence. Verify with:
```bash
go version
```
## ./bin/spx missing after build
The build did not complete successfully. Re-run the setup:
```bash
./scripts/dev-setup.sh
```
Check the output for compilation errors. Common causes are missing Go version or missing system packages.
## OVN services not starting
Verify the setup script was run with `--management`:
```bash
./scripts/setup-ovn.sh --management
```
Check OVN controller status:
```bash
sudo systemctl is-active ovn-controller
sudo ovn-sbctl show
```
## CA certificate not trusted
AWS CLI will reject HTTPS connections to Spinifex services. Re-add the certificate:
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
## clone-deps.sh fails
Ensure you have access to the GitHub repositories. If using SSH and it fails, try HTTPS:
```bash
git config --global url."https://github.com/".insteadOf "git@github.com:"
./scripts/clone-deps.sh
```
+97
View File
@@ -0,0 +1,97 @@
---
title: "Spinifex Admin CLI"
description: "Complete reference for the Spinifex administration CLI. Manage accounts, nodes, VMs, and services."
category: "Administration"
tags:
- cli
- admin
- reference
badge: cli
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:** `~/Development/mulga/spinifex/bin/spx`
All services in the Spinifex platform are managed through this single binary.
## Instructions
## Account Management
```bash
./bin/spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Node Management
```bash
./bin/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
./bin/spx top nodes
```
## Image Management
```bash
./bin/spx admin images list
./bin/spx admin images import --name debian-12-arm64
```
## Cluster Shutdown
```bash
./bin/spx admin cluster shutdown
```
## Troubleshooting
## Permission denied running spinifex
The binary may not be executable. Fix permissions:
```bash
chmod +x ./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.
+97
View File
@@ -0,0 +1,97 @@
---
title: "VPC Networking"
description: "Create and manage Virtual Private Clouds with subnets, DHCP, and Geneve overlay networking."
category: "Environments"
tags:
- vpc
- networking
- ovn
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OVN Architecture"
url: "https://www.ovn.org/en/"
---
# VPC Networking
> Create and manage Virtual Private Clouds with subnets, DHCP, and Geneve overlay networking.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Every EC2 instance runs inside a VPC with an isolated virtual network. Spinifex uses OVN (Open Virtual Network) to provide the networking layer.
**How it works:**
- VPC becomes an OVN logical router
- Subnet becomes an OVN logical switch with DHCP
- Each instance gets an OVN port with automatic IP assignment
- Cross-host traffic uses Geneve tunnels (UDP 6081)
## Instructions
## Create VPC
```bash
aws ec2 create-vpc --cidr-block 10.200.0.0/16
export SPINIFEX_VPC="vpc-XXX"
```
## Create Subnet
```bash
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
export SPINIFEX_SUBNET="subnet-XXX"
```
## Verify
```bash
aws ec2 describe-vpcs
aws ec2 describe-subnets
sudo ovn-nbctl lr-list
sudo ovn-nbctl ls-list
```
## Troubleshooting
## VPC creation fails
Ensure OVN services are running and the vpcd daemon is active:
```bash
sudo systemctl is-active ovn-controller
```
Check the vpcd logs for errors:
```bash
cat ~/spinifex/logs/vpcd.log
```
## Instances cannot reach each other
This typically means Geneve tunnels are not established between hosts. Verify tunnel configuration:
```bash
sudo ovs-vsctl show | grep -i geneve
```
Ensure UDP port 6081 is open between all hosts:
```bash
sudo ss -ulnp | grep 6081
```
From inside a VM, check that the private IP was assigned via DHCP:
```bash
ip addr show
```
+84 -78
View File
@@ -1,130 +1,136 @@
module github.com/mulgadc/hive
module github.com/mulgadc/spinifex
go 1.26.0
go 1.26.1
require (
github.com/NYTimes/gziphandler v1.1.1
github.com/aws/aws-sdk-go v1.55.8
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/klauspost/cpuid/v2 v2.3.0
github.com/mulgadc/predastore v1.0.1
github.com/mulgadc/viperblock v1.0.15
github.com/nats-io/nats-server/v2 v2.11.6
github.com/nats-io/nats.go v1.43.0
github.com/mulgadc/predastore v1.1.0
github.com/mulgadc/viperblock v1.1.0
github.com/nats-io/nats-server/v2 v2.12.4
github.com/nats-io/nats.go v1.49.0
github.com/ovn-kubernetes/libovsdb v0.8.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pterm/pterm v0.12.82
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
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
go.uber.org/automaxprocs v1.6.0
golang.org/x/net v0.47.0
gopkg.in/ini.v1 v1.67.0
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
cloud.google.com/go v0.121.2 // indirect
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
github.com/antithesishq/antithesis-sdk-go v0.6.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/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash v1.1.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/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger/v4 v4.8.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // 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.13.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-chi/chi/v5 v5.2.4 // 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-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // 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-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.13 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/go-hclog v1.6.2 // indirect
github.com/hashicorp/go-immutable-radix v1.0.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.2 // indirect
github.com/hashicorp/golang-lru v0.5.0 // 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/reedsolomon v1.12.6 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/reedsolomon v1.13.2 // 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/mattn/go-runewidth v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // 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/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/jwt/v2 v2.8.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/openai/openai-go/v3 v3.8.1 // indirect
github.com/openai/openai-go/v3 v3.24.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/securego/gosec/v2 v2.22.11 // 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/securego/gosec/v2 v2.24.7 // 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/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // 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.etcd.io/bbolt v1.3.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260304144227-18da59047661 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
google.golang.org/genai v1.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/genai v1.49.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
tool (
+187 -173
View File
@@ -7,14 +7,14 @@ atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtE
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=
cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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=
@@ -27,21 +27,15 @@ github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
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/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
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/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/antithesishq/antithesis-sdk-go v0.6.0 h1:v/YViLhFYkZOEEof4AXjD5AgGnGM84YHF4RqEwp6I2g=
github.com/antithesishq/antithesis-sdk-go v0.6.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
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=
@@ -87,6 +81,7 @@ 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=
@@ -94,13 +89,19 @@ github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW
github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw=
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
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=
@@ -108,24 +109,29 @@ 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.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
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 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -137,13 +143,19 @@ 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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/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/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=
@@ -157,8 +169,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
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=
@@ -168,21 +180,21 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
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/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/enterprise-certificate-proxy v0.3.13 h1:hSPAhW3NX+7HNlTsmrvU0jL75cIzxFktheceg95Nq14=
github.com/googleapis/enterprise-certificate-proxy v0.3.13/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
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=
@@ -192,21 +204,23 @@ github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmty
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I=
github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
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.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0=
github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4=
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 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
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=
@@ -230,15 +244,15 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.12.6 h1:8pqE9aECQG/ZFitiUD1xK/E83zwosBAZtE3UbuZM8TQ=
github.com/klauspost/reedsolomon v1.12.6/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
github.com/klauspost/reedsolomon v1.13.2 h1:9qtQy2tKEVpVB8Pfq87ZljHZb60/LbeTQ1OxV8EGzdE=
github.com/klauspost/reedsolomon v1.13.2/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
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=
@@ -249,51 +263,56 @@ 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.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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/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.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/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.0.1 h1:Qohnnn3a2VxLI41GABHPxcV6cTAv009+mfhzFZ91RTc=
github.com/mulgadc/predastore v1.0.1/go.mod h1:/ktr/gfFMECGCKFiz9Ng0DgPii/BhaGNjDxaIUgBEd0=
github.com/mulgadc/viperblock v1.0.15 h1:QZ+oV4JE2uPxaK+XtcIr1RelAZHwTW00Ix7/ZNGsXpI=
github.com/mulgadc/viperblock v1.0.15/go.mod h1:dV1eRKaIjH8YfuCk2Bd4hxKd3Rx5mWfO6gelJ9SbbeY=
github.com/mulgadc/predastore v1.1.0 h1:DJ/rUWHmb8/cmqX/Cy1ttsX9X077S5PC3vsf6FW0WXU=
github.com/mulgadc/predastore v1.1.0/go.mod h1:4AcNew+IrsSPgvXO3ALBzfsMFOJHGVdS9OUCFU8cyZ8=
github.com/mulgadc/viperblock v1.1.0 h1:afh4Ygah/pmfL7drITzEHfpNp5Gra8kzWqxaOu4QkUM=
github.com/mulgadc/viperblock v1.1.0/go.mod h1:w9V0DjUH5hMXbXsiLbDr9lpqzXBGhEiYB1BeV6TYBkw=
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.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/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts=
github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
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/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/openai/openai-go/v3 v3.8.1 h1:b+YWsmwqXnbpSHWQEntZAkKciBZ5CJXwL68j+l59UDg=
github.com/openai/openai-go/v3 v3.8.1/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU=
github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
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.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -303,25 +322,31 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
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=
@@ -329,43 +354,40 @@ 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/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
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/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/securego/gosec/v2 v2.24.7 h1:3k5yJnrhT1TTdsG0ZsnenlfCcT+7Y/+zeCPHbL7QAn8=
github.com/securego/gosec/v2 v2.24.7/go.mod h1:AdDJbjcG/XxFgVv7pW19vMNYlFM6+Q6Qy3t6lWAUcEY=
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/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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
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/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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
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=
@@ -373,7 +395,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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.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=
@@ -381,66 +406,57 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
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/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.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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.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/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
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.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.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260218203240-3dfff04db8fa h1:6Wi43P0isP8Nl8D4qJmA3VC4FswVH0RJkr5cauo67SQ=
golang.org/x/exp/typeparams v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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=
@@ -451,8 +467,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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=
@@ -461,8 +477,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -471,7 +487,6 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
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-20200202164722-d101bd2416d5/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=
@@ -488,38 +503,37 @@ golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBc
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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=
@@ -531,12 +545,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genai v1.37.0 h1:dgp71k1wQ+/+APdZrN3LFgAGnVnr5IdTF1Oj0Dg+BQc=
google.golang.org/genai v1.37.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=
@@ -544,15 +558,15 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
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.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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=
@@ -565,5 +579,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
-343
View File
@@ -1,343 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"github.com/nats-io/nats.go"
"github.com/spf13/viper"
)
type ClusterConfig struct {
Epoch uint64 `mapstructure:"epoch"` // bump when leader commits changes
Node string `mapstructure:"node"` // my node name
Version string `mapstructure:"version"` // hive version
Nodes map[string]Config `mapstructure:"nodes"` // full config for every node
}
// Config holds all configuration for the application
type Config struct {
// Node config
Node string `mapstructure:"node"`
Host string `mapstructure:"host"` // Unique hostname or IP of this node
Region string `mapstructure:"region"`
AZ string `mapstructure:"az"`
DataDir string `mapstructure:"data_dir"`
Services []string `mapstructure:"services"` // Which services this node runs locally
Daemon DaemonConfig `mapstructure:"daemon"`
NATS NATSConfig `mapstructure:"nats"`
Predastore PredastoreConfig `mapstructure:"predastore"`
Viperblock ViperblockConfig `mapstructure:"viperblock"`
AWSGW AWSGWConfig `mapstructure:"awsgw"`
// Authentication
// TODO: Move to more appropriate setting above
AccessKey string `mapstructure:"accesskey"`
SecretKey string `mapstructure:"secretkey"`
BaseDir string `mapstructure:"base_dir"`
WalDir string `mapstructure:"wal_dir"`
}
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
// is nil, the node name is unset, or the node is not found in the Nodes map.
func (cc *ClusterConfig) NodeBaseDir() string {
if cc == nil || cc.Node == "" {
slog.Warn("NodeBaseDir: no config or node name set, using global PID path")
return ""
}
node, ok := cc.Nodes[cc.Node]
if !ok {
slog.Error("NodeBaseDir: node not found in config", "node", cc.Node)
return ""
}
if node.BaseDir == "" {
slog.Warn("NodeBaseDir: BaseDir is empty for node, using global PID path", "node", cc.Node)
}
return node.BaseDir
}
// AllServices is the default service list when Services is empty (backward compat).
var AllServices = []string{"nats", "predastore", "viperblock", "daemon", "awsgw", "ui"}
// HasService reports whether the node runs the named service.
// An empty Services list means all services (backward compat).
func (c Config) HasService(name string) bool {
services := c.Services
if len(services) == 0 {
services = AllServices
}
for _, s := range services {
if s == name {
return true
}
}
return false
}
// GetServices returns the configured service list, defaulting to AllServices.
func (c Config) GetServices() []string {
if len(c.Services) == 0 {
return AllServices
}
return c.Services
}
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"` // TODO: Replace with root cluster config
}
type ViperblockConfig struct {
ShardWAL *bool `mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
}
type PredastoreConfig struct {
Host string `mapstructure:"host"`
Bucket string `mapstructure:"bucket"`
Region string `mapstructure:"region"`
AccessKey string `mapstructure:"accesskey"`
SecretKey string `mapstructure:"secretkey"`
BaseDir string `mapstructure:"base_dir"`
NodeID int `mapstructure:"node_id"`
}
// 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
DeleteOnTermination bool
NBDURI string // NBD URI - socket path (nbd:unix:/path.sock) or TCP (nbd://host:port)
DeviceName string // Device name (e.g. /dev/sdf) for hot-plugged volumes
}
// NBDTransport defines the transport type for NBD connections
type NBDTransport string
const (
// NBDTransportSocket uses Unix domain sockets (faster, local only)
NBDTransportSocket NBDTransport = "socket"
// NBDTransportTCP uses TCP connections (required for remote/DPU scenarios)
NBDTransportTCP NBDTransport = "tcp"
)
type EBSMountResponse struct {
URI string
Mounted bool
Error string
}
type EBSUnMountResponse struct {
Volume string
Mounted bool
Error string
}
type EBSSyncRequest struct {
Volume string
}
type EBSSyncResponse struct {
Volume string
Synced bool
Error string
}
type EBSDeleteRequest struct {
Volume string
}
type EBSDeleteResponse struct {
Volume string
Success bool
Error string
}
type EBSSnapshotRequest struct {
Volume string
SnapshotID string
}
type EBSSnapshotResponse struct {
SnapshotID string
Success bool
Error string
}
// EC2, TODO: Move to vm.go or more applicable place
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
}
if err := msg.Respond(response); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
// Cluster manager API types
type NodeJoinRequest struct {
Node string `json:"node"`
Region string `json:"region"`
AZ string `json:"az"`
DataDir string `json:"data_dir"`
DaemonHost string `json:"daemon_host"` // Host:port where this node's daemon is accessible
}
// SharedClusterData contains only the shared cluster information (no node-specific top-level fields)
type SharedClusterData struct {
Epoch uint64 `json:"epoch" toml:"epoch"`
Version string `json:"version" toml:"version"`
Nodes map[string]Config `json:"nodes" toml:"nodes"`
}
type NodeJoinResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
SharedData *SharedClusterData `json:"shared_data,omitempty"`
ConfigHash string `json:"config_hash,omitempty"`
JoiningNode string `json:"joining_node,omitempty"` // The node name that is joining
// CA distribution for per-node certificate generation
CACert string `json:"ca_cert,omitempty"` // PEM-encoded CA certificate
CAKey string `json:"ca_key,omitempty"` // PEM-encoded CA private key
// Predastore config distribution for multi-node clusters
PredastoreConfig string `json:"predastore_config,omitempty"` // Full predastore.toml content
}
type NodeHealthResponse struct {
Node string `json:"node"`
Status string `json:"status"`
ConfigHash string `json:"config_hash"`
Epoch uint64 `json:"epoch"`
Uptime int64 `json:"uptime"`
Services []string `json:"services"`
ServiceHealth map[string]string `json:"service_health,omitempty"`
}
// NodeStatusResponse is returned by the hive.node.status NATS topic (fan-out).
type NodeStatusResponse struct {
Node string `json:"node"`
Status string `json:"status"`
Host string `json:"host"`
Region string `json:"region"`
AZ string `json:"az"`
Uptime int64 `json:"uptime"`
Services []string `json:"services"`
TotalVCPU int `json:"total_vcpu"`
TotalMemGB float64 `json:"total_mem_gb"`
AllocVCPU int `json:"alloc_vcpu"`
AllocMemGB float64 `json:"alloc_mem_gb"`
VMCount int `json:"vm_count"`
InstanceTypes []InstanceTypeCap `json:"instance_types"`
}
// InstanceTypeCap describes available capacity for one instance type on a node.
type InstanceTypeCap struct {
Name string `json:"name"`
VCPU int `json:"vcpu"`
MemoryGB float64 `json:"memory_gb"`
Available int `json:"available"`
}
// VMInfo describes a single VM for the cluster stats CLI.
type VMInfo struct {
InstanceID string `json:"instance_id"`
Status string `json:"status"`
InstanceType string `json:"instance_type"`
VCPU int `json:"vcpu"`
MemoryGB float64 `json:"memory_gb"`
LaunchTime int64 `json:"launch_time"`
}
// NodeVMsResponse is returned by the hive.node.vms NATS topic (fan-out).
type NodeVMsResponse struct {
Node string `json:"node"`
Host string `json:"host"`
VMs []VMInfo `json:"vms"`
}
// LoadConfig loads the configuration from file and environment variables
func LoadConfig(configPath string) (*ClusterConfig, error) {
// Set environment variable prefix
viper.SetEnvPrefix("HIVE")
viper.AutomaticEnv()
// 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 ClusterConfig
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
return &config, nil
}
-437
View File
@@ -1,437 +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
## Local Docker E2E Tests
For more advanced testing (e.g., if GitHub Actions is failing), run the Docker E2E tests locally before committing. These mirror the CI pipeline exactly.
```bash
make test-docker-single # Single-node E2E only
make test-docker-multi # Multi-node E2E only
make test-docker # Both suites
```
Requirements:
- Docker with `--privileged` support
- `/dev/kvm` available (KVM-capable host)
- Sibling repos (`viperblock/`, `predastore/`) cloned alongside `hive/`
The Docker build copies local source (including `go.work` if present), so uncommitted changes in sibling repos are picked up automatically.
-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
File diff suppressed because it is too large Load Diff
-202
View File
@@ -1,202 +0,0 @@
package daemon
import (
"fmt"
"os/exec"
"testing"
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClassifyCrashReason_CleanExit(t *testing.T) {
assert.Equal(t, "clean-exit", classifyCrashReason(nil))
}
func TestClassifyCrashReason_OOMKill(t *testing.T) {
// Start a process and kill it with SIGKILL to get a real ExitError
cmd := exec.Command("sleep", "60")
require.NoError(t, cmd.Start())
require.NoError(t, cmd.Process.Kill()) // sends SIGKILL
waitErr := cmd.Wait()
require.Error(t, waitErr)
assert.Equal(t, "oom-killed", classifyCrashReason(waitErr))
}
func TestClassifyCrashReason_ExitCode(t *testing.T) {
// Run a command that exits with a non-zero code
cmd := exec.Command("sh", "-c", "exit 42")
waitErr := cmd.Run()
require.Error(t, waitErr)
assert.Equal(t, "exit-42", classifyCrashReason(waitErr))
}
func TestClassifyCrashReason_Unknown(t *testing.T) {
// A non-ExitError error
err := fmt.Errorf("some random error")
assert.Equal(t, "unknown", classifyCrashReason(err))
}
func TestHandleInstanceCrash_SkipsNonRunning(t *testing.T) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
defer nc.Close()
d := &Daemon{
natsConn: nc,
resourceMgr: NewResourceManager(),
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
}
instance := &vm.VM{
ID: "i-test-stopped",
Status: vm.StateStopped,
}
d.Instances.VMS[instance.ID] = instance
// Should return without action (no panic, no state change)
d.handleInstanceCrash(instance, fmt.Errorf("test error"))
assert.Equal(t, vm.StateStopped, instance.Status)
}
func TestHandleInstanceCrash_SkipsShuttingDown(t *testing.T) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
defer nc.Close()
d := &Daemon{
natsConn: nc,
resourceMgr: NewResourceManager(),
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
}
d.shuttingDown.Store(true)
instance := &vm.VM{
ID: "i-test-shutdown",
Status: vm.StateRunning,
}
d.Instances.VMS[instance.ID] = instance
d.handleInstanceCrash(instance, fmt.Errorf("test error"))
// Status unchanged because shuttingDown is true
assert.Equal(t, vm.StateRunning, instance.Status)
}
func TestMaybeRestart_ExceedsMaxInWindow(t *testing.T) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
defer nc.Close()
d := &Daemon{
node: "test-node",
natsConn: nc,
config: &config.Config{
Services: []string{"daemon"},
},
resourceMgr: NewResourceManager(),
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
}
// Find an allocatable instance type
var allocType string
for typeName := range d.resourceMgr.instanceTypes {
allocType = typeName
break
}
require.NotEmpty(t, allocType)
instance := &vm.VM{
ID: "i-test-maxcrash",
Status: vm.StateError,
InstanceType: allocType,
Health: vm.InstanceHealthState{
CrashCount: maxRestartsInWindow + 1, // exceeds limit
FirstCrashTime: time.Now(),
RestartCount: maxRestartsInWindow,
},
}
d.Instances.VMS[instance.ID] = instance
// Should not schedule a restart (no panic, instance stays in error)
d.maybeRestartInstance(instance)
// Instance should remain in error state
assert.Equal(t, vm.StateError, instance.Status)
}
func TestMaybeRestart_ResetsAfterWindow(t *testing.T) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
defer nc.Close()
d := &Daemon{
node: "test-node",
natsConn: nc,
config: &config.Config{
Services: []string{"daemon"},
},
resourceMgr: NewResourceManager(),
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
}
// Find an allocatable instance type
var allocType string
for typeName := range d.resourceMgr.instanceTypes {
allocType = typeName
break
}
require.NotEmpty(t, allocType)
instance := &vm.VM{
ID: "i-test-windowreset",
Status: vm.StateError,
InstanceType: allocType,
Health: vm.InstanceHealthState{
CrashCount: 5,
FirstCrashTime: time.Now().Add(-restartWindow - time.Minute), // outside window
RestartCount: 5,
},
}
d.Instances.VMS[instance.ID] = instance
// maybeRestartInstance should reset counters and schedule a restart
// (restart will fail because there's no real QEMU, but counters should reset)
d.maybeRestartInstance(instance)
// Counters should be reset
assert.Equal(t, 1, instance.Health.CrashCount)
assert.Equal(t, 0, instance.Health.RestartCount)
}
func TestRestartBackoff_Exponential(t *testing.T) {
// Verify the backoff calculation logic
expected := []time.Duration{
5 * time.Second, // restart 0
10 * time.Second, // restart 1
20 * time.Second, // restart 2
40 * time.Second, // restart 3
80 * time.Second, // restart 4
120 * time.Second, // restart 5 (capped at 2min)
120 * time.Second, // restart 6 (capped at 2min)
}
for i, want := range expected {
delay := restartBackoffBase
for j := 0; j < i; j++ {
delay *= 2
if delay > restartBackoffMax {
delay = restartBackoffMax
break
}
}
assert.Equal(t, want, delay, "Backoff mismatch at restart count %d", i)
}
}
-482
View File
@@ -1,482 +0,0 @@
package daemon
import (
"encoding/json"
"errors"
"log/slog"
"strings"
"time"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
)
const (
// InstanceStateBucket is the name of the KV bucket for storing instance state
InstanceStateBucket = "hive-instance-state"
// ClusterStateBucket is the name of the KV bucket for cluster state (heartbeats, shutdown markers, service maps)
ClusterStateBucket = "hive-cluster-state"
// InstanceStatePrefix is the key prefix for per-node instance state entries
InstanceStatePrefix = "node."
// StoppedInstancePrefix is the key prefix for stopped instances in shared KV
StoppedInstancePrefix = "instance."
)
// JetStreamManager manages JetStream KV store operations for instance state
type JetStreamManager struct {
js nats.JetStreamContext
kv nats.KeyValue // hive-instance-state
clusterKV nats.KeyValue // hive-cluster-state
replicas int
}
// NewJetStreamManager creates a new JetStreamManager from a NATS connection.
// replicas specifies the number of replicas for the KV bucket (typically matches cluster node count).
func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error) {
js, err := nc.JetStream()
if err != nil {
return nil, err
}
return &JetStreamManager{
js: js,
replicas: replicas,
}, nil
}
// InitKVBucket initializes the KV bucket, creating it if it doesn't exist
func (m *JetStreamManager) InitKVBucket() error {
// Try to get the existing bucket first
kv, err := m.js.KeyValue(InstanceStateBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
// Bucket doesn't exist, create it
slog.Debug("Creating JetStream KV bucket", "bucket", InstanceStateBucket, "replicas", m.replicas)
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: InstanceStateBucket,
Description: "Hive instance state storage",
History: 1, // Only keep latest value
Replicas: m.replicas, // Replication across cluster nodes
})
if err != nil {
return err
}
} else {
return err
}
} else {
slog.Debug("Connected to existing JetStream KV bucket", "bucket", InstanceStateBucket)
}
m.kv = kv
return nil
}
// InitClusterStateBucket initializes the cluster-state KV bucket, creating it if it doesn't exist.
func (m *JetStreamManager) InitClusterStateBucket() error {
kv, err := m.js.KeyValue(ClusterStateBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
slog.Debug("Creating JetStream KV bucket", "bucket", ClusterStateBucket, "replicas", m.replicas)
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: ClusterStateBucket,
Description: "Hive cluster state (heartbeats, shutdown markers, service maps)",
History: 1,
Replicas: m.replicas,
TTL: 1 * time.Hour,
})
if err != nil {
return err
}
} else {
return err
}
} else {
slog.Debug("Connected to existing JetStream KV bucket", "bucket", ClusterStateBucket)
}
m.clusterKV = kv
return nil
}
// Heartbeat represents a daemon's periodic health status published to cluster KV.
type Heartbeat struct {
Node string `json:"node"`
Epoch uint64 `json:"epoch"`
Timestamp string `json:"timestamp"`
Services []string `json:"services"`
VMCount int `json:"vm_count"`
AllocatedVCPU int `json:"allocated_vcpu"`
AvailableVCPU int `json:"available_vcpu"`
AllocatedMem float64 `json:"allocated_mem_gb"`
AvailableMem float64 `json:"available_mem_gb"`
}
// WriteHeartbeat writes a heartbeat entry for the given node to the cluster-state KV.
func (m *JetStreamManager) WriteHeartbeat(h *Heartbeat) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
data, err := json.Marshal(h)
if err != nil {
return err
}
_, err = m.clusterKV.Put("heartbeat."+h.Node, data)
return err
}
// ReadHeartbeat reads the heartbeat entry for the given node from the cluster-state KV.
func (m *JetStreamManager) ReadHeartbeat(nodeID string) (*Heartbeat, error) {
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
entry, err := m.clusterKV.Get("heartbeat." + nodeID)
if err != nil {
return nil, err
}
var h Heartbeat
if err := json.Unmarshal(entry.Value(), &h); err != nil {
return nil, err
}
return &h, nil
}
// ClusterShutdownState tracks the coordinated cluster shutdown progress in KV.
type ClusterShutdownState struct {
Initiator string `json:"initiator"`
Phase string `json:"phase"`
Started string `json:"started"`
Timeout string `json:"timeout"`
Force bool `json:"force"`
NodesTotal int `json:"nodes_total"`
NodesAcked map[string]string `json:"nodes_acked"`
}
// WriteClusterShutdown writes the cluster shutdown state to KV.
func (m *JetStreamManager) WriteClusterShutdown(state *ClusterShutdownState) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
_, err = m.clusterKV.Put("cluster.shutdown", data)
return err
}
// ReadClusterShutdown reads the cluster shutdown state from KV.
func (m *JetStreamManager) ReadClusterShutdown() (*ClusterShutdownState, error) {
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
entry, err := m.clusterKV.Get("cluster.shutdown")
if err != nil {
return nil, err
}
var state ClusterShutdownState
if err := json.Unmarshal(entry.Value(), &state); err != nil {
return nil, err
}
return &state, nil
}
// DeleteClusterShutdown removes the cluster shutdown state from KV.
func (m *JetStreamManager) DeleteClusterShutdown() error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
err := m.clusterKV.Delete("cluster.shutdown")
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
return nil
}
// WriteShutdownMarker writes a shutdown marker for the given node to the cluster-state KV.
func (m *JetStreamManager) WriteShutdownMarker(nodeID string) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
data, _ := json.Marshal(map[string]any{
"node": nodeID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
_, err := m.clusterKV.Put("shutdown."+nodeID, data)
return err
}
// ReadShutdownMarker checks if a clean shutdown marker exists for the given node.
func (m *JetStreamManager) ReadShutdownMarker(nodeID string) (bool, error) {
if m.clusterKV == nil {
return false, errors.New("cluster state KV not initialized")
}
_, err := m.clusterKV.Get("shutdown." + nodeID)
if errors.Is(err, nats.ErrKeyNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// DeleteShutdownMarker removes the shutdown marker for the given node.
func (m *JetStreamManager) DeleteShutdownMarker(nodeID string) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
err := m.clusterKV.Delete("shutdown." + nodeID)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
return nil
}
// WriteServiceManifest writes the service manifest for the given node to the cluster-state KV.
func (m *JetStreamManager) WriteServiceManifest(nodeID string, services []string, natsHost, predastoreHost string) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
data, _ := json.Marshal(map[string]any{
"node": nodeID,
"services": services,
"nats_host": natsHost,
"predastore_host": predastoreHost,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
_, err := m.clusterKV.Put("node."+nodeID+".services", data)
return err
}
// WriteState writes the instance state to the KV store for the given node.
// It acquires instances.Mu internally.
func (m *JetStreamManager) WriteState(nodeID string, instances *vm.Instances) error {
instances.Mu.Lock()
defer instances.Mu.Unlock()
if m.kv == nil {
return errors.New("KV bucket not initialized")
}
// Create a struct without the mutex to avoid copying the lock
state := struct {
VMS map[string]*vm.VM `json:"vms"`
}{
VMS: instances.VMS,
}
jsonData, err := json.Marshal(state)
if err != nil {
return err
}
key := InstanceStatePrefix + nodeID
_, err = m.kv.Put(key, jsonData)
if err != nil {
return err
}
slog.Debug("Wrote state to JetStream KV", "key", key, "instances", len(instances.VMS))
return nil
}
// LoadState loads the instance state from the KV store for the given node
func (m *JetStreamManager) LoadState(nodeID string) (*vm.Instances, error) {
if m.kv == nil {
return nil, errors.New("KV bucket not initialized")
}
key := InstanceStatePrefix + nodeID
entry, err := m.kv.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
// Key doesn't exist, return empty state
slog.Debug("No existing state in JetStream KV, returning empty state", "key", key)
return &vm.Instances{VMS: make(map[string]*vm.VM)}, nil
}
return nil, err
}
var instances vm.Instances
if err := json.Unmarshal(entry.Value(), &instances); err != nil {
return nil, err
}
// Ensure the VMS map is initialized
if instances.VMS == nil {
instances.VMS = make(map[string]*vm.VM)
}
slog.Debug("Loaded state from JetStream KV", "key", key, "instances", len(instances.VMS))
return &instances, nil
}
// DeleteState removes the instance state from the KV store for the given node
func (m *JetStreamManager) DeleteState(nodeID string) error {
if m.kv == nil {
return errors.New("KV bucket not initialized")
}
key := InstanceStatePrefix + nodeID
err := m.kv.Delete(key)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
slog.Debug("Deleted state from JetStream KV", "key", key)
return nil
}
// UpdateReplicas updates the replica count for the KV bucket's underlying stream.
// This should be called when new nodes join the cluster.
// Note: Increasing replicas requires the new replica count of NATS servers to be available.
func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
if m.js == nil {
return errors.New("JetStream context not initialized")
}
// KV buckets are backed by streams with name "KV_<bucket>"
streamName := "KV_" + InstanceStateBucket
// Get current stream info
streamInfo, err := m.js.StreamInfo(streamName)
if err != nil {
return err
}
currentReplicas := streamInfo.Config.Replicas
if currentReplicas >= newReplicas {
slog.Debug("Stream already has sufficient replicas", "current", currentReplicas, "requested", newReplicas)
return nil
}
// Update the stream config with new replica count
streamInfo.Config.Replicas = newReplicas
_, err = m.js.UpdateStream(&streamInfo.Config)
if err != nil {
return err
}
m.replicas = newReplicas
slog.Info("Updated JetStream KV bucket replicas", "bucket", InstanceStateBucket, "oldReplicas", currentReplicas, "newReplicas", newReplicas)
// Also update the cluster-state bucket if it exists
if m.clusterKV != nil {
clusterStreamName := "KV_" + ClusterStateBucket
clusterInfo, err := m.js.StreamInfo(clusterStreamName)
if err == nil && clusterInfo.Config.Replicas < newReplicas {
clusterInfo.Config.Replicas = newReplicas
if _, err := m.js.UpdateStream(&clusterInfo.Config); err != nil {
slog.Warn("Failed to update cluster-state bucket replicas", "error", err)
} else {
slog.Info("Updated cluster-state bucket replicas", "bucket", ClusterStateBucket, "newReplicas", newReplicas)
}
}
}
return nil
}
// WriteStoppedInstance writes a stopped instance to the shared KV store.
func (m *JetStreamManager) WriteStoppedInstance(instanceID string, instance *vm.VM) error {
if m.kv == nil {
return errors.New("KV bucket not initialized")
}
jsonData, err := json.Marshal(instance)
if err != nil {
return err
}
key := StoppedInstancePrefix + instanceID
_, err = m.kv.Put(key, jsonData)
if err != nil {
return err
}
slog.Debug("Wrote stopped instance to JetStream KV", "key", key, "instanceId", instanceID)
return nil
}
// LoadStoppedInstance loads a stopped instance from the shared KV store.
// Returns nil, nil if the key does not exist.
func (m *JetStreamManager) LoadStoppedInstance(instanceID string) (*vm.VM, error) {
if m.kv == nil {
return nil, errors.New("KV bucket not initialized")
}
key := StoppedInstancePrefix + instanceID
entry, err := m.kv.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, nil
}
return nil, err
}
var instance vm.VM
if err := json.Unmarshal(entry.Value(), &instance); err != nil {
return nil, err
}
return &instance, nil
}
// DeleteStoppedInstance removes a stopped instance from the shared KV store.
// It is idempotent — deleting a non-existent key is not an error.
func (m *JetStreamManager) DeleteStoppedInstance(instanceID string) error {
if m.kv == nil {
return errors.New("KV bucket not initialized")
}
key := StoppedInstancePrefix + instanceID
err := m.kv.Delete(key)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
return err
}
slog.Debug("Deleted stopped instance from JetStream KV", "key", key)
return nil
}
// ListStoppedInstances returns all stopped instances from the shared KV store.
func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
if m.kv == nil {
return nil, errors.New("KV bucket not initialized")
}
keys, err := m.kv.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return nil, nil
}
return nil, err
}
var instances []*vm.VM
for _, key := range keys {
if !strings.HasPrefix(key, StoppedInstancePrefix) {
continue
}
entry, err := m.kv.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
continue
}
return nil, err
}
var instance vm.VM
if err := json.Unmarshal(entry.Value(), &instance); err != nil {
slog.Error("Failed to unmarshal stopped instance", "key", key, "err", err)
continue
}
instances = append(instances, &instance)
}
return instances, nil
}
-573
View File
@@ -1,573 +0,0 @@
package daemon
import (
"testing"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestJetStreamManager_WriteAndLoadState tests round-trip write and load of instance state
func TestJetStreamManager_WriteAndLoadState(t *testing.T) {
natsURL := sharedJSNATSURL
// Connect to NATS
nc, err := nats.Connect(natsURL)
require.NoError(t, err, "Failed to connect to NATS")
defer nc.Close()
// Create JetStreamManager
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err, "Failed to create JetStreamManager")
// Initialize the KV bucket
err = jsm.InitKVBucket()
require.NoError(t, err, "Failed to init KV bucket")
// Create test instances
testNodeID := "test-node-1"
testInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-test-001": {
ID: "i-test-001",
Status: vm.StateRunning,
InstanceType: "t3.micro",
},
"i-test-002": {
ID: "i-test-002",
Status: vm.StateStopped,
InstanceType: "t3.small",
},
},
}
// Write state
err = jsm.WriteState(testNodeID, testInstances)
require.NoError(t, err, "Failed to write state")
// Load state
loadedInstances, err := jsm.LoadState(testNodeID)
require.NoError(t, err, "Failed to load state")
require.NotNil(t, loadedInstances, "Loaded instances should not be nil")
// Verify the loaded state matches
assert.Len(t, loadedInstances.VMS, 2, "Should have 2 instances")
assert.NotNil(t, loadedInstances.VMS["i-test-001"], "Should have i-test-001")
assert.NotNil(t, loadedInstances.VMS["i-test-002"], "Should have i-test-002")
assert.Equal(t, vm.StateRunning, loadedInstances.VMS["i-test-001"].Status)
assert.Equal(t, vm.StateStopped, loadedInstances.VMS["i-test-002"].Status)
assert.Equal(t, "t3.micro", loadedInstances.VMS["i-test-001"].InstanceType)
assert.Equal(t, "t3.small", loadedInstances.VMS["i-test-002"].InstanceType)
}
// TestJetStreamManager_LoadState_KeyNotFound tests that LoadState returns empty state when key doesn't exist
func TestJetStreamManager_LoadState_KeyNotFound(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Load state for a non-existent node
instances, err := jsm.LoadState("non-existent-node")
require.NoError(t, err, "Should not error when key not found")
require.NotNil(t, instances, "Should return non-nil instances")
assert.Empty(t, instances.VMS, "Should return empty VMS map")
}
// TestJetStreamManager_BucketCreation tests that InitKVBucket creates the bucket when it doesn't exist
func TestJetStreamManager_BucketCreation(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
// InitKVBucket should create the bucket
err = jsm.InitKVBucket()
require.NoError(t, err, "Should create bucket without error")
// Verify the bucket exists by checking jsm.kv is set
assert.NotNil(t, jsm.kv, "KV bucket should be initialized")
}
// TestJetStreamManager_BucketReconnection tests that InitKVBucket connects to existing bucket
func TestJetStreamManager_BucketReconnection(t *testing.T) {
natsURL := sharedJSNATSURL
// First connection - create the bucket
nc1, err := nats.Connect(natsURL)
require.NoError(t, err)
jsm1, err := NewJetStreamManager(nc1, 1)
require.NoError(t, err)
err = jsm1.InitKVBucket()
require.NoError(t, err, "First InitKVBucket should succeed")
// Write some test data
testInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-persist": {
ID: "i-persist",
Status: vm.StateRunning,
},
},
}
err = jsm1.WriteState("persist-node", testInstances)
require.NoError(t, err)
nc1.Close()
// Second connection - should connect to existing bucket
nc2, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc2.Close()
jsm2, err := NewJetStreamManager(nc2, 1)
require.NoError(t, err)
err = jsm2.InitKVBucket()
require.NoError(t, err, "Second InitKVBucket should succeed (reconnect)")
// Verify data persisted
loadedInstances, err := jsm2.LoadState("persist-node")
require.NoError(t, err)
assert.NotEmpty(t, loadedInstances.VMS, "Should have persisted instances")
assert.NotNil(t, loadedInstances.VMS["i-persist"], "Should have i-persist")
assert.Equal(t, vm.StateRunning, loadedInstances.VMS["i-persist"].Status)
}
// TestJetStreamManager_DeleteState tests deleting state from the KV store
func TestJetStreamManager_DeleteState(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Write state
testNodeID := "delete-test-node"
testInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-delete-me": {
ID: "i-delete-me",
Status: vm.StateRunning,
},
},
}
err = jsm.WriteState(testNodeID, testInstances)
require.NoError(t, err)
// Verify state exists
loadedInstances, err := jsm.LoadState(testNodeID)
require.NoError(t, err)
assert.NotEmpty(t, loadedInstances.VMS)
// Delete state
err = jsm.DeleteState(testNodeID)
require.NoError(t, err, "Should delete state without error")
// Verify state is gone (should return empty state)
loadedInstances, err = jsm.LoadState(testNodeID)
require.NoError(t, err)
assert.Empty(t, loadedInstances.VMS, "Should return empty state after deletion")
}
// TestJetStreamManager_DeleteState_NonExistent tests deleting state that doesn't exist
func TestJetStreamManager_DeleteState_NonExistent(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Delete non-existent state should not error
err = jsm.DeleteState("non-existent-node")
require.NoError(t, err, "Deleting non-existent state should not error")
}
// TestJetStreamManager_WriteState_UpdateExisting tests that writing state updates existing entry
func TestJetStreamManager_WriteState_UpdateExisting(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
testNodeID := "update-test-node"
// Write initial state
initialInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-initial": {
ID: "i-initial",
Status: vm.StateRunning,
},
},
}
err = jsm.WriteState(testNodeID, initialInstances)
require.NoError(t, err)
// Update state with different instances
updatedInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-initial": {
ID: "i-initial",
Status: vm.StateStopped, // Changed status
},
"i-new": { // Added new instance
ID: "i-new",
Status: vm.StateRunning,
},
},
}
err = jsm.WriteState(testNodeID, updatedInstances)
require.NoError(t, err)
// Load and verify updated state
loadedInstances, err := jsm.LoadState(testNodeID)
require.NoError(t, err)
assert.Len(t, loadedInstances.VMS, 2, "Should have 2 instances")
assert.Equal(t, vm.StateStopped, loadedInstances.VMS["i-initial"].Status, "Status should be updated")
assert.NotNil(t, loadedInstances.VMS["i-new"], "Should have new instance")
}
// TestJetStreamManager_MultipleNodes tests storing state for multiple nodes
func TestJetStreamManager_MultipleNodes(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Write state for node-1
node1Instances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-node1-001": {ID: "i-node1-001", Status: vm.StateRunning},
},
}
err = jsm.WriteState("node-1", node1Instances)
require.NoError(t, err)
// Write state for node-2
node2Instances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-node2-001": {ID: "i-node2-001", Status: vm.StateStopped},
"i-node2-002": {ID: "i-node2-002", Status: vm.StateRunning},
},
}
err = jsm.WriteState("node-2", node2Instances)
require.NoError(t, err)
// Load and verify node-1 state
loadedNode1, err := jsm.LoadState("node-1")
require.NoError(t, err)
assert.Len(t, loadedNode1.VMS, 1)
assert.NotNil(t, loadedNode1.VMS["i-node1-001"])
// Load and verify node-2 state
loadedNode2, err := jsm.LoadState("node-2")
require.NoError(t, err)
assert.Len(t, loadedNode2.VMS, 2)
assert.NotNil(t, loadedNode2.VMS["i-node2-001"])
assert.NotNil(t, loadedNode2.VMS["i-node2-002"])
// Verify node isolation - node-1 doesn't have node-2's instances
_, exists := loadedNode1.VMS["i-node2-001"]
assert.False(t, exists, "Node-1 should not have node-2's instances")
}
// TestJetStreamManager_KVNotInitialized tests error handling when KV is not initialized
func TestJetStreamManager_KVNotInitialized(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create JetStreamManager but don't call InitKVBucket
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
testInstances := &vm.Instances{VMS: make(map[string]*vm.VM)}
err = jsm.WriteState("test-node", testInstances)
assert.Error(t, err, "WriteState should error when KV not initialized")
_, err = jsm.LoadState("test-node")
assert.Error(t, err, "LoadState should error when KV not initialized")
err = jsm.DeleteState("test-node")
assert.Error(t, err, "DeleteState should error when KV not initialized")
}
// TestJetStreamManager_UpdateReplicas tests updating replica count for the KV bucket
func TestJetStreamManager_UpdateReplicas(t *testing.T) {
natsURL := sharedJSNATSURL
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create with 1 replica (typical for single node startup)
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Verify initial replica count
js, _ := nc.JetStream()
streamInfo, err := js.StreamInfo("KV_" + InstanceStateBucket)
require.NoError(t, err)
assert.Equal(t, 1, streamInfo.Config.Replicas, "Should start with 1 replica")
// Try to update to same replica count (should be a no-op)
err = jsm.UpdateReplicas(1)
assert.NoError(t, err, "Updating to same replica count should succeed")
// Note: Increasing replicas beyond 1 requires additional NATS servers in the cluster,
// which we don't have in the test environment. In a single-node test server,
// attempting to increase replicas will fail with "insufficient resources" error.
// This test verifies the basic functionality works.
}
// TestJetStreamManager_UpdateReplicas_NoInit tests UpdateReplicas when JS not initialized
func TestJetStreamManager_UpdateReplicas_NoInit(t *testing.T) {
// Test with nil JetStream context
jsm := &JetStreamManager{
js: nil,
replicas: 1,
}
err := jsm.UpdateReplicas(3)
assert.Error(t, err, "UpdateReplicas should error when JetStream not initialized")
}
// --- Stopped instance KV tests ---
// TestJetStreamManager_WriteAndLoadStoppedInstance tests round-trip write and load of a stopped instance
func TestJetStreamManager_WriteAndLoadStoppedInstance(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
testVM := &vm.VM{
ID: "i-stopped-001",
Status: vm.StateStopped,
InstanceType: "t3.micro",
LastNode: "node-1",
}
// Write stopped instance
err = jsm.WriteStoppedInstance(testVM.ID, testVM)
require.NoError(t, err)
// Load stopped instance
loaded, err := jsm.LoadStoppedInstance(testVM.ID)
require.NoError(t, err)
require.NotNil(t, loaded)
assert.Equal(t, testVM.ID, loaded.ID)
assert.Equal(t, vm.StateStopped, loaded.Status)
assert.Equal(t, "t3.micro", loaded.InstanceType)
assert.Equal(t, "node-1", loaded.LastNode)
// Cleanup
_ = jsm.DeleteStoppedInstance(testVM.ID)
}
// TestJetStreamManager_LoadStoppedInstance_NotFound tests that LoadStoppedInstance returns nil for missing key
func TestJetStreamManager_LoadStoppedInstance_NotFound(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
loaded, err := jsm.LoadStoppedInstance("i-nonexistent")
require.NoError(t, err)
assert.Nil(t, loaded)
}
// TestJetStreamManager_DeleteStoppedInstance tests deleting a stopped instance (including non-existent)
func TestJetStreamManager_DeleteStoppedInstance(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
testVM := &vm.VM{
ID: "i-stopped-del",
Status: vm.StateStopped,
}
// Write then delete
err = jsm.WriteStoppedInstance(testVM.ID, testVM)
require.NoError(t, err)
err = jsm.DeleteStoppedInstance(testVM.ID)
require.NoError(t, err)
// Verify it's gone
loaded, err := jsm.LoadStoppedInstance(testVM.ID)
require.NoError(t, err)
assert.Nil(t, loaded)
// Delete non-existent should not error
err = jsm.DeleteStoppedInstance("i-does-not-exist")
require.NoError(t, err)
}
// TestJetStreamManager_ListStoppedInstances tests listing multiple stopped instances
func TestJetStreamManager_ListStoppedInstances(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Write multiple stopped instances
vms := []*vm.VM{
{ID: "i-list-001", Status: vm.StateStopped, InstanceType: "t3.micro"},
{ID: "i-list-002", Status: vm.StateStopped, InstanceType: "t3.small"},
{ID: "i-list-003", Status: vm.StateStopped, InstanceType: "t3.medium"},
}
for _, v := range vms {
err = jsm.WriteStoppedInstance(v.ID, v)
require.NoError(t, err)
}
// List stopped instances
instances, err := jsm.ListStoppedInstances()
require.NoError(t, err)
// Should contain at least the 3 we wrote (may also contain instances from other tests)
instanceIDs := make(map[string]bool)
for _, inst := range instances {
instanceIDs[inst.ID] = true
}
assert.True(t, instanceIDs["i-list-001"])
assert.True(t, instanceIDs["i-list-002"])
assert.True(t, instanceIDs["i-list-003"])
// Cleanup
for _, v := range vms {
_ = jsm.DeleteStoppedInstance(v.ID)
}
}
// TestJetStreamManager_StoppedInstances_NoInterference tests that stopped instances don't interfere with per-node state
func TestJetStreamManager_StoppedInstances_NoInterference(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
err = jsm.InitKVBucket()
require.NoError(t, err)
// Write per-node state
nodeInstances := &vm.Instances{
VMS: map[string]*vm.VM{
"i-running-001": {ID: "i-running-001", Status: vm.StateRunning},
},
}
err = jsm.WriteState("interference-test-node", nodeInstances)
require.NoError(t, err)
// Write stopped instance
stoppedVM := &vm.VM{ID: "i-stopped-interf", Status: vm.StateStopped}
err = jsm.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
require.NoError(t, err)
// Verify per-node state is unaffected
loaded, err := jsm.LoadState("interference-test-node")
require.NoError(t, err)
assert.Len(t, loaded.VMS, 1)
assert.NotNil(t, loaded.VMS["i-running-001"])
// Verify stopped instance loads independently
loadedStopped, err := jsm.LoadStoppedInstance(stoppedVM.ID)
require.NoError(t, err)
require.NotNil(t, loadedStopped)
assert.Equal(t, "i-stopped-interf", loadedStopped.ID)
// Cleanup
_ = jsm.DeleteStoppedInstance(stoppedVM.ID)
_ = jsm.DeleteState("interference-test-node")
}
// TestJetStreamManager_StoppedInstance_KVNotInitialized tests error handling when KV is not initialized
func TestJetStreamManager_StoppedInstance_KVNotInitialized(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
// Don't call InitKVBucket
err = jsm.WriteStoppedInstance("i-test", &vm.VM{})
assert.Error(t, err)
_, err = jsm.LoadStoppedInstance("i-test")
assert.Error(t, err)
err = jsm.DeleteStoppedInstance("i-test")
assert.Error(t, err)
_, err = jsm.ListStoppedInstances()
assert.Error(t, err)
}
-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
}
-262
View File
@@ -1,262 +0,0 @@
package gateway
import (
"crypto/subtle"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/predastore/auth"
)
const (
// Maximum allowed clock skew for signature validation (5 minutes)
maxClockSkew = 5 * time.Minute
)
// SigV4AuthMiddleware returns a Fiber middleware that validates AWS Signature V4 authentication.
// It verifies the Authorization header against the configured credentials.
func (gw *GatewayConfig) SigV4AuthMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Skip OPTIONS requests (CORS preflight)
if c.Method() == "OPTIONS" {
return c.Next()
}
authHeader := c.Get("Authorization")
if authHeader == "" {
return gw.writeSigV4Error(c, awserrors.ErrorMissingAuthenticationToken)
}
// Parse the Authorization header
// Format: AWS4-HMAC-SHA256 Credential=ACCESS_KEY/DATE/REGION/SERVICE/aws4_request, SignedHeaders=..., Signature=...
if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256 ") {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Extract components from the Authorization header
parts := strings.Split(authHeader[len("AWS4-HMAC-SHA256 "):], ", ")
if len(parts) != 3 {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Parse Credential
var accessKey, date, region, service string
if after, ok := strings.CutPrefix(parts[0], "Credential="); ok {
credParts := strings.Split(after, "/")
if len(credParts) != 5 || credParts[4] != "aws4_request" {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
accessKey = credParts[0]
date = credParts[1]
region = credParts[2]
service = credParts[3]
} else {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Parse SignedHeaders
var signedHeaders string
if after, ok := strings.CutPrefix(parts[1], "SignedHeaders="); ok {
signedHeaders = after
} else {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Parse Signature
var providedSignature string
if after, ok := strings.CutPrefix(parts[2], "Signature="); ok {
providedSignature = after
} else {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Verify access key matches configured credentials
fmt.Println("accessKey", accessKey, "gw.AccessKey", gw.AccessKey)
if accessKey != gw.AccessKey {
slog.Debug("Invalid access key", "provided", accessKey, "expected", gw.AccessKey)
return gw.writeSigV4Error(c, awserrors.ErrorInvalidClientTokenId)
}
// Get timestamp from X-Amz-Date header
timestamp := c.Get("X-Amz-Date")
if timestamp == "" {
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
// Validate timestamp is within acceptable bounds to prevent replay attacks
parsedTime, err := time.Parse("20060102T150405Z", timestamp)
if err != nil {
slog.Debug("Invalid timestamp format", "timestamp", timestamp)
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
}
if time.Since(parsedTime).Abs() > maxClockSkew {
slog.Debug("Signature expired", "timestamp", timestamp, "skew", time.Since(parsedTime))
return gw.writeSigV4Error(c, awserrors.ErrorSignatureDoesNotMatch)
}
// Compute expected signature
expectedSignature := gw.computeSignature(c, date, timestamp, region, service, signedHeaders)
// Compare signatures using constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(providedSignature)) != 1 {
slog.Debug("Signature mismatch",
"expected", expectedSignature,
"provided", providedSignature,
)
return gw.writeSigV4Error(c, awserrors.ErrorSignatureDoesNotMatch)
}
// Store parsed auth data in context for downstream handlers
c.Locals("sigv4.service", service)
c.Locals("sigv4.region", region)
c.Locals("sigv4.accessKey", accessKey)
slog.Debug("SigV4 authentication successful", "accessKey", accessKey)
return c.Next()
}
}
// computeSignature builds the canonical request and computes the AWS Signature V4 signature.
func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region, service, signedHeaders string) string {
// Build canonical URI (URI-encoded path)
path := c.Path()
if path == "" {
path = "/"
}
canonicalURI := auth.UriEncode(path, false)
if canonicalURI == "" {
canonicalURI = "/"
}
// Build canonical query string (sorted, encoded)
canonicalQueryString := buildCanonicalQueryString(string(c.Request().URI().QueryString()))
// Build canonical headers from SignedHeaders list
headersList := strings.Split(signedHeaders, ";")
sort.Strings(headersList)
var canonicalHeaders strings.Builder
for _, header := range headersList {
header = strings.ToLower(strings.TrimSpace(header))
var value string
if header == "host" {
value = string(c.Request().Host())
} else {
value = c.Get(canonicalHeaderName(header))
}
// Trim leading/trailing whitespace and collapse multiple spaces
value = strings.TrimSpace(value)
canonicalHeaders.WriteString(header)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(value)
canonicalHeaders.WriteString("\n")
}
// Hash payload body with SHA256
payloadHash := auth.HashSHA256(string(c.Body()))
// Build canonical request
canonicalRequest := fmt.Sprintf(
"%s\n%s\n%s\n%s\n%s\n%s",
c.Method(),
canonicalURI,
canonicalQueryString,
canonicalHeaders.String(),
signedHeaders,
payloadHash,
)
hashedCanonicalRequest := auth.HashSHA256(canonicalRequest)
// Build string-to-sign
scope := fmt.Sprintf("%s/%s/%s/aws4_request", date, region, service)
stringToSign := fmt.Sprintf(
"AWS4-HMAC-SHA256\n%s\n%s\n%s",
timestamp,
scope,
hashedCanonicalRequest,
)
// Derive signing key and compute signature
signingKey := auth.GetSigningKey(gw.SecretKey, date, region, service)
signature := auth.HmacSHA256Hex(signingKey, stringToSign)
return signature
}
// buildCanonicalQueryString creates the canonical query string according to AWS specs.
func buildCanonicalQueryString(queryString string) string {
if queryString == "" {
return ""
}
// Parse query parameters
params := make(map[string][]string)
pairs := strings.SplitSeq(queryString, "&")
for pair := range pairs {
if pair == "" {
continue
}
kv := strings.SplitN(pair, "=", 2)
key := kv[0]
value := ""
if len(kv) == 2 {
value = kv[1]
}
params[key] = append(params[key], value)
}
// Sort keys
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
// Build canonical query string
var result []string
for _, key := range keys {
values := params[key]
sort.Strings(values)
encodedKey := auth.UriEncode(key, true)
for _, v := range values {
encodedValue := auth.UriEncode(v, true)
result = append(result, fmt.Sprintf("%s=%s", encodedKey, encodedValue))
}
}
return strings.Join(result, "&")
}
// canonicalHeaderName converts a lowercase header name to the canonical form for lookup.
func canonicalHeaderName(header string) string {
// Convert header names like "x-amz-date" to "X-Amz-Date" for Fiber's Get method
parts := strings.Split(header, "-")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "-")
}
// writeSigV4Error writes an EC2-compatible XML error response for authentication failures.
func (gw *GatewayConfig) writeSigV4Error(c *fiber.Ctx, errorCode string) error {
requestID := c.Get("x-amz-request-id", uuid.NewString())
errorMsg, exists := awserrors.ErrorLookup[errorCode]
if !exists {
errorMsg = awserrors.ErrorMessage{HTTPCode: 500, Message: "Internal error"}
}
xmlError := GenerateEC2ErrorResponse(errorCode, errorMsg.Message, requestID)
c.Set("Content-Type", "application/xml")
return c.Status(errorMsg.HTTPCode).Send(xmlError)
}
-381
View File
@@ -1,381 +0,0 @@
package gateway
import (
"fmt"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/predastore/auth"
)
const (
testAccessKey = "AKIAIOSFODNN7EXAMPLE"
testSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
testRegion = "us-east-1"
testService = "ec2"
)
// generateTestAuthHeader creates a valid AWS SigV4 Authorization header for testing.
func generateTestAuthHeader(method, path, queryString, body, accessKey, secretKey, region, service string) (authHeader, timestamp string) {
now := time.Now().UTC()
timestamp = now.Format(auth.TimeFormat)
date := now.Format(auth.ShortTimeFormat)
// Build canonical URI
canonicalURI := auth.UriEncode(path, false)
if canonicalURI == "" {
canonicalURI = "/"
}
// Build canonical query string
canonicalQueryString := buildCanonicalQueryString(queryString)
// Build canonical headers
host := "localhost:9999"
canonicalHeaders := fmt.Sprintf("host:%s\nx-amz-date:%s\n", host, timestamp)
signedHeaders := "host;x-amz-date"
// Hash payload
payloadHash := auth.HashSHA256(body)
// Build canonical request
canonicalRequest := fmt.Sprintf(
"%s\n%s\n%s\n%s\n%s\n%s",
method,
canonicalURI,
canonicalQueryString,
canonicalHeaders,
signedHeaders,
payloadHash,
)
hashedCanonicalRequest := auth.HashSHA256(canonicalRequest)
// Build string-to-sign
scope := fmt.Sprintf("%s/%s/%s/aws4_request", date, region, service)
stringToSign := fmt.Sprintf(
"AWS4-HMAC-SHA256\n%s\n%s\n%s",
timestamp,
scope,
hashedCanonicalRequest,
)
// Derive signing key and compute signature
signingKey := auth.GetSigningKey(secretKey, date, region, service)
signature := auth.HmacSHA256Hex(signingKey, stringToSign)
// Build Authorization header
authHeader = fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s/%s/%s/aws4_request, SignedHeaders=%s, Signature=%s",
accessKey,
date,
region,
service,
signedHeaders,
signature,
)
return authHeader, timestamp
}
func setupTestApp(accessKey, secretKey string) *fiber.App {
gw := &GatewayConfig{
DisableLogging: true,
AccessKey: accessKey,
SecretKey: secretKey,
Region: testRegion,
}
app := fiber.New()
app.Use(gw.SigV4AuthMiddleware())
// Simple test handler
app.All("/*", func(c *fiber.Ctx) error {
return c.SendString("OK")
})
return app
}
func TestSigV4Auth_NoAuthorizationHeader(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
req := httptest.NewRequest("GET", "/", nil)
req.Host = "localhost:9999"
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 403 {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "MissingAuthenticationToken") {
t.Errorf("Expected MissingAuthenticationToken error, got: %s", string(body))
}
}
func TestSigV4Auth_MalformedHeader(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
testCases := []struct {
name string
authHeader string
}{
{"empty prefix", "InvalidPrefix Credential=test"},
{"missing parts", "AWS4-HMAC-SHA256 Credential=test"},
{"invalid credential format", "AWS4-HMAC-SHA256 Credential=a/b/c, SignedHeaders=host, Signature=sig"},
{"missing SignedHeaders prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, Headers=host, Signature=sig"},
{"missing Signature prefix", "AWS4-HMAC-SHA256 Credential=a/b/c/d/aws4_request, SignedHeaders=host, Sig=abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", tc.authHeader)
req.Header.Set("X-Amz-Date", time.Now().UTC().Format(auth.TimeFormat))
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 400 {
t.Errorf("Expected status 400, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "IncompleteSignature") {
t.Errorf("Expected IncompleteSignature error, got: %s", string(body))
}
})
}
}
func TestSigV4Auth_InvalidAccessKey(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
authHeader, timestamp := generateTestAuthHeader(
"GET", "/", "", "",
"INVALID_ACCESS_KEY", testSecretKey, testRegion, testService,
)
req := httptest.NewRequest("GET", "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 403 {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "InvalidClientTokenId") {
t.Errorf("Expected InvalidClientTokenId error, got: %s", string(body))
}
}
func TestSigV4Auth_InvalidSignature(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
// Generate auth header with wrong secret key
authHeader, timestamp := generateTestAuthHeader(
"GET", "/", "", "",
testAccessKey, "WRONG_SECRET_KEY", testRegion, testService,
)
req := httptest.NewRequest("GET", "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 403 {
t.Errorf("Expected status 403, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "SignatureDoesNotMatch") {
t.Errorf("Expected SignatureDoesNotMatch error, got: %s", string(body))
}
}
func TestSigV4Auth_ValidSignature(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
authHeader, timestamp := generateTestAuthHeader(
"GET", "/", "", "",
testAccessKey, testSecretKey, testRegion, testService,
)
req := httptest.NewRequest("GET", "/", nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
t.Errorf("Expected status 200, got %d, body: %s", resp.StatusCode, string(body))
}
}
func TestSigV4Auth_OptionsSkipsAuth(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
req := httptest.NewRequest("OPTIONS", "/", nil)
req.Host = "localhost:9999"
// No Authorization header
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("Expected status 200 for OPTIONS, got %d", resp.StatusCode)
}
}
func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
body := "Action=DescribeInstances&Version=2016-11-15"
authHeader, timestamp := generateTestAuthHeader(
"POST", "/", "", body,
testAccessKey, testSecretKey, testRegion, testService,
)
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Host = "localhost:9999"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Errorf("Expected status 200, got %d, body: %s", resp.StatusCode, string(respBody))
}
}
func TestSigV4Auth_ValidSignatureWithQueryString(t *testing.T) {
app := setupTestApp(testAccessKey, testSecretKey)
queryString := "Action=DescribeInstances&Version=2016-11-15"
authHeader, timestamp := generateTestAuthHeader(
"GET", "/", queryString, "",
testAccessKey, testSecretKey, testRegion, testService,
)
req := httptest.NewRequest("GET", "/?"+queryString, nil)
req.Host = "localhost:9999"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("Failed to test request: %v", err)
}
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Errorf("Expected status 200, got %d, body: %s", resp.StatusCode, string(respBody))
}
}
func TestParseAWSQueryArgs_URLDecoding(t *testing.T) {
testCases := []struct {
name string
input string
key string
expected string
}{
{"plain value", "Action=DescribeInstances", "Action", "DescribeInstances"},
{"encoded slashes", "Device=%2Fdev%2Fsdf", "Device", "/dev/sdf"},
{"encoded spaces", "Name=my%20volume", "Name", "my volume"},
{"encoded plus as space", "Name=my+volume", "Name", "my volume"},
{"no encoding needed", "VolumeId=vol-abc123", "VolumeId", "vol-abc123"},
{"multiple params", "VolumeId=vol-abc&Device=%2Fdev%2Fsdg", "Device", "/dev/sdg"},
{"encoded key dot", "Tag%2EKey=Name", "Tag.Key", "Name"},
{"encoded key and value", "Filter%2E1%2EName=instance-id&Filter%2E1%2EValue=i-abc", "Filter.1.Name", "instance-id"},
{"key-only encoded", "Tag%2EKey=", "Tag.Key", ""},
{"key without value", "Tag%2EKey", "Tag.Key", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ParseAWSQueryArgs(tc.input)
if result[tc.key] != tc.expected {
t.Errorf("Expected %q for key %q, got %q", tc.expected, tc.key, result[tc.key])
}
})
}
}
func TestBuildCanonicalQueryString(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{"empty", "", ""},
{"single param", "Action=Test", "Action=Test"},
{"multiple params sorted", "Version=1&Action=Test", "Action=Test&Version=1"},
{"encoded values", "Name=Hello World", "Name=Hello%20World"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := buildCanonicalQueryString(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
func TestCanonicalHeaderName(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"host", "Host"},
{"x-amz-date", "X-Amz-Date"},
{"content-type", "Content-Type"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result := canonicalHeaderName(tc.input)
if result != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, result)
}
})
}
}
-202
View File
@@ -1,202 +0,0 @@
// Package gateway provides the AWS Gateway for the Hive platform.
// It handles the incoming requests from the AWS SDK and delegates to the appropriate gateway functions (which calls the NATS handlers).
package gateway
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/hive/hive/awsec2query"
"github.com/mulgadc/hive/hive/awserrors"
gateway_ec2_account "github.com/mulgadc/hive/hive/gateway/ec2/account"
gateway_ec2_eigw "github.com/mulgadc/hive/hive/gateway/ec2/eigw"
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"
gateway_ec2_snapshot "github.com/mulgadc/hive/hive/gateway/ec2/snapshot"
gateway_ec2_tags "github.com/mulgadc/hive/hive/gateway/ec2/tags"
gateway_ec2_volume "github.com/mulgadc/hive/hive/gateway/ec2/volume"
gateway_ec2_zone "github.com/mulgadc/hive/hive/gateway/ec2/zone"
"github.com/mulgadc/hive/hive/utils"
)
// EC2Handler processes parsed query args and returns XML response bytes.
// The action parameter is the EC2 API action name, passed from the map key.
type EC2Handler func(action string, q map[string]string, gw *GatewayConfig) ([]byte, error)
// ec2Handler creates a type-safe EC2Handler that allocates the typed input struct,
// parses query params into it, calls the handler, and marshals the output to XML.
func ec2Handler[In any](handler func(*In, *GatewayConfig) (any, error)) EC2Handler {
return func(action string, q map[string]string, gw *GatewayConfig) ([]byte, error) {
input := new(In)
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
return nil, err
}
output, err := handler(input, gw)
if err != nil {
return nil, err
}
payload := utils.GenerateXMLPayload(action+"Response", output)
xmlOutput, err := utils.MarshalToXML(payload)
if err != nil {
return nil, errors.New("failed to marshal response to XML")
}
return xmlOutput, nil
}
}
var ec2Actions = map[string]EC2Handler{
"DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes())
}),
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.RunInstances(input, gw.NATSConn)
}),
"StartInstances": ec2Handler(func(input *ec2.StartInstancesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.StartInstances(input, gw.NATSConn)
}),
"StopInstances": ec2Handler(func(input *ec2.StopInstancesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.StopInstances(input, gw.NATSConn)
}),
"TerminateInstances": ec2Handler(func(input *ec2.TerminateInstancesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.TerminateInstances(input, gw.NATSConn)
}),
"DescribeInstanceTypes": ec2Handler(func(input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.DescribeInstanceTypes(input, gw.NATSConn, gw.ExpectedNodes)
}),
"GetConsoleOutput": ec2Handler(func(input *ec2.GetConsoleOutputInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.GetConsoleOutput(input, gw.NATSConn)
}),
"CreateKeyPair": ec2Handler(func(input *ec2.CreateKeyPairInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_key.CreateKeyPair(input, gw.NATSConn)
}),
"DeleteKeyPair": ec2Handler(func(input *ec2.DeleteKeyPairInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_key.DeleteKeyPair(input, gw.NATSConn)
}),
"DescribeKeyPairs": ec2Handler(func(input *ec2.DescribeKeyPairsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_key.DescribeKeyPairs(input, gw.NATSConn)
}),
"ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig) ([]byte, error) {
// Workaround: parser leaves Base64 padding URL-encoded
if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") {
q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1)
}
return ec2Handler(func(input *ec2.ImportKeyPairInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_key.ImportKeyPair(input, gw.NATSConn)
})(action, q, gw)
},
"DescribeImages": ec2Handler(func(input *ec2.DescribeImagesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_image.DescribeImages(input, gw.NATSConn)
}),
"CreateImage": ec2Handler(func(input *ec2.CreateImageInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_image.CreateImage(input, gw.NATSConn)
}),
"DescribeRegions": ec2Handler(func(input *ec2.DescribeRegionsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_zone.DescribeRegions(input, gw.Region)
}),
"DescribeAvailabilityZones": ec2Handler(func(input *ec2.DescribeAvailabilityZonesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_zone.DescribeAvailabilityZones(input, gw.Region, gw.AZ)
}),
"DescribeVolumes": ec2Handler(func(input *ec2.DescribeVolumesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.DescribeVolumes(input, gw.NATSConn)
}),
"ModifyVolume": ec2Handler(func(input *ec2.ModifyVolumeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.ModifyVolume(input, gw.NATSConn)
}),
"CreateVolume": ec2Handler(func(input *ec2.CreateVolumeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.CreateVolume(input, gw.NATSConn)
}),
"DeleteVolume": ec2Handler(func(input *ec2.DeleteVolumeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.DeleteVolume(input, gw.NATSConn)
}),
"AttachVolume": ec2Handler(func(input *ec2.AttachVolumeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.AttachVolume(input, gw.NATSConn)
}),
"DescribeVolumeStatus": ec2Handler(func(input *ec2.DescribeVolumeStatusInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.DescribeVolumeStatus(input, gw.NATSConn)
}),
"DetachVolume": ec2Handler(func(input *ec2.DetachVolumeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_volume.DetachVolume(input, gw.NATSConn)
}),
"DescribeAccountAttributes": ec2Handler(func(input *ec2.DescribeAccountAttributesInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.DescribeAccountAttributes(input)
}),
"EnableEbsEncryptionByDefault": ec2Handler(func(input *ec2.EnableEbsEncryptionByDefaultInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.EnableEbsEncryptionByDefault(input, gw.NATSConn)
}),
"DisableEbsEncryptionByDefault": ec2Handler(func(input *ec2.DisableEbsEncryptionByDefaultInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.DisableEbsEncryptionByDefault(input, gw.NATSConn)
}),
"GetEbsEncryptionByDefault": ec2Handler(func(input *ec2.GetEbsEncryptionByDefaultInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.GetEbsEncryptionByDefault(input, gw.NATSConn)
}),
"GetSerialConsoleAccessStatus": ec2Handler(func(input *ec2.GetSerialConsoleAccessStatusInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.GetSerialConsoleAccessStatus(input, gw.NATSConn)
}),
"EnableSerialConsoleAccess": ec2Handler(func(input *ec2.EnableSerialConsoleAccessInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.EnableSerialConsoleAccess(input, gw.NATSConn)
}),
"DisableSerialConsoleAccess": ec2Handler(func(input *ec2.DisableSerialConsoleAccessInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_account.DisableSerialConsoleAccess(input, gw.NATSConn)
}),
"CreateTags": ec2Handler(func(input *ec2.CreateTagsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_tags.CreateTags(input, gw.NATSConn)
}),
"DeleteTags": ec2Handler(func(input *ec2.DeleteTagsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_tags.DeleteTags(input, gw.NATSConn)
}),
"DescribeTags": ec2Handler(func(input *ec2.DescribeTagsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_tags.DescribeTags(input, gw.NATSConn)
}),
"CreateSnapshot": ec2Handler(func(input *ec2.CreateSnapshotInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_snapshot.CreateSnapshot(input, gw.NATSConn)
}),
"DeleteSnapshot": ec2Handler(func(input *ec2.DeleteSnapshotInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_snapshot.DeleteSnapshot(input, gw.NATSConn)
}),
"DescribeSnapshots": ec2Handler(func(input *ec2.DescribeSnapshotsInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_snapshot.DescribeSnapshots(input, gw.NATSConn)
}),
"CopySnapshot": ec2Handler(func(input *ec2.CopySnapshotInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_snapshot.CopySnapshot(input, gw.NATSConn)
}),
"CreateEgressOnlyInternetGateway": ec2Handler(func(input *ec2.CreateEgressOnlyInternetGatewayInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_eigw.CreateEgressOnlyInternetGateway(input, gw.NATSConn)
}),
"DeleteEgressOnlyInternetGateway": ec2Handler(func(input *ec2.DeleteEgressOnlyInternetGatewayInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_eigw.DeleteEgressOnlyInternetGateway(input, gw.NATSConn)
}),
"DescribeEgressOnlyInternetGateways": ec2Handler(func(input *ec2.DescribeEgressOnlyInternetGatewaysInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_eigw.DescribeEgressOnlyInternetGateways(input, gw.NATSConn)
}),
}
// ec2LocalActions are actions that don't require a NATS connection.
var ec2LocalActions = map[string]bool{
"DescribeRegions": true,
"DescribeAvailabilityZones": true,
"DescribeAccountAttributes": true,
}
func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
queryArgs := ParseAWSQueryArgs(string(ctx.Body()))
action := queryArgs["Action"]
handler, ok := ec2Actions[action]
if !ok {
return errors.New(awserrors.ErrorInvalidAction)
}
if gw.NATSConn == nil && !ec2LocalActions[action] {
return errors.New(awserrors.ErrorServerInternal)
}
xmlOutput, err := handler(action, queryArgs, gw)
if err != nil {
return err
}
return ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput)
}
@@ -1,95 +0,0 @@
package gateway_ec2_instance
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/qmp"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
// terminateStoppedInstanceRequest is the payload sent to the ec2.terminate topic
type terminateStoppedInstanceRequest struct {
InstanceID string `json:"instance_id"`
}
// TerminateInstances sends terminate commands to specified instances via NATS
// Uses system_powerdown with stop_instance attribute to prevent restart
func TerminateInstances(input *ec2.TerminateInstancesInput, natsConn *nats.Conn) (*ec2.TerminateInstancesOutput, error) {
if len(input.InstanceIds) == 0 {
return nil, fmt.Errorf("no instance IDs provided")
}
slog.Info("TerminateInstances: Processing request", "instance_count", len(input.InstanceIds))
var stateChanges []*ec2.InstanceStateChange
// Process each instance
for _, instanceIDPtr := range input.InstanceIds {
if instanceIDPtr == nil {
continue
}
instanceID := *instanceIDPtr
// Build the QMP command to terminate the instance
// Note: stop_instance=true prevents restart on daemon/node restart
command := qmp.Command{
ID: instanceID,
QMPCommand: qmp.QMPCommand{
Execute: "system_powerdown",
Arguments: map[string]any{},
},
Attributes: qmp.Attributes{
StopInstance: true, // Prevent restart on daemon/node restart
TerminateInstance: true,
},
}
// Marshal the command
jsonData, err := json.Marshal(command)
if err != nil {
slog.Error("TerminateInstances: Failed to marshal command", "instance_id", instanceID, "err", err)
continue
}
// Send NATS request to the specific instance topic
subject := fmt.Sprintf("ec2.cmd.%s", instanceID)
msg, err := natsConn.Request(subject, jsonData, 5*time.Second)
if err != nil {
// If no daemon owns this instance, try the ec2.terminate topic for stopped instances
if errors.Is(err, nats.ErrNoResponders) {
slog.Info("TerminateInstances: No responder on per-instance topic, trying ec2.terminate", "instance_id", instanceID)
terminateReq, _ := json.Marshal(terminateStoppedInstanceRequest{InstanceID: instanceID})
terminateMsg, terminateErr := natsConn.Request("ec2.terminate", terminateReq, 30*time.Second)
if terminateErr == nil {
if _, parseErr := utils.ValidateErrorPayload(terminateMsg.Data); parseErr == nil {
slog.Info("TerminateInstances: Stopped instance terminated via ec2.terminate", "instance_id", instanceID)
stateChanges = append(stateChanges, newStateChange(instanceID, 32, "shutting-down", 80, "stopped"))
continue
}
}
}
slog.Error("TerminateInstances: Failed to send command", "instance_id", instanceID, "err", err)
stateChanges = append(stateChanges, newStateChange(instanceID, 16, "running", 16, "running"))
continue
}
slog.Info("TerminateInstances: Command sent successfully", "instance_id", instanceID, "response", string(msg.Data))
stateChanges = append(stateChanges, newStateChange(instanceID, 32, "shutting-down", 16, "running"))
}
output := &ec2.TerminateInstancesOutput{
TerminatingInstances: stateChanges,
}
slog.Info("TerminateInstances: Completed", "total_instances", len(stateChanges))
return output, nil
}
-334
View File
@@ -1,334 +0,0 @@
package gateway
import (
"encoding/json"
"encoding/xml"
"errors"
"log/slog"
"net/url"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/google/uuid"
"github.com/mulgadc/hive/hive/awsec2query"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/nats-io/nats.go"
)
type GatewayConfig struct {
Debug bool `json:"debug"`
DisableLogging bool `json:"disable_logging"`
NATSConn *nats.Conn // Shared NATS connection for service communication
Config string // Shared AWS Gateway config for S3 auth
ExpectedNodes int // Number of expected hive nodes for multi-node operations
Region string // Region this gateway is running in
AZ string // Availability zone this gateway is running in
AccessKey string // AWS Access Key ID for authentication
SecretKey string // AWS Secret Access Key for authentication
}
var supportedServices = map[string]bool{
"ec2": true,
"iam": true,
"account": true,
}
type ErrorResponse struct {
XMLName xml.Name `xml:"Response"`
Errors Errors `xml:"Errors"`
RequestID string `xml:"RequestID"`
}
type Errors struct {
Error ErrorDetail `xml:"Error"`
}
type ErrorDetail struct {
Code string `xml:"Code"`
Message error `xml:"Message"`
}
func (gw *GatewayConfig) SetupRoutes() *fiber.App {
var logLevel slog.Level
if gw.Debug {
logLevel = slog.LevelDebug
} else if gw.DisableLogging {
logLevel = slog.LevelError
} else {
logLevel = slog.LevelInfo
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
// Create a new logger with the custom handler
slogger := slog.New(handler)
// Set it as the default logger
slog.SetDefault(slogger)
// Configure slog for logging
slog.New(slog.NewTextHandler(os.Stdout, nil))
app := fiber.New(fiber.Config{
// Disable the startup banner
DisableStartupMessage: gw.DisableLogging,
// Set the body limit for S3 specs to 5GiB
//BodyLimit: 5 * 1024 * 1024 * 1024,
// Override default error handler
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
return gw.ErrorHandler(ctx, err)
}},
)
if !gw.DisableLogging {
app.Use(logger.New())
}
// Add CORS middleware for browser requests
app.Use(cors.New(cors.Config{
AllowOrigins: "https://localhost:3000",
AllowMethods: "GET,POST,PUT,DELETE,HEAD,OPTIONS",
AllowHeaders: "*",
AllowCredentials: true,
}))
// Add AWS SigV4 authentication middleware
app.Use(gw.SigV4AuthMiddleware())
// Define routes
app.Get("/*", func(c *fiber.Ctx) error {
return gw.Request(c)
})
app.Post("/*", func(c *fiber.Ctx) error {
return gw.Request(c)
})
return app
}
// Note, custom endpoints can be configured via ENV vars to the AWS SDK/CLI tool, with individual endpoints depending the service
// AWS_ENDPOINT_URL_EC2=https://localhost:9999/ aws --no-verify-ssl ec2 describe-instances
// aws --endpoint-url https://localhost:9999/ --no-verify-ssl eks list-clusters
// AWS_ENDPOINT_URL=https://localhost:9999/ aws --no-verify-ssl ec2 describe-instances
func (gw *GatewayConfig) Request(ctx *fiber.Ctx) error {
// Route the request to the appropriate endpoint (e.g EC2, IAM, etc)
svc, err := gw.GetService(ctx)
slog.Info("Request", "service", svc, "method", ctx.Method(), "path", ctx.Path())
if err != nil {
slog.Error("GetService error", "error", err)
return gw.ErrorHandler(ctx, err)
}
switch svc {
case "ec2":
err = gw.EC2_Request(ctx)
case "account":
err = gw.Account_Request(ctx)
case "iam":
err = gw.IAM_Request(ctx)
default:
err = errors.New(awserrors.ErrorUnsupportedOperation)
}
if err != nil {
slog.Error("Service request error", "service", svc, "error", err)
return gw.ErrorHandler(ctx, err)
} else {
slog.Info("Service request completed", "service", svc)
}
return nil
}
func (gw *GatewayConfig) GetService(ctx *fiber.Ctx) (string, error) {
svc, ok := ctx.Locals("sigv4.service").(string)
if !ok {
return "", errors.New(awserrors.ErrorAuthFailure)
}
if !supportedServices[svc] {
slog.Debug("Unsupported service", "service", svc)
return "", errors.New(awserrors.ErrorUnsupportedOperation)
}
return svc, nil
}
func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error {
// TODO: Support service type specific errors (e.g EC2, S3, IAM, differ)
svc, _ := gw.GetService(ctx)
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
// Status code defaults to 500
// Get the request ID
var requestId = uuid.NewString()
requestId = ctx.Get("x-amz-request-id", requestId)
var errorMsg = awserrors.ErrorMessage{}
// Check if the error lookup exists
if _, exists := awserrors.ErrorLookup[err.Error()]; !exists {
slog.Warn("Unknown error code", "error", err.Error())
err = errors.New(awserrors.ErrorInternalError)
}
errorMsg = awserrors.ErrorLookup[err.Error()]
xmlError := GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
slog.Debug("Generated error response", "error", err.Error(), "xml", string(xmlError), "requestId", requestId)
if errorMsg.HTTPCode == 0 {
errorMsg.HTTPCode = 500
}
// Set standard S3 error response headers
ctx.Set("Content-Type", "application/xml")
return ctx.Status(errorMsg.HTTPCode).Send(xmlError)
}
// Parse AWS query arguments (used by some services like EC2/S3)
// Properly URL-decodes both keys and values
func ParseAWSQueryArgs(query string) map[string]string {
params := make(map[string]string)
pairs := strings.SplitSeq(query, "&")
for pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
key, _ := url.QueryUnescape(kv[0])
value, _ := url.QueryUnescape(kv[1])
params[key] = value
} else if len(kv) == 1 {
key, _ := url.QueryUnescape(kv[0])
params[key] = ""
}
}
return params
}
func GenerateEC2ErrorResponse(code, message, requestID string) (output []byte) {
errorXml := ErrorResponse{
Errors: Errors{
Error: ErrorDetail{
Code: code,
Message: errors.New(message),
},
},
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("Failed to build XML", "error", err)
return nil
}
// Add XML header
output = append([]byte(xml.Header), output...)
return output
}
func ParseArgsToStruct(input *any, args map[string]string) (err error) {
// Generated from input shape: RunInstancesRequest
err = awsec2query.QueryParamsToStruct(args, input)
if err != nil {
return errors.New(awserrors.ErrorInvalidParameter)
}
return nil
}
// NodeDiscoverResponse is the response from a node discovery request
type NodeDiscoverResponse struct {
Node string `json:"node"`
}
// DiscoverActiveNodes discovers the number of active hive daemon nodes in the cluster
// by publishing a discovery request and counting unique responses.
// Returns the number of active nodes (minimum 1 if fallback is needed).
func (gw *GatewayConfig) DiscoverActiveNodes() int {
if gw.NATSConn == nil {
slog.Warn("DiscoverActiveNodes: NATS connection not available, using ExpectedNodes fallback", "fallback", gw.ExpectedNodes)
return gw.ExpectedNodes
}
// Create an inbox for collecting responses from all nodes
inbox := nats.NewInbox()
sub, err := gw.NATSConn.SubscribeSync(inbox)
if err != nil {
slog.Error("DiscoverActiveNodes: Failed to create inbox subscription", "err", err)
return gw.ExpectedNodes
}
defer sub.Unsubscribe()
// Publish discovery request to all nodes
err = gw.NATSConn.PublishRequest("hive.nodes.discover", inbox, []byte("{}"))
if err != nil {
slog.Error("DiscoverActiveNodes: Failed to publish request", "err", err)
return gw.ExpectedNodes
}
// Collect responses with a short timeout
// We use a short timeout since discovery should be fast
timeout := 500 * time.Millisecond
deadline := time.Now().Add(timeout)
nodesSeen := make(map[string]bool)
for time.Now().Before(deadline) {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
msg, err := sub.NextMsg(remaining)
if err != nil {
if err == nats.ErrTimeout {
break
}
slog.Debug("DiscoverActiveNodes: Error receiving message", "err", err)
break
}
var response NodeDiscoverResponse
if err := json.Unmarshal(msg.Data, &response); err != nil {
slog.Debug("DiscoverActiveNodes: Failed to unmarshal response", "err", err)
continue
}
nodesSeen[response.Node] = true
}
activeNodes := len(nodesSeen)
if activeNodes == 0 {
// Fallback to configured value if no responses
slog.Warn("DiscoverActiveNodes: No nodes responded, using ExpectedNodes fallback", "fallback", gw.ExpectedNodes)
return gw.ExpectedNodes
}
slog.Debug("DiscoverActiveNodes: Discovered active nodes", "count", activeNodes)
return activeNodes
}
-8
View File
@@ -1,8 +0,0 @@
package gateway
import "github.com/gofiber/fiber/v2"
func (gw *GatewayConfig) IAM_Request(ctx *fiber.Ctx) error {
return nil
}
@@ -1,101 +0,0 @@
package handlers_ec2_account
import (
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var _ AccountSettingsService = (*AccountSettingsServiceImpl)(nil)
func setupTestAccountService(t *testing.T) *AccountSettingsServiceImpl {
t.Helper()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
svc, err := NewAccountSettingsServiceImplWithNATS(nil, nc)
require.NoError(t, err)
return svc
}
// EBS Encryption tests
func TestEbsEncryption_DefaultOff(t *testing.T) {
svc := setupTestAccountService(t)
out, err := svc.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{})
require.NoError(t, err)
assert.False(t, *out.EbsEncryptionByDefault)
}
func TestEbsEncryption_EnableDisable(t *testing.T) {
svc := setupTestAccountService(t)
// Enable
enableOut, err := svc.EnableEbsEncryptionByDefault(&ec2.EnableEbsEncryptionByDefaultInput{})
require.NoError(t, err)
assert.True(t, *enableOut.EbsEncryptionByDefault)
// Verify
getOut, err := svc.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{})
require.NoError(t, err)
assert.True(t, *getOut.EbsEncryptionByDefault)
// Disable
disableOut, err := svc.DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{})
require.NoError(t, err)
assert.False(t, *disableOut.EbsEncryptionByDefault)
// Verify
getOut, err = svc.GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{})
require.NoError(t, err)
assert.False(t, *getOut.EbsEncryptionByDefault)
}
// Serial Console tests
func TestSerialConsole_DefaultOff(t *testing.T) {
svc := setupTestAccountService(t)
out, err := svc.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
require.NoError(t, err)
assert.False(t, *out.SerialConsoleAccessEnabled)
}
func TestSerialConsole_EnableDisable(t *testing.T) {
svc := setupTestAccountService(t)
enableOut, err := svc.EnableSerialConsoleAccess(&ec2.EnableSerialConsoleAccessInput{})
require.NoError(t, err)
assert.True(t, *enableOut.SerialConsoleAccessEnabled)
getOut, err := svc.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
require.NoError(t, err)
assert.True(t, *getOut.SerialConsoleAccessEnabled)
disableOut, err := svc.DisableSerialConsoleAccess(&ec2.DisableSerialConsoleAccessInput{})
require.NoError(t, err)
assert.False(t, *disableOut.SerialConsoleAccessEnabled)
getOut, err = svc.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
require.NoError(t, err)
assert.False(t, *getOut.SerialConsoleAccessEnabled)
}
-15
View File
@@ -1,15 +0,0 @@
package handlers_ec2_image
import "github.com/aws/aws-sdk-go/service/ec2"
// ImageService defines the interface for EC2 image operations business logic
type ImageService interface {
CreateImage(input *ec2.CreateImageInput) (*ec2.CreateImageOutput, error)
CopyImage(input *ec2.CopyImageInput) (*ec2.CopyImageOutput, error)
DescribeImages(input *ec2.DescribeImagesInput) (*ec2.DescribeImagesOutput, error)
DescribeImageAttribute(input *ec2.DescribeImageAttributeInput) (*ec2.DescribeImageAttributeOutput, error)
RegisterImage(input *ec2.RegisterImageInput) (*ec2.RegisterImageOutput, error)
DeregisterImage(input *ec2.DeregisterImageInput) (*ec2.DeregisterImageOutput, error)
ModifyImageAttribute(input *ec2.ModifyImageAttributeInput) (*ec2.ModifyImageAttributeOutput, error)
ResetImageAttribute(input *ec2.ResetImageAttributeInput) (*ec2.ResetImageAttributeOutput, error)
}
@@ -1,261 +0,0 @@
package handlers_ec2_image
import (
"encoding/json"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
awss3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/hive/hive/awserrors"
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
"github.com/mulgadc/hive/hive/objectstore"
"github.com/mulgadc/viperblock/viperblock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testBucket = "test-bucket"
const testAccountID = "123456789"
// setupTestImageService creates an image service with in-memory storage for testing
func setupTestImageService(t *testing.T) (*ImageServiceImpl, *objectstore.MemoryObjectStore) {
store := objectstore.NewMemoryObjectStore()
svc := NewImageServiceImplWithStore(store, testBucket, testAccountID)
return svc, store
}
// createTestVolumeConfig creates a test volume config in the mock store
func createTestVolumeConfig(t *testing.T, store *objectstore.MemoryObjectStore, volumeID string, sizeGiB int) {
volumeState := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
SizeGiB: uint64(sizeGiB),
},
},
}
data, err := json.Marshal(volumeState)
require.NoError(t, err)
_, err = store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(volumeID + "/config.json"),
Body: strings.NewReader(string(data)),
ContentType: aws.String("application/json"),
})
require.NoError(t, err)
}
// createTestAMIConfig creates a test AMI config in the mock store
func createTestAMIConfig(t *testing.T, store *objectstore.MemoryObjectStore, imageID string) {
amiState := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
AMIMetadata: viperblock.AMIMetadata{
ImageID: imageID,
Name: "test-ami",
Architecture: "x86_64",
PlatformDetails: "Linux/UNIX",
Virtualization: "hvm",
RootDeviceType: "ebs",
VolumeSizeGiB: 8,
},
},
}
data, err := json.Marshal(amiState)
require.NoError(t, err)
_, err = store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(imageID + "/config.json"),
Body: strings.NewReader(string(data)),
ContentType: aws.String("application/json"),
})
require.NoError(t, err)
}
func TestCreateImageFromInstance_NilInput(t *testing.T) {
svc, _ := setupTestImageService(t)
_, err := svc.CreateImageFromInstance(CreateImageParams{})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestCreateImageFromInstance_RunningInstance_NoNATS(t *testing.T) {
svc, store := setupTestImageService(t)
// Create volume and AMI configs
createTestVolumeConfig(t, store, "vol-root123", 10)
createTestAMIConfig(t, store, "ami-source123")
// Running instance without NATS should fail (natsConn is nil)
_, err := svc.CreateImageFromInstance(CreateImageParams{
Input: &ec2.CreateImageInput{
InstanceId: aws.String("i-test123"),
Name: aws.String("my-image"),
},
RootVolumeID: "vol-root123",
SourceImageID: "ami-source123",
IsRunning: true,
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestCreateImageFromInstance_StoppedInstance_NoConfig(t *testing.T) {
svc, _ := setupTestImageService(t)
// Stopped instance without config will fail (no config to create viperblock)
_, err := svc.CreateImageFromInstance(CreateImageParams{
Input: &ec2.CreateImageInput{
InstanceId: aws.String("i-test123"),
Name: aws.String("my-image"),
},
RootVolumeID: "vol-nonexistent",
SourceImageID: "ami-source123",
IsRunning: false,
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestDescribeImages_AfterCreate(t *testing.T) {
svc, store := setupTestImageService(t)
// Manually create an AMI config in the store (simulates what CreateImageFromInstance does)
amiID := "ami-testimage123"
createTestAMIConfig(t, store, amiID)
// Describe images should find it
result, err := svc.DescribeImages(&ec2.DescribeImagesInput{
ImageIds: []*string{aws.String(amiID)},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Images, 1)
assert.Equal(t, amiID, *result.Images[0].ImageId)
assert.Equal(t, "test-ami", *result.Images[0].Name)
assert.Equal(t, "x86_64", *result.Images[0].Architecture)
}
func TestGetVolumeConfig(t *testing.T) {
svc, store := setupTestImageService(t)
createTestVolumeConfig(t, store, "vol-abc123", 20)
cfg, err := svc.getVolumeConfig("vol-abc123")
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, uint64(20), cfg.VolumeMetadata.SizeGiB)
}
func TestGetVolumeConfig_NotFound(t *testing.T) {
svc, _ := setupTestImageService(t)
_, err := svc.getVolumeConfig("vol-nonexistent")
require.Error(t, err)
}
func TestGetAMIConfig(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfig(t, store, "ami-abc123")
meta, err := svc.getAMIConfig("ami-abc123")
require.NoError(t, err)
assert.Equal(t, "ami-abc123", meta.ImageID)
assert.Equal(t, "test-ami", meta.Name)
assert.Equal(t, "x86_64", meta.Architecture)
assert.Equal(t, "Linux/UNIX", meta.PlatformDetails)
assert.Equal(t, "hvm", meta.Virtualization)
}
func TestGetAMIConfig_NotFound(t *testing.T) {
svc, _ := setupTestImageService(t)
_, err := svc.getAMIConfig("ami-nonexistent")
require.Error(t, err)
}
func TestPutSnapshotMetadata(t *testing.T) {
svc, store := setupTestImageService(t)
err := svc.putSnapshotMetadata("snap-abc123", "vol-xyz789", 10)
require.NoError(t, err)
// Verify the metadata was written correctly
result, err := store.GetObject(&awss3.GetObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String("snap-abc123/metadata.json"),
})
require.NoError(t, err)
defer result.Body.Close()
var cfg handlers_ec2_snapshot.SnapshotConfig
err = json.NewDecoder(result.Body).Decode(&cfg)
require.NoError(t, err)
assert.Equal(t, "snap-abc123", cfg.SnapshotID)
assert.Equal(t, "vol-xyz789", cfg.VolumeID)
assert.Equal(t, int64(10), cfg.VolumeSize)
assert.Equal(t, "completed", cfg.State)
assert.Equal(t, "100%", cfg.Progress)
assert.Equal(t, testAccountID, cfg.OwnerID)
}
func TestCreateImageFromInstance_SourceAMIReadFailure(t *testing.T) {
svc, store := setupTestImageService(t)
// Create volume config but NOT the source AMI config
createTestVolumeConfig(t, store, "vol-root123", 10)
// With non-empty SourceImageID, missing AMI config should be a hard error
_, err := svc.CreateImageFromInstance(CreateImageParams{
Input: &ec2.CreateImageInput{
InstanceId: aws.String("i-test123"),
Name: aws.String("my-image"),
},
RootVolumeID: "vol-root123",
SourceImageID: "ami-nonexistent",
IsRunning: true, // will fail at snapshot step first (no NATS)
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestDescribeImages_FilterByOwnerSelf(t *testing.T) {
svc, store := setupTestImageService(t)
// Create AMI with "self" owner alias
amiID := "ami-selfowned"
amiState := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
AMIMetadata: viperblock.AMIMetadata{
ImageID: amiID,
Name: "self-owned-ami",
ImageOwnerAlias: "self",
Architecture: "x86_64",
PlatformDetails: "Linux/UNIX",
RootDeviceType: "ebs",
VolumeSizeGiB: 8,
},
},
}
data, err := json.Marshal(amiState)
require.NoError(t, err)
_, err = store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(amiID + "/config.json"),
Body: strings.NewReader(string(data)),
})
require.NoError(t, err)
// Filter by "self"
result, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Owners: []*string{aws.String("self")},
})
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.Images, 1)
assert.Equal(t, amiID, *result.Images[0].ImageId)
}
-52
View File
@@ -1,52 +0,0 @@
package handlers_ec2_image
import (
"errors"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
// NATSImageService handles image operations via NATS messaging
type NATSImageService struct {
natsConn *nats.Conn
}
// NewNATSImageService creates a new NATS-based image service
func NewNATSImageService(conn *nats.Conn) ImageService {
return &NATSImageService{natsConn: conn}
}
func (s *NATSImageService) DescribeImages(input *ec2.DescribeImagesInput) (*ec2.DescribeImagesOutput, error) {
return utils.NATSRequest[ec2.DescribeImagesOutput](s.natsConn, "ec2.DescribeImages", input, 30*time.Second)
}
func (s *NATSImageService) CreateImage(input *ec2.CreateImageInput) (*ec2.CreateImageOutput, error) {
return utils.NATSRequest[ec2.CreateImageOutput](s.natsConn, "ec2.CreateImage", input, 120*time.Second)
}
func (s *NATSImageService) CopyImage(input *ec2.CopyImageInput) (*ec2.CopyImageOutput, error) {
return nil, errors.New("CopyImage not yet implemented")
}
func (s *NATSImageService) DescribeImageAttribute(input *ec2.DescribeImageAttributeInput) (*ec2.DescribeImageAttributeOutput, error) {
return nil, errors.New("DescribeImageAttribute not yet implemented")
}
func (s *NATSImageService) RegisterImage(input *ec2.RegisterImageInput) (*ec2.RegisterImageOutput, error) {
return nil, errors.New("RegisterImage not yet implemented")
}
func (s *NATSImageService) DeregisterImage(input *ec2.DeregisterImageInput) (*ec2.DeregisterImageOutput, error) {
return nil, errors.New("DeregisterImage not yet implemented")
}
func (s *NATSImageService) ModifyImageAttribute(input *ec2.ModifyImageAttributeInput) (*ec2.ModifyImageAttributeOutput, error) {
return nil, errors.New("ModifyImageAttribute not yet implemented")
}
func (s *NATSImageService) ResetImageAttribute(input *ec2.ResetImageAttributeInput) (*ec2.ResetImageAttributeOutput, error) {
return nil, errors.New("ResetImageAttribute not yet implemented")
}
-11
View File
@@ -1,11 +0,0 @@
package handlers_ec2_key
import "github.com/aws/aws-sdk-go/service/ec2"
// KeyService defines the interface for EC2 key pair operations business logic
type KeyService interface {
CreateKeyPair(input *ec2.CreateKeyPairInput) (*ec2.CreateKeyPairOutput, error)
DeleteKeyPair(input *ec2.DeleteKeyPairInput) (*ec2.DeleteKeyPairOutput, error)
DescribeKeyPairs(input *ec2.DescribeKeyPairsInput) (*ec2.DescribeKeyPairsOutput, error)
ImportKeyPair(input *ec2.ImportKeyPairInput) (*ec2.ImportKeyPairOutput, error)
}
-452
View File
@@ -1,452 +0,0 @@
package handlers_ec2_key
import (
"encoding/json"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
)
// TestKeyPairMetadataMarshaling tests that CreateKeyPairOutput (used as metadata) can be marshaled/unmarshaled correctly
func TestKeyPairMetadataMarshaling(t *testing.T) {
metadata := ec2.CreateKeyPairOutput{
KeyPairId: aws.String("key-12345"),
KeyFingerprint: aws.String("SHA256:abcdef1234567890"),
KeyName: aws.String("test-key"),
// Note: KeyMaterial is not stored in metadata (only returned once during creation)
}
// Marshal to JSON
data, err := json.Marshal(metadata)
if err != nil {
t.Fatalf("Failed to marshal CreateKeyPairOutput: %v", err)
}
// Unmarshal from JSON
var unmarshaled ec2.CreateKeyPairOutput
err = json.Unmarshal(data, &unmarshaled)
if err != nil {
t.Fatalf("Failed to unmarshal CreateKeyPairOutput: %v", err)
}
// Verify fields match
if *unmarshaled.KeyPairId != *metadata.KeyPairId {
t.Errorf("KeyPairId mismatch: got %s, want %s", *unmarshaled.KeyPairId, *metadata.KeyPairId)
}
if *unmarshaled.KeyFingerprint != *metadata.KeyFingerprint {
t.Errorf("KeyFingerprint mismatch: got %s, want %s", *unmarshaled.KeyFingerprint, *metadata.KeyFingerprint)
}
if *unmarshaled.KeyName != *metadata.KeyName {
t.Errorf("KeyName mismatch: got %s, want %s", *unmarshaled.KeyName, *metadata.KeyName)
}
}
// TestDetermineKeyTypeFromFingerprint tests the key type detection logic
func TestDetermineKeyTypeFromFingerprint(t *testing.T) {
tests := []struct {
name string
fingerprint string
expected string
}{
{
name: "ED25519 key (SHA256 prefix)",
fingerprint: "SHA256:abcdef1234567890",
expected: "ed25519",
},
{
name: "RSA key (hex fingerprint)",
fingerprint: "ab:cd:ef:12:34:56:78:90",
expected: "rsa",
},
{
name: "RSA key (uppercase hex)",
fingerprint: "AB:CD:EF:12:34:56:78:90",
expected: "rsa",
},
{
name: "Empty fingerprint",
fingerprint: "",
expected: "rsa", // default fallback
},
{
name: "RSA key (MD5 format)",
fingerprint: "1a2b3c4d5e6f7890abcdef1234567890",
expected: "rsa",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the exact logic from DescribeKeyPairs (line 537-541)
var keyType string
if strings.HasPrefix(tt.fingerprint, "SHA256:") {
keyType = "ed25519"
} else {
keyType = "rsa"
}
if keyType != tt.expected {
t.Errorf("Expected key type %s, got %s for fingerprint %s", tt.expected, keyType, tt.fingerprint)
}
})
}
}
// TestDescribeKeyPairsInputValidation tests input validation for DescribeKeyPairs
func TestDescribeKeyPairsInputValidation(t *testing.T) {
tests := []struct {
name string
input *ec2.DescribeKeyPairsInput
expectErr bool
}{
{
name: "Nil input",
input: nil,
expectErr: false, // DescribeKeyPairs accepts nil input (all optional)
},
{
name: "Empty input",
input: &ec2.DescribeKeyPairsInput{},
expectErr: false,
},
{
name: "With KeyNames filter",
input: &ec2.DescribeKeyPairsInput{
KeyNames: []*string{aws.String("test-key")},
},
expectErr: false,
},
{
name: "With KeyPairIds filter",
input: &ec2.DescribeKeyPairsInput{
KeyPairIds: []*string{aws.String("key-12345")},
},
expectErr: false,
},
{
name: "With both filters",
input: &ec2.DescribeKeyPairsInput{
KeyNames: []*string{aws.String("test-key")},
KeyPairIds: []*string{aws.String("key-12345")},
},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Since DescribeKeyPairs has no required parameters, all inputs should be valid
// This test documents the expected behavior
if tt.input == nil && tt.expectErr {
t.Error("Unexpected error expectation for nil input")
}
})
}
}
// TestKeyPairFiltering tests the filtering logic for DescribeKeyPairs
func TestKeyPairFiltering(t *testing.T) {
// Create sample key pairs
keyPairs := []*ec2.KeyPairInfo{
{
KeyPairId: aws.String("key-11111"),
KeyName: aws.String("test-key-1"),
KeyFingerprint: aws.String("SHA256:abc123"),
},
{
KeyPairId: aws.String("key-22222"),
KeyName: aws.String("test-key-2"),
KeyFingerprint: aws.String("ab:cd:ef:12"),
},
{
KeyPairId: aws.String("key-33333"),
KeyName: aws.String("prod-key-1"),
KeyFingerprint: aws.String("SHA256:def456"),
},
}
tests := []struct {
name string
input *ec2.DescribeKeyPairsInput
expectedCount int
expectedKeyIds []string
}{
{
name: "No filters - return all",
input: &ec2.DescribeKeyPairsInput{},
expectedCount: 3,
expectedKeyIds: []string{"key-11111", "key-22222", "key-33333"},
},
{
name: "Filter by single KeyName",
input: &ec2.DescribeKeyPairsInput{
KeyNames: []*string{aws.String("test-key-1")},
},
expectedCount: 1,
expectedKeyIds: []string{"key-11111"},
},
{
name: "Filter by multiple KeyNames",
input: &ec2.DescribeKeyPairsInput{
KeyNames: []*string{aws.String("test-key-1"), aws.String("test-key-2")},
},
expectedCount: 2,
expectedKeyIds: []string{"key-11111", "key-22222"},
},
{
name: "Filter by single KeyPairId",
input: &ec2.DescribeKeyPairsInput{
KeyPairIds: []*string{aws.String("key-22222")},
},
expectedCount: 1,
expectedKeyIds: []string{"key-22222"},
},
{
name: "Filter by multiple KeyPairIds",
input: &ec2.DescribeKeyPairsInput{
KeyPairIds: []*string{aws.String("key-11111"), aws.String("key-33333")},
},
expectedCount: 2,
expectedKeyIds: []string{"key-11111", "key-33333"},
},
{
name: "Filter by non-existent KeyName",
input: &ec2.DescribeKeyPairsInput{
KeyNames: []*string{aws.String("nonexistent-key")},
},
expectedCount: 0,
expectedKeyIds: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate filtering logic from DescribeKeyPairs
var filtered []*ec2.KeyPairInfo
if len(tt.input.KeyNames) > 0 {
// Filter by KeyName
nameSet := make(map[string]bool)
for _, name := range tt.input.KeyNames {
nameSet[*name] = true
}
for _, kp := range keyPairs {
if nameSet[*kp.KeyName] {
filtered = append(filtered, kp)
}
}
} else if len(tt.input.KeyPairIds) > 0 {
// Filter by KeyPairId
idSet := make(map[string]bool)
for _, id := range tt.input.KeyPairIds {
idSet[*id] = true
}
for _, kp := range keyPairs {
if idSet[*kp.KeyPairId] {
filtered = append(filtered, kp)
}
}
} else {
// No filters - return all
filtered = keyPairs
}
if len(filtered) != tt.expectedCount {
t.Errorf("Expected %d key pairs, got %d", tt.expectedCount, len(filtered))
}
// Verify expected key IDs are present
for _, expectedId := range tt.expectedKeyIds {
found := false
for _, kp := range filtered {
if *kp.KeyPairId == expectedId {
found = true
break
}
}
if !found {
t.Errorf("Expected key pair ID %s not found in filtered results", expectedId)
}
}
})
}
}
// TestKeyPairMetadataPath tests the S3 path construction logic
func TestKeyPairMetadataPath(t *testing.T) {
accountID := "123456789"
tests := []struct {
name string
keyPairId string
expected string
}{
{
name: "Standard key pair ID",
keyPairId: "key-abcdef123456",
expected: "keys/123456789/key-abcdef123456.json",
},
{
name: "Short key pair ID",
keyPairId: "key-12345",
expected: "keys/123456789/key-12345.json",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate path construction from the code
metadataPath := "keys/" + accountID + "/" + tt.keyPairId + ".json"
if metadataPath != tt.expected {
t.Errorf("Expected path %s, got %s", tt.expected, metadataPath)
}
})
}
}
// TestImportKeyPairKeyTypeDetection tests key type detection from public key material
func TestImportKeyPairKeyTypeDetection(t *testing.T) {
tests := []struct {
name string
publicKeyData string
expectedType string
expectError bool
}{
{
name: "ED25519 key",
publicKeyData: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl",
expectedType: "ed25519",
expectError: false,
},
{
name: "RSA key",
publicKeyData: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCz...",
expectedType: "rsa",
expectError: false,
},
{
name: "ECDSA key",
publicKeyData: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTY...",
expectedType: "ecdsa",
expectError: false,
},
{
name: "Invalid format - no key data",
publicKeyData: "ssh-rsa",
expectedType: "",
expectError: true,
},
{
name: "Empty key material",
publicKeyData: "",
expectedType: "",
expectError: true,
},
{
name: "Unsupported key type",
publicKeyData: "ssh-dss AAAAB3NzaC1kc3MAAACB...",
expectedType: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the key type detection logic from ImportKeyPair
parts := strings.Fields(tt.publicKeyData)
if len(parts) < 2 {
if !tt.expectError {
t.Errorf("Expected no error but got invalid format")
}
return
}
var keyType string
algorithmPrefix := parts[0]
switch {
case strings.HasPrefix(algorithmPrefix, "ssh-ed25519"):
keyType = "ed25519"
case strings.HasPrefix(algorithmPrefix, "ssh-rsa"):
keyType = "rsa"
case strings.HasPrefix(algorithmPrefix, "ecdsa-sha2-"):
keyType = "ecdsa"
default:
if !tt.expectError {
t.Errorf("Expected no error but got unsupported key type: %s", algorithmPrefix)
}
return
}
if keyType != tt.expectedType {
t.Errorf("Expected key type %s, got %s", tt.expectedType, keyType)
}
})
}
}
// TestImportKeyPairInputValidation tests input validation for ImportKeyPair
func TestImportKeyPairInputValidation(t *testing.T) {
tests := []struct {
name string
input *ec2.ImportKeyPairInput
expectErr bool
}{
{
name: "Nil input",
input: nil,
expectErr: true,
},
{
name: "Missing KeyName",
input: &ec2.ImportKeyPairInput{
PublicKeyMaterial: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..."),
},
expectErr: true,
},
{
name: "Missing PublicKeyMaterial",
input: &ec2.ImportKeyPairInput{
KeyName: aws.String("test-key"),
},
expectErr: true,
},
{
name: "Empty PublicKeyMaterial",
input: &ec2.ImportKeyPairInput{
KeyName: aws.String("test-key"),
PublicKeyMaterial: []byte(""),
},
expectErr: true,
},
{
name: "Valid input",
input: &ec2.ImportKeyPairInput{
KeyName: aws.String("test-key"),
PublicKeyMaterial: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..."),
},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate validation logic from ImportKeyPair
hasError := false
if tt.input == nil || tt.input.KeyName == nil {
hasError = true
} else if len(tt.input.PublicKeyMaterial) == 0 {
hasError = true
}
if hasError != tt.expectErr {
t.Errorf("Expected error=%v, got error=%v", tt.expectErr, hasError)
}
})
}
}
-11
View File
@@ -1,11 +0,0 @@
package handlers_ec2_snapshot
import "github.com/aws/aws-sdk-go/service/ec2"
// SnapshotService defines the interface for EC2 snapshot operations
type SnapshotService interface {
CreateSnapshot(input *ec2.CreateSnapshotInput) (*ec2.Snapshot, error)
DescribeSnapshots(input *ec2.DescribeSnapshotsInput) (*ec2.DescribeSnapshotsOutput, error)
DeleteSnapshot(input *ec2.DeleteSnapshotInput) (*ec2.DeleteSnapshotOutput, error)
CopySnapshot(input *ec2.CopySnapshotInput) (*ec2.CopySnapshotOutput, error)
}
-10
View File
@@ -1,10 +0,0 @@
package handlers_ec2_tags
import "github.com/aws/aws-sdk-go/service/ec2"
// TagsService defines the interface for EC2 tag operations
type TagsService interface {
CreateTags(input *ec2.CreateTagsInput) (*ec2.CreateTagsOutput, error)
DescribeTags(input *ec2.DescribeTagsInput) (*ec2.DescribeTagsOutput, error)
DeleteTags(input *ec2.DeleteTagsInput) (*ec2.DeleteTagsOutput, error)
}
-12
View File
@@ -1,12 +0,0 @@
package handlers_ec2_volume
import "github.com/aws/aws-sdk-go/service/ec2"
// VolumeService defines the interface for EBS volume operations
type VolumeService interface {
CreateVolume(input *ec2.CreateVolumeInput) (*ec2.Volume, error)
DescribeVolumes(input *ec2.DescribeVolumesInput) (*ec2.DescribeVolumesOutput, error)
ModifyVolume(input *ec2.ModifyVolumeInput) (*ec2.ModifyVolumeOutput, error)
DeleteVolume(input *ec2.DeleteVolumeInput) (*ec2.DeleteVolumeOutput, error)
DescribeVolumeStatus(input *ec2.DescribeVolumeStatusInput) (*ec2.DescribeVolumeStatusOutput, error)
}
-46
View File
@@ -1,46 +0,0 @@
package service
import (
"fmt"
"github.com/mulgadc/hive/hive/services/awsgw"
"github.com/mulgadc/hive/hive/services/hive"
"github.com/mulgadc/hive/hive/services/hiveui"
"github.com/mulgadc/hive/hive/services/nats"
"github.com/mulgadc/hive/hive/services/predastore"
"github.com/mulgadc/hive/hive/services/viperblockd"
)
type Service interface {
Start() (int, error)
Stop() error
Status() (string, error)
Shutdown() error
Reload() error
}
func New(btype string, config any) (Service, error) {
switch btype {
case "nats":
return nats.New(config)
case "predastore":
return predastore.New(config)
case "viperblock":
return viperblockd.New(config)
case "hive":
return hive.New(config)
case "awsgw":
return awsgw.New(config)
case "hive-ui":
return hiveui.New(config)
}
return nil, fmt.Errorf("unknown service type: %s", btype)
}
-99
View File
@@ -1,99 +0,0 @@
package awsgw
import (
"fmt"
"log/slog"
"os"
"strings"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/gateway"
"github.com/mulgadc/hive/hive/utils"
)
var serviceName = "awsgw"
type Service struct {
Config *config.ClusterConfig
}
func New(cfg any) (svc *Service, err error) {
svc = &Service{
Config: cfg.(*config.ClusterConfig),
}
return svc, nil
}
func (svc *Service) Start() (int, error) {
if err := utils.WritePidFileTo(svc.Config.NodeBaseDir(), serviceName, os.Getpid()); err != nil {
slog.Error("Failed to write pid file", "err", err)
}
err := launchService(svc.Config)
if err != nil {
return 0, err
}
return os.Getpid(), nil
}
func (svc *Service) Stop() (err error) {
return utils.StopProcessAt(svc.Config.NodeBaseDir(), serviceName)
}
func (svc *Service) Status() (string, error) {
return "", nil
}
func (svc *Service) Shutdown() (err error) {
return svc.Stop()
}
func (svc *Service) Reload() (err error) {
return nil
}
func launchService(config *config.ClusterConfig) (err error) {
nodeConfig := config.Nodes[config.Node]
// Connect to NATS for service communication
natsConn, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
if err != nil {
slog.Error("Failed to connect to NATS", "err", err)
return err
}
defer natsConn.Close()
// Append Base dir if config has no leading path
if nodeConfig.BaseDir != "" && !strings.HasPrefix(nodeConfig.AWSGW.Config, "/") {
nodeConfig.AWSGW.Config = fmt.Sprintf("%s/%s", nodeConfig.BaseDir, nodeConfig.AWSGW.Config)
}
// Create gateway with NATS connection
gw := gateway.GatewayConfig{
Debug: nodeConfig.AWSGW.Debug,
DisableLogging: false,
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
ExpectedNodes: len(config.Nodes),
Region: nodeConfig.Region,
AZ: nodeConfig.AZ,
AccessKey: nodeConfig.AccessKey,
SecretKey: nodeConfig.SecretKey,
}
app := gw.SetupRoutes()
if err != nil {
slog.Warn("Failed to setup gateway routes", "err", err)
return err
}
if err := app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey); err != nil {
slog.Error("Failed to start TLS listener", "err", err)
os.Exit(1)
}
return nil
}
-1
View File
@@ -1 +0,0 @@
24.12.0
-57
View File
@@ -1,57 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/core", "ultracite/remix", "ultracite/react"],
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":URL:",
":BLANK_LINE:",
[":BUN:", ":NODE:"],
":BLANK_LINE:",
[":PACKAGE:", ":PACKAGE_WITH_PROTOCOL:"],
":BLANK_LINE:",
[":ALIAS:", ":PATH:"]
]
}
}
}
}
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.ts"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"filenameCases": ["camelCase"]
}
}
}
}
}
},
{
"includes": ["**/components/ui/*.tsx"],
"linter": {
"rules": {
"performance": {
"noNamespaceImport": "off"
}
}
}
}
]
}
-1
View File
@@ -1 +0,0 @@
import{c as B,aH as $,aI as K,aE as C,j as s,aD as F,aJ as b,aK as k,L as E}from"./index.js";import{u as L}from"./useSuspenseQuery.js";import{B as O}from"./back-link.js";import{E as S}from"./error-banner.js";import{a as U}from"./s3.js";import{U as A,F as I,O as R}from"./upload-button.js";import"./alert-dialog.js";import"./download.js";import"./trash-2.js";function G(){const e=B.c(24),{bucket:n,_splat:v}=$.useParams(),a=K(v||""),{data:m}=L(C(n,a)),r=U();let d;e[0]!==m.Contents?(d=m.Contents||[],e[0]=m.Contents,e[1]=d):d=e[1];const o=d;let p;e[2]!==m.CommonPrefixes?(p=m.CommonPrefixes||[],e[2]=m.CommonPrefixes,e[3]=p):p=e[3];const i=p,u=a.split("/").filter(Boolean),y=[{name:n,path:`/s3/ls/${n}`},...u.map((t,l)=>{const c=`${u.slice(0,l+1).join("/")}/`;return{name:t,path:`/s3/ls/${n}/${c}`}})],P=u.length>1?`/s3/ls/${n}/${u.slice(0,-1).join("/")}/`:`/s3/ls/${n}`;let f;e[4]!==P?(f=s.jsx(O,{to:P,children:"Back"}),e[4]=P,e[5]=f):f=e[5];let x;e[6]!==n||e[7]!==a||e[8]!==r.isPending||e[9]!==r.mutateAsync?(x=s.jsx(A,{bucket:n,isPending:r.isPending,onUpload:r.mutateAsync,prefix:a}),e[6]=n,e[7]=a,e[8]=r.isPending,e[9]=r.mutateAsync,e[10]=x):x=e[10];let h;e[11]!==r.error?(h=r.error&&s.jsx(S,{error:r.error,msg:"Failed to upload object"}),e[11]=r.error,e[12]=h):h=e[12];let g;e[13]!==n||e[14]!==i||e[15]!==a?(g=i.length>0&&s.jsxs("div",{className:"mb-6",children:[s.jsx("h2",{className:"mb-3 font-semibold text-lg",children:"Folders"}),s.jsx("div",{className:"space-y-2",children:i.map(t=>{if(!t.Prefix)return null;const l=F(b(t.Prefix,a)),c=k(t.Prefix,a);return s.jsx(I,{bucketName:n,folderName:l,fullPrefix:c},t.Prefix)})})]}),e[13]=n,e[14]=i,e[15]=a,e[16]=g):g=e[16];let j;e[17]!==n||e[18]!==o||e[19]!==a?(j=o.length>0&&s.jsxs("div",{className:"space-y-2",children:[s.jsx("h2",{className:"mb-3 font-semibold text-lg",children:"Objects"}),o.map(t=>{if(!t.Key)return null;const l=b(t.Key,a),c=k(t.Key,a);return s.jsx(R,{bucketName:n,displayName:l,fullKey:c,object:t},t.Key)})]}),e[17]=n,e[18]=o,e[19]=a,e[20]=j):j=e[20];let N;return e[21]!==i.length||e[22]!==o.length?(N=o.length===0&&i.length===0&&s.jsx("p",{className:"text-muted-foreground",children:"No objects found in this folder."}),e[21]=i.length,e[22]=o.length,e[23]=N):N=e[23],s.jsxs(s.Fragment,{children:[f,s.jsx("div",{className:"mb-6",children:s.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[s.jsx("div",{className:"flex items-center gap-2 text-muted-foreground text-sm",children:y.map((t,l)=>s.jsxs("div",{className:"flex items-center gap-2",children:[l>0&&s.jsx("span",{children:"/"}),l===y.length-1?s.jsx("span",{className:"text-foreground",children:t.name}):s.jsx(E,{className:"hover:text-foreground",to:t.path,children:t.name})]},t.path))}),x]})}),h,g,j,N]})}export{G as component};
-1
View File
@@ -1 +0,0 @@
import{c as O,bp as T,u as D,bq as J,j as s,br as K,B as Q}from"./index.js";import{a as U,u as W,I as X,F as Y,b as Z,c as ee}from"./field.js";import{u as se}from"./useSuspenseQuery.js";import{B as q}from"./back-link.js";import{E as te}from"./error-banner.js";import{P as ie}from"./page-heading.js";import{d as le}from"./ec2.js";function de(){const e=O.c(58),{id:R}=T.useParams(),o=D();let S;e[0]!==R?(S=K(R),e[0]=R,e[1]=S):S=e[1];const{data:H}=se(S),A=H.Volumes?.[0],t=A?.VolumeId,l=le(),i=A?.Size??1;let a;e[2]!==i?(a=U(J.refine(r=>r.size>i,{message:`New size must be greater than current size (${i} GiB)`,path:["size"]})),e[2]=i,e[3]=a):a=e[3];let n;e[4]!==i?(n={size:i},e[4]=i,e[5]=n):n=e[5];let B;e[6]!==a||e[7]!==n?(B={resolver:a,defaultValues:n},e[6]=a,e[7]=n,e[8]=B):B=e[8];const{handleSubmit:k,register:E,formState:L}=W(B),{errors:V,isSubmitting:$}=L;if(!t){let r;return e[9]===Symbol.for("react.memo_cache_sentinel")?(r=s.jsxs(s.Fragment,{children:[s.jsx(q,{to:"/ec2/describe-volumes",children:"Back to volumes"}),s.jsx("p",{className:"text-muted-foreground",children:"Volume not found."})]}),e[9]=r):r=e[9],r}let F;e[10]!==l||e[11]!==o||e[12]!==t?(F=async r=>{await l.mutateAsync({...r,volumeId:t}),o({to:"/ec2/describe-volumes/$id",params:{id:t}})},e[10]=l,e[11]=o,e[12]=t,e[13]=F):F=e[13];const w=F;let m,c;e[14]!==t?(m=s.jsx(q,{params:{id:t},to:"/ec2/describe-volumes/$id",children:"Back to volume"}),c=s.jsx(ie,{subtitle:t,title:"Resize Volume"}),e[14]=t,e[15]=m,e[16]=c):(m=e[15],c=e[16]);let u;e[17]!==l.error?(u=l.error&&s.jsx(te,{error:l.error,msg:"Failed to resize volume"}),e[17]=l.error,e[18]=u):u=e[18];let d;e[19]!==k||e[20]!==w?(d=k(w),e[19]=k,e[20]=w,e[21]=d):d=e[21];let f;e[22]!==i?(f=s.jsx("div",{className:"space-y-2",children:s.jsxs("p",{className:"text-muted-foreground text-sm",children:["Current size: ",i," GiB"]})}),e[22]=i,e[23]=f):f=e[23];let N;e[24]===Symbol.for("react.memo_cache_sentinel")?(N=s.jsx(Z,{children:s.jsx("label",{htmlFor:"size",children:"New size (GiB)"})}),e[24]=N):N=e[24];const I=!!V.size,_=i+1;let p;e[25]!==E?(p=E("size",{valueAsNumber:!0}),e[25]=E,e[26]=p):p=e[26];let x;e[27]!==I||e[28]!==_||e[29]!==p?(x=s.jsx(X,{"aria-invalid":I,id:"size",min:_,type:"number",...p}),e[27]=I,e[28]=_,e[29]=p,e[30]=x):x=e[30];let b;e[31]!==V.size?(b=s.jsx(Y,{errors:[V.size]}),e[31]=V.size,e[32]=b):b=e[32];let h;e[33]!==x||e[34]!==b?(h=s.jsxs(ee,{children:[N,x,b]}),e[33]=x,e[34]=b,e[35]=h):h=e[35];const C=$||l.isPending;let j;e[36]!==o||e[37]!==t?(j=()=>o({to:"/ec2/describe-volumes/$id",params:{id:t}}),e[36]=o,e[37]=t,e[38]=j):j=e[38];let z;e[39]!==C||e[40]!==j?(z=s.jsx(Q,{disabled:C,onClick:j,type:"button",variant:"outline",children:"Cancel"}),e[39]=C,e[40]=j,e[41]=z):z=e[41];const G=$||l.isPending,M=$||l.isPending?"Resizing…":"Resize Volume";let g;e[42]!==G||e[43]!==M?(g=s.jsx(Q,{disabled:G,type:"submit",children:M}),e[42]=G,e[43]=M,e[44]=g):g=e[44];let v;e[45]!==z||e[46]!==g?(v=s.jsxs("div",{className:"flex gap-2",children:[z,g]}),e[45]=z,e[46]=g,e[47]=v):v=e[47];let y;e[48]!==f||e[49]!==h||e[50]!==v||e[51]!==d?(y=s.jsxs("form",{className:"max-w-4xl space-y-6",onSubmit:d,children:[f,h,v]}),e[48]=f,e[49]=h,e[50]=v,e[51]=d,e[52]=y):y=e[52];let P;return e[53]!==y||e[54]!==m||e[55]!==c||e[56]!==u?(P=s.jsxs(s.Fragment,{children:[m,c,u,y]}),e[53]=y,e[54]=m,e[55]=c,e[56]=u,e[57]=P):P=e[57],P}export{de as component};
-1
View File
@@ -1 +0,0 @@
import{bA as D,u as b,br as p,r as g,j as e,bB as r,B as c,L as f,bC as A}from"./index.js";import{u as V}from"./useSuspenseQuery.js";import{B as m}from"./back-link.js";import{D as t,a as l}from"./detail-row.js";import{E as y}from"./error-banner.js";import{P as I}from"./page-heading.js";import{g as S,f as T,b as C,a as B,c as N,d as k,e as E,h as z}from"./alert-dialog.js";import{B as w}from"./badge.js";import{e as P}from"./ec2.js";import{T as R}from"./trash-2.js";function G(){const{id:n}=D.useParams(),u=b(),{data:d}=V(p(n)),a=d.Volumes?.[0],i=P(),[x,o]=g.useState(!1),j=a?.State==="available"&&(!a.Attachments||a.Attachments.length===0),h=async()=>{try{await i.mutateAsync(n),u({to:"/ec2/describe-volumes"})}finally{o(!1)}};if(!a?.VolumeId)return e.jsxs(e.Fragment,{children:[e.jsx(m,{to:"/ec2/describe-volumes",children:"Back to volumes"}),e.jsx("p",{className:"text-muted-foreground",children:"Volume not found."})]});const v=r(a.CreateTime);return e.jsxs(e.Fragment,{children:[e.jsx(m,{to:"/ec2/describe-volumes",children:"Back to volumes"}),i.error&&e.jsx(y,{error:i.error,msg:"Failed to delete volume"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(I,{actions:e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs(c,{disabled:!j,onClick:()=>o(!0),size:"sm",variant:"destructive",children:[e.jsx(R,{className:"size-4"}),"Delete"]}),a.State==="available"?e.jsx(f,{className:A({variant:"outline"}),params:{id:a.VolumeId},to:"/ec2/modify-volume/$id",children:"Resize Volume"}):e.jsx(c,{disabled:!0,variant:"outline",children:"Resize Volume"}),a.State&&e.jsx(w,{variant:a.State==="available"?"default":"outline",children:a.State})]}),subtitle:"EC2 Volume Details",title:a.VolumeId}),e.jsxs(t,{children:[e.jsx(t.Header,{children:"Volume Information"}),e.jsxs(t.Content,{children:[e.jsx(l,{label:"Volume ID",value:a.VolumeId}),e.jsx(l,{label:"Size",value:`${a.Size} GiB`}),e.jsx(l,{label:"Volume Type",value:a.VolumeType}),e.jsx(l,{label:"State",value:a.State}),e.jsx(l,{label:"Availability Zone",value:a.AvailabilityZone}),e.jsx(l,{label:"Create Time",value:v}),e.jsx(l,{label:"Encrypted",value:a.Encrypted?"Yes":"No"}),e.jsx(l,{label:"KMS Key ID",value:a.KmsKeyId})]})]}),a.Attachments&&a.Attachments.length>0&&e.jsxs(t,{children:[e.jsx(t.Header,{children:"Attachments"}),a.Attachments.map(s=>e.jsxs(t.Content,{children:[e.jsx(l,{label:"Instance ID",value:s.InstanceId}),e.jsx(l,{label:"Device",value:s.Device}),e.jsx(l,{label:"Status",value:s.State}),e.jsx(l,{label:"Attach Time",value:r(s.AttachTime)}),e.jsx(l,{label:"Delete on Termination",value:s.DeleteOnTermination?"Yes":"No"})]},s.InstanceId))]})]}),e.jsx(S,{onOpenChange:o,open:x,children:e.jsxs(T,{children:[e.jsxs(C,{children:[e.jsx(B,{children:"Delete Volume"}),e.jsxs(N,{children:['Are you sure you want to delete the volume "',a.VolumeId,'"? This action cannot be undone.']})]}),e.jsxs(k,{children:[e.jsx(E,{children:"Cancel"}),e.jsx(z,{disabled:i.isPending,onClick:h,children:i.isPending?"Deleting…":"Delete"})]})]})})]})}export{G as component};
-1
View File
@@ -1 +0,0 @@
import{bD as x,u as p,bE as h,r as j,j as e,bB as g,B as D}from"./index.js";import{u as K}from"./useSuspenseQuery.js";import{B as o}from"./back-link.js";import{D as s,a as r}from"./detail-row.js";import{E as f}from"./error-banner.js";import{P as b}from"./page-heading.js";import{g as P,f as k,b as T,a as A,c as C,d as v,e as B,h as N}from"./alert-dialog.js";import{f as w}from"./ec2.js";import{T as E}from"./trash-2.js";function M(){const{id:l}=x.useParams(),c=p(),{data:d}=K(h(l)),a=d.KeyPairs?.[0],i=w(),[y,t]=j.useState(!1),m=async()=>{try{await i.mutateAsync(l),c({to:"/ec2/describe-key-pairs"})}finally{t(!1)}};if(!a?.KeyPairId)return e.jsxs(e.Fragment,{children:[e.jsx(o,{to:"/ec2/describe-key-pairs",children:"Back to key pairs"}),e.jsx("p",{className:"text-muted-foreground",children:"Key pair not found."})]});const u=g(a.CreateTime);return e.jsxs(e.Fragment,{children:[e.jsx(o,{to:"/ec2/describe-key-pairs",children:"Back to key pairs"}),i.error&&e.jsx(f,{error:i.error,msg:"Failed to delete key pair"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(b,{actions:e.jsxs(D,{onClick:()=>t(!0),size:"sm",variant:"destructive",children:[e.jsx(E,{className:"size-4"}),"Delete"]}),subtitle:"EC2 Key Pair Details",title:a.KeyName??""}),e.jsxs(s,{children:[e.jsx(s.Header,{children:"Key Pair Information"}),e.jsxs(s.Content,{children:[e.jsx(r,{label:"Key Name",value:a.KeyName}),e.jsx(r,{label:"Key Pair ID",value:a.KeyPairId}),e.jsx(r,{label:"Key Type",value:a.KeyType}),e.jsx(r,{label:"Key Fingerprint",value:a.KeyFingerprint}),e.jsx(r,{label:"Creation Date",value:u})]})]}),a.Tags&&a.Tags.length>0&&e.jsxs(s,{children:[e.jsx(s.Header,{children:"Tags"}),e.jsx(s.Content,{children:a.Tags.map(n=>e.jsx(r,{label:n.Key||"",value:n.Value},n.Key))})]})]}),e.jsx(P,{onOpenChange:t,open:y,children:e.jsxs(k,{children:[e.jsxs(T,{children:[e.jsx(A,{children:"Delete Key Pair"}),e.jsxs(C,{children:['Are you sure you want to delete the key pair "',a.KeyName,'"? This action cannot be undone. Any instances using this key pair will no longer be able to be accessed with it.']})]}),e.jsxs(v,{children:[e.jsx(B,{children:"Cancel"}),e.jsx(N,{disabled:i.isPending,onClick:m,children:i.isPending?"Deleting…":"Delete"})]})]})})]})}export{M as component};
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{c as g,ca as u,j as a,bR as p}from"./index.js";import{u as x}from"./useSuspenseQuery.js";import{B as d}from"./back-link.js";import{P as I}from"./page-heading.js";import{B as j}from"./badge.js";import{A as h}from"./ami-details.js";import"./detail-row.js";function E(){const e=g.c(14),{id:c}=u.useParams();let o;e[0]!==c?(o=p(c),e[0]=c,e[1]=o):o=e[1];const{data:f}=x(o),t=f?.Images?.[0];if(!t?.ImageId){let n;return e[2]===Symbol.for("react.memo_cache_sentinel")?(n=a.jsxs(a.Fragment,{children:[a.jsx(d,{to:"/ec2/describe-images",children:"Back to images"}),a.jsx("p",{className:"text-muted-foreground",children:"Image not found."})]}),e[2]=n):n=e[2],n}let r;e[3]===Symbol.for("react.memo_cache_sentinel")?(r=a.jsx(d,{to:"/ec2/describe-images",children:"Back to images"}),e[3]=r):r=e[3];let s;e[4]!==t.State?(s=t.State?a.jsx(j,{variant:t.State==="available"?"default":"outline",children:t.State}):void 0,e[4]=t.State,e[5]=s):s=e[5];let i;e[6]!==t.ImageId||e[7]!==s?(i=a.jsx(I,{actions:s,subtitle:"EC2 Image Details",title:t.ImageId}),e[6]=t.ImageId,e[7]=s,e[8]=i):i=e[8];let m;e[9]!==t?(m=a.jsx(h,{image:t,showExtendedDetails:!0}),e[9]=t,e[10]=m):m=e[10];let l;return e[11]!==i||e[12]!==m?(l=a.jsxs(a.Fragment,{children:[r,a.jsxs("div",{className:"space-y-6",children:[i,m]})]}),e[11]=i,e[12]=m,e[13]=l):l=e[13],l}export{E as component};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{c as S,bB as M,j as t}from"./index.js";import{D as T,a}from"./detail-row.js";function C(i){const e=S.c(55),{image:l,showExtendedDetails:P}=i,s=P===void 0?!1:P;let y;e[0]!==l.CreationDate?(y=M(l.CreationDate),e[0]=l.CreationDate,e[1]=y):y=e[1];const V=y,R=s?"AMI Information":"AMI Details";let o;e[2]!==R?(o=t.jsx(T.Header,{children:R}),e[2]=R,e[3]=o):o=e[3];let r;e[4]!==l.ImageId?(r=t.jsx(a,{label:"AMI ID",value:l.ImageId}),e[4]=l.ImageId,e[5]=r):r=e[5];let c;e[6]!==l.Name?(c=t.jsx(a,{label:"AMI Name",value:l.Name}),e[6]=l.Name,e[7]=c):c=e[7];let n;e[8]!==l.Description?(n=t.jsx(a,{label:"Description",value:l.Description}),e[8]=l.Description,e[9]=n):n=e[9];let u;e[10]!==l.Architecture?(u=t.jsx(a,{label:"Architecture",value:l.Architecture}),e[10]=l.Architecture,e[11]=u):u=e[11];let D;e[12]!==l.VirtualizationType?(D=t.jsx(a,{label:"Virtualization Type",value:l.VirtualizationType}),e[12]=l.VirtualizationType,e[13]=D):D=e[13];const A=l.Platform||"Linux";let v;e[14]!==A?(v=t.jsx(a,{label:"Platform",value:A}),e[14]=A,e[15]=v):v=e[15];let m;e[16]!==l.PlatformDetails?(m=t.jsx(a,{label:"Platform Details",value:l.PlatformDetails}),e[16]=l.PlatformDetails,e[17]=m):m=e[17];let x;e[18]!==l.ImageType?(x=t.jsx(a,{label:"Image Type",value:l.ImageType}),e[18]=l.ImageType,e[19]=x):x=e[19];let b;e[20]!==l.RootDeviceType?(b=t.jsx(a,{label:"Root Device Type",value:l.RootDeviceType}),e[20]=l.RootDeviceType,e[21]=b):b=e[21];let f;e[22]!==l.RootDeviceName?(f=t.jsx(a,{label:"Root Device Name",value:l.RootDeviceName}),e[22]=l.RootDeviceName,e[23]=f):f=e[23];let j;e[24]!==l.OwnerId?(j=t.jsx(a,{label:"Owner",value:l.OwnerId}),e[24]=l.OwnerId,e[25]=j):j=e[25];let d;e[26]!==l.State?(d=t.jsx(a,{label:"State",value:l.State}),e[26]=l.State,e[27]=d):d=e[27];let I;e[28]!==V||e[29]!==l.Public||e[30]!==s?(I=s&&t.jsxs(t.Fragment,{children:[t.jsx(a,{label:"Creation Date",value:V}),t.jsx(a,{label:"Public",value:l.Public?"Yes":"No"})]}),e[28]=V,e[29]=l.Public,e[30]=s,e[31]=I):I=e[31];let h;e[32]!==v||e[33]!==m||e[34]!==x||e[35]!==b||e[36]!==f||e[37]!==j||e[38]!==d||e[39]!==I||e[40]!==r||e[41]!==c||e[42]!==n||e[43]!==u||e[44]!==D?(h=t.jsxs(T.Content,{children:[r,c,n,u,D,v,m,x,b,f,j,d,I]}),e[32]=v,e[33]=m,e[34]=x,e[35]=b,e[36]=f,e[37]=j,e[38]=d,e[39]=I,e[40]=r,e[41]=c,e[42]=n,e[43]=u,e[44]=D,e[45]=h):h=e[45];let N;e[46]!==h||e[47]!==o?(N=t.jsxs(T,{children:[o,h]}),e[46]=h,e[47]=o,e[48]=N):N=e[48];let p;e[49]!==l.BlockDeviceMappings||e[50]!==s?(p=s&&l.BlockDeviceMappings&&l.BlockDeviceMappings.length>0&&l.BlockDeviceMappings.map(B),e[49]=l.BlockDeviceMappings,e[50]=s,e[51]=p):p=e[51];let E;return e[52]!==N||e[53]!==p?(E=t.jsxs(t.Fragment,{children:[N,p]}),e[52]=N,e[53]=p,e[54]=E):E=e[54],E}function B(i){return t.jsxs(T,{children:[t.jsxs(T.Header,{children:["Block Device: ",i.DeviceName]}),t.jsxs(T.Content,{children:[t.jsx(a,{label:"Device Name",value:i.DeviceName}),i.Ebs&&t.jsxs(t.Fragment,{children:[t.jsx(a,{label:"Volume Size",value:i.Ebs.VolumeSize?`${i.Ebs.VolumeSize} GB`:void 0}),t.jsx(a,{label:"Volume Type",value:i.Ebs.VolumeType}),t.jsx(a,{label:"Delete On Termination",value:i.Ebs.DeleteOnTermination?"Yes":"No"}),t.jsx(a,{label:"Encrypted",value:i.Ebs.Encrypted?"Yes":"No"}),i.Ebs.SnapshotId&&t.jsx(a,{label:"Snapshot ID",value:i.Ebs.SnapshotId})]}),i.VirtualName&&t.jsx(a,{label:"Virtual Name",value:i.VirtualName})]})]},i.DeviceName)}export{C as A};
@@ -1 +0,0 @@
import{h as o,c as r,j as l,L as a}from"./index.js";const m=[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]],x=o("arrow-left",m);function p(i){const e=r.c(7);let s,t;e[0]!==i?({children:s,...t}=i,e[0]=i,e[1]=s,e[2]=t):(s=e[1],t=e[2]);let n;e[3]===Symbol.for("react.memo_cache_sentinel")?(n=l.jsx(x,{className:"size-4"}),e[3]=n):n=e[3];let c;return e[4]!==s||e[5]!==t?(c=l.jsxs(a,{className:"mb-6 inline-flex items-center gap-2 text-sm",...t,children:[n,s]}),e[4]=s,e[5]=t,e[6]=c):c=e[6],c}export{p as B};
-1
View File
@@ -1 +0,0 @@
import{c as g,a7 as b,a as f,bz as p,cb as m}from"./index.js";const h=p("group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-[0.625rem] transition-all transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",{variants:{variant:{default:"bg-primary text-primary-foreground [a]:hover:bg-primary/80",secondary:"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",destructive:"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",outline:"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",ghost:"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",link:"text-primary underline-offset-4 hover:underline"}},defaultVariants:{variant:"default"}});function y(d){const e=g.c(13);let t,i,a,s,r;if(e[0]!==d){const{className:l,variant:u,render:v,...c}=d;t=v,r=u===void 0?"default":u,s=m,i="span",a=b({className:f(h({className:l,variant:r}))},c),e[0]=d,e[1]=t,e[2]=i,e[3]=a,e[4]=s,e[5]=r}else t=e[1],i=e[2],a=e[3],s=e[4],r=e[5];let n;e[6]!==r?(n={slot:"badge",variant:r},e[6]=r,e[7]=n):n=e[7];let o;return e[8]!==t||e[9]!==i||e[10]!==a||e[11]!==n?(o={defaultTagName:i,props:a,render:t,state:n},e[8]=t,e[9]=i,e[10]=a,e[11]=n,e[12]=o):o=e[12],s(o)}export{y as B};
-1
View File
@@ -1 +0,0 @@
import{h as c}from"./index.js";const e=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],t=c("check",e);export{t as C};
@@ -1 +0,0 @@
import{c as R,u as A,j as t,f as H,B as P}from"./index.js";import{a as L,u as M,I as T,F as $,b as q,c as z}from"./field.js";import{B as D}from"./back-link.js";import{E as G}from"./error-banner.js";import{P as J}from"./page-heading.js";import{u as K}from"./s3.js";function Y(){const e=R.c(40),r=A(),s=K();let x;e[0]===Symbol.for("react.memo_cache_sentinel")?(x={resolver:L(H)},e[0]=x):x=e[0];const{handleSubmit:N,register:y,formState:w}=M(x),{errors:k,isSubmitting:S}=w;let p;e[1]!==s||e[2]!==r?(p=async I=>{await s.mutateAsync(I),r({to:"/s3/ls"})},e[1]=s,e[2]=r,e[3]=p):p=e[3];const F=p;let h,j;e[4]===Symbol.for("react.memo_cache_sentinel")?(h=t.jsx(D,{to:"/s3/ls",children:"Back to buckets"}),j=t.jsx(J,{title:"Create Bucket"}),e[4]=h,e[5]=j):(h=e[4],j=e[5]);let i;e[6]!==s.error?(i=s.error&&t.jsx(G,{error:s.error,msg:"Failed to create bucket"}),e[6]=s.error,e[7]=i):i=e[7];let l;e[8]!==N||e[9]!==F?(l=N(F),e[8]=N,e[9]=F,e[10]=l):l=e[10];let g;e[11]===Symbol.for("react.memo_cache_sentinel")?(g=t.jsx(q,{children:t.jsx("label",{htmlFor:"bucketName",children:"Bucket Name"})}),e[11]=g):g=e[11];const C=!!k.bucketName;let a;e[12]!==y?(a=y("bucketName"),e[12]=y,e[13]=a):a=e[13];let o;e[14]!==C||e[15]!==a?(o=t.jsx(T,{"aria-invalid":C,id:"bucketName",placeholder:"my-bucket",type:"text",...a}),e[14]=C,e[15]=a,e[16]=o):o=e[16];let c;e[17]!==k.bucketName?(c=t.jsx($,{errors:[k.bucketName]}),e[17]=k.bucketName,e[18]=c):c=e[18];let n;e[19]!==o||e[20]!==c?(n=t.jsxs(z,{children:[g,o,c]}),e[19]=o,e[20]=c,e[21]=n):n=e[21];const v=S||s.isPending;let m;e[22]!==r?(m=()=>r({to:"/s3/ls"}),e[22]=r,e[23]=m):m=e[23];let u;e[24]!==v||e[25]!==m?(u=t.jsx(P,{disabled:v,onClick:m,type:"button",variant:"outline",children:"Cancel"}),e[24]=v,e[25]=m,e[26]=u):u=e[26];const _=S||s.isPending,E=S||s.isPending?"Creating…":"Create Bucket";let f;e[27]!==_||e[28]!==E?(f=t.jsx(P,{disabled:_,type:"submit",children:E}),e[27]=_,e[28]=E,e[29]=f):f=e[29];let d;e[30]!==u||e[31]!==f?(d=t.jsxs("div",{className:"flex gap-2",children:[u,f]}),e[30]=u,e[31]=f,e[32]=d):d=e[32];let b;e[33]!==n||e[34]!==d||e[35]!==l?(b=t.jsxs("form",{className:"max-w-4xl space-y-6",onSubmit:l,children:[n,d]}),e[33]=n,e[34]=d,e[35]=l,e[36]=b):b=e[36];let B;return e[37]!==b||e[38]!==i?(B=t.jsxs(t.Fragment,{children:[h,j,i,b]}),e[37]=b,e[38]=i,e[39]=B):B=e[39],B}export{Y as component};
File diff suppressed because one or more lines are too long

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