mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-24 05:04:03 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d5fd7d2c | |||
| b857c7982e | |||
| 965e65df3b | |||
| 4c4683ba0c | |||
| a458a28406 | |||
| 2351d4ff81 | |||
| e7a3b5b712 | |||
| 36f820c859 | |||
| c60a691ac1 | |||
| 21fd5a7081 | |||
| 2b455e365e | |||
| e6a09d371c | |||
| 6f3f348287 | |||
| 8e8b17c599 | |||
| 72aa3e7563 | |||
| 096b3380fc | |||
| 663805cd23 | |||
| 380ab2a870 | |||
| 98c75cbb83 | |||
| 3ffe67271f | |||
| 4466de04d3 | |||
| fd91b80851 | |||
| 0853eb5068 | |||
| 3d8c9f3812 | |||
| 2cac5e2c37 | |||
| 0ec13f33eb | |||
| 7af16b75b9 | |||
| 40e09f0886 | |||
| e26cd7fd82 | |||
| 8f223d35d3 | |||
| b0dbc76b22 | |||
| 506bced4fa | |||
| 438ec3c973 | |||
| 11a1f18cb0 | |||
| 1ff08ab80a | |||
| d17eef2ba9 | |||
| b1059899a2 | |||
| 17da8b2eb2 | |||
| ee3df227a7 | |||
| 015dbbc272 | |||
| 404b3d6246 | |||
| 021b0982f4 | |||
| 7b590957e1 | |||
| c9c3cda651 | |||
| 1efb4c4a3b | |||
| 05cb4ac108 | |||
| 6eac4d6598 | |||
| f03a2314c6 | |||
| 2c444b1eba | |||
| aea2e2c05a | |||
| d90bf53eaf | |||
| 14c52366da | |||
| 6737c42601 | |||
| 19988af768 | |||
| d20fcd2c38 | |||
| 1c6950b89e | |||
| 94ec56bb59 | |||
| 0b2e613c6c | |||
| 2d304528b8 | |||
| 35f2104325 | |||
| c4027a0380 | |||
| 77c6d05042 | |||
| 8af3d2e5fa | |||
| 49c5807ef1 | |||
| 5fad038b87 | |||
| 48c0565d47 | |||
| 371193a832 | |||
| 9d66ef8233 | |||
| 992111e809 | |||
| 12c2e2789e | |||
| 3720a5ba90 | |||
| de0e049b16 | |||
| 6e3fe148af | |||
| 81b990a238 | |||
| 52f2498699 | |||
| 61e8eb9d91 | |||
| 1453beca6b | |||
| d0056ee925 | |||
| 55486490aa | |||
| b370f6336e | |||
| 1910347cce | |||
| e08b8140a6 | |||
| a7e0cb6972 | |||
| 6b9fc7b600 | |||
| c2e66e1ab4 | |||
| deae613a75 | |||
| 8188707c91 | |||
| b9c528ce4c | |||
| 5992d3f056 | |||
| 6bc36455fc | |||
| db45c028be | |||
| ff6ca8a057 | |||
| df0e8a6459 | |||
| 3dc3d41d79 | |||
| eba702b1f2 | |||
| e6ffecafca | |||
| 27a27f660a | |||
| b4dcd3ead9 | |||
| 43acb57b9f | |||
| 17c011e9db | |||
| a330e35d5f | |||
| a5ff1ca5a7 | |||
| 8573800914 | |||
| cefbcea485 | |||
| 1bf4a9cfab |
+164
-48
@@ -1,80 +1,196 @@
|
||||
name: Hive E2E Test
|
||||
name: Hive 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@v2
|
||||
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: 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: ./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/hive && ./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@v2
|
||||
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: 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: ./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/hive && ./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@v2
|
||||
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: 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: ./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/hive && ./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
|
||||
|
||||
+49
-19
@@ -11,13 +11,6 @@ on:
|
||||
- go.sum
|
||||
- Makefile
|
||||
- .github/workflows/hive.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- Makefile
|
||||
- .github/workflows/hive.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -30,35 +23,71 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: hive
|
||||
fetch-depth: 2
|
||||
|
||||
- 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: Setup Go Workspace
|
||||
run: go work init ./hive ./viperblock ./predastore
|
||||
|
||||
- name: Build Hive
|
||||
run: make -C hive build
|
||||
|
||||
- name: Run Tests
|
||||
run: make -C hive test
|
||||
- name: Run Tests with Coverage
|
||||
run: make -C hive test-cover
|
||||
|
||||
- name: Check Diff Coverage
|
||||
run: cd hive && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
race_detection:
|
||||
name: Hive Race Detection
|
||||
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
|
||||
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: "hive/go.sum"
|
||||
go-version-file: "hive/go.mod"
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./hive ./viperblock ./predastore
|
||||
|
||||
- name: Run Tests with Race Detector
|
||||
run: make -C hive test-race
|
||||
|
||||
lint_and_security:
|
||||
name: Hive Lint and Security
|
||||
@@ -74,28 +103,29 @@ jobs:
|
||||
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: Setup Go Workspace
|
||||
run: go work init ./hive ./viperblock ./predastore
|
||||
|
||||
- name: Check Format
|
||||
run: make -C hive check-format
|
||||
|
||||
- name: Check Modernize
|
||||
run: make -C hive check-modernize
|
||||
|
||||
- name: Go Vet
|
||||
run: make -C hive vet
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
distro:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: hive
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
path: predastore
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
path: viperblock
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up QEMU (for arm64 cross-build)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Build distribution tarballs
|
||||
run: cd hive && make distro VERSION=${{ github.ref_name }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
hive/dist/*.tar.gz
|
||||
hive/dist/*.sha256
|
||||
hive/scripts/setup.sh
|
||||
generate_release_notes: true
|
||||
@@ -4,11 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- ui
|
||||
paths:
|
||||
- hive/services/hiveui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
- dev
|
||||
paths:
|
||||
- hive/services/hiveui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
@@ -46,5 +42,8 @@ jobs:
|
||||
- name: Run Biome Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
@@ -13,6 +13,7 @@ go.work.sum
|
||||
|
||||
# Build outputs
|
||||
bin/
|
||||
dist/
|
||||
tmp/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
+1
-925
@@ -1,927 +1,3 @@
|
||||
# Hive 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 Hive see [https://docs.mulgadc.com](https://docs.mulgadc.com/)
|
||||
|
||||
@@ -33,6 +33,19 @@ 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
|
||||
_RACEQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' || true; }; exit $${PIPESTATUS[0]}
|
||||
_SECQ = >
|
||||
else
|
||||
_Q =
|
||||
_COVQ = || true
|
||||
_RACEQ =
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
|
||||
build:
|
||||
$(MAKE) go_build
|
||||
|
||||
@@ -42,9 +55,13 @@ build-ui:
|
||||
cd hive/services/hiveui/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/hive/cmd/hive/cmd.Version=$(VERSION) -X github.com/mulgadc/hive/cmd/hive/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/hive/main.go
|
||||
|
||||
go_run:
|
||||
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
|
||||
@@ -52,13 +69,32 @@ 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 ./hive/...
|
||||
|
||||
# Run unit tests with coverage profile
|
||||
# Note: go test may exit non-zero due to Go version mismatch in coverage instrumentation
|
||||
# for packages without test files. We check actual test results + coverage threshold instead.
|
||||
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 ./hive/... $(_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 ./hive/... $(_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)...."
|
||||
@@ -79,12 +115,13 @@ install-system:
|
||||
apt-get update && sudo apt-get install -y \
|
||||
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,73 @@ 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 for both architectures via Docker
|
||||
distro:
|
||||
@echo "Building hive $(VERSION) distribution tarballs..."
|
||||
@mkdir -p dist/
|
||||
@echo "Building linux/amd64..."
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
-f build/Dockerfile.distro \
|
||||
--output type=local,dest=dist/amd64/ \
|
||||
../
|
||||
tar -czf dist/hive-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
|
||||
sha256sum dist/hive-$(VERSION)-linux-amd64.tar.gz > dist/hive-$(VERSION)-linux-amd64.tar.gz.sha256
|
||||
@echo "Building linux/arm64..."
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
-f build/Dockerfile.distro \
|
||||
--output type=local,dest=dist/arm64/ \
|
||||
../
|
||||
tar -czf dist/hive-$(VERSION)-linux-arm64.tar.gz -C dist/arm64 .
|
||||
sha256sum dist/hive-$(VERSION)-linux-arm64.tar.gz > dist/hive-$(VERSION)-linux-arm64.tar.gz.sha256
|
||||
@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-clean:
|
||||
rm -rf dist/
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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-clean
|
||||
|
||||
@@ -52,23 +52,30 @@ 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 Hive.
|
||||
|
||||
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=hive
|
||||
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
|
||||
|
||||
hive 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
|
||||
|
||||
@@ -79,12 +86,6 @@ Hive coordinates these independent components:
|
||||
|
||||
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
|
||||
@@ -116,12 +117,6 @@ 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.
|
||||
|
||||
## License
|
||||
|
||||
@@ -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 go.work first (lives in hive/)
|
||||
COPY hive/go.work ./hive/
|
||||
|
||||
# Copy module files for dependency caching
|
||||
COPY hive/go.mod hive/go.sum ./hive/
|
||||
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/
|
||||
|
||||
# Download dependencies (cached layer)
|
||||
RUN cd hive && go mod download
|
||||
|
||||
# Copy full source
|
||||
COPY hive/ ./hive/
|
||||
COPY predastore/ ./predastore/
|
||||
COPY viperblock/ ./viperblock/
|
||||
|
||||
# Build hive binary
|
||||
RUN COMMIT=$(cd hive && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
|
||||
cd hive && go build -ldflags "-s -w \
|
||||
-X github.com/mulgadc/hive/cmd/hive/cmd.Version=${VERSION} \
|
||||
-X github.com/mulgadc/hive/cmd/hive/cmd.Commit=${COMMIT}" \
|
||||
-o /out/hive cmd/hive/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 hive/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
RUN cp hive/scripts/setup-ovn.sh /out/setup-ovn.sh
|
||||
RUN mkdir -p /out/systemd && cp hive/build/systemd/* /out/systemd/
|
||||
RUN cp hive/build/logrotate/hive /out/logrotate-hive
|
||||
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /out/ /
|
||||
@@ -0,0 +1,9 @@
|
||||
/var/log/hive/*.log {
|
||||
daily
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Hive AWS Gateway
|
||||
PartOf=hive.target
|
||||
After=hive-nats.service hive-daemon.service
|
||||
Requires=hive-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
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/hive service awsgw start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=HIVE_CONFIG_PATH=/etc/hive/hive.toml
|
||||
Environment=HIVE_BASE_DIR=/var/lib/hive/
|
||||
Environment=HIVE_AWSGW_TLS_CERT=/etc/hive/server.pem
|
||||
Environment=HIVE_AWSGW_TLS_KEY=/etc/hive/server.key
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Hive Daemon (VM Orchestration)
|
||||
PartOf=hive.target
|
||||
After=hive-nats.service hive-viperblock.service
|
||||
Requires=hive-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
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/hive service hive start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=HIVE_CONFIG_PATH=/etc/hive/hive.toml
|
||||
Environment=HIVE_BASE_DIR=/var/lib/hive/hive/
|
||||
Environment=HIVE_WAL_DIR=/var/lib/hive/hive/
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Hive NATS Message Bus
|
||||
PartOf=hive.target
|
||||
Before=hive-predastore.service hive-daemon.service hive-awsgw.service hive-vpcd.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
ExecStart=/usr/local/bin/hive service nats start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=HIVE_CONFIG_PATH=/etc/hive/nats/nats.conf
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Hive Predastore (S3-compatible Object Storage)
|
||||
PartOf=hive.target
|
||||
After=hive-nats.service
|
||||
Requires=hive-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
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/hive service predastore start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=HIVE_PREDASTORE_CONFIG_PATH=/etc/hive/predastore/predastore.toml
|
||||
Environment=HIVE_PREDASTORE_BASE_PATH=/var/lib/hive/predastore/
|
||||
Environment=HIVE_PREDASTORE_TLS_CERT=/etc/hive/server.pem
|
||||
Environment=HIVE_PREDASTORE_TLS_KEY=/etc/hive/server.key
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Hive Web UI
|
||||
PartOf=hive.target
|
||||
After=hive-awsgw.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
ExecStart=/usr/local/bin/hive service hive-ui start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Hive Viperblock (Block Storage)
|
||||
PartOf=hive.target
|
||||
After=hive-nats.service hive-predastore.service
|
||||
Requires=hive-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
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/hive service viperblock start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
EnvironmentFile=/etc/hive/systemd.env
|
||||
Environment=HIVE_CONFIG_PATH=/etc/hive/hive.toml
|
||||
Environment=HIVE_BASE_DIR=/var/lib/hive/viperblock/
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Hive VPC Daemon (OVN Networking)
|
||||
PartOf=hive.target
|
||||
After=hive-nats.service hive-daemon.service
|
||||
Requires=hive-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hive
|
||||
Group=hive
|
||||
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/hive service vpcd start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=HIVE_CONFIG_PATH=/etc/hive/hive.toml
|
||||
|
||||
[Install]
|
||||
WantedBy=hive.target
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Hive Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=hive-nats.service hive-predastore.service hive-viperblock.service hive-daemon.service hive-awsgw.service hive-vpcd.service hive-ui.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+377
-56
@@ -19,6 +19,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,13 +32,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/hive/hive/admin"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/formation"
|
||||
handlers_ec2_vpc "github.com/mulgadc/hive/hive/handlers/ec2/vpc"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/hive/hive/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"
|
||||
)
|
||||
@@ -128,6 +134,28 @@ var imagesListCmd = &cobra.Command{
|
||||
Run: runimagesListCmd,
|
||||
}
|
||||
|
||||
var accountCmd = &cobra.Command{
|
||||
Use: "account",
|
||||
Short: "Manage Hive accounts",
|
||||
Long: `Create and manage Hive 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 Hive 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 Hive accounts with their ID, name, status, and creation time.`,
|
||||
Run: runAccountList,
|
||||
}
|
||||
|
||||
/*
|
||||
CLI ideas
|
||||
|
||||
@@ -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("hive-dir", DefaultDataDir(), "Hive base directory")
|
||||
|
||||
// Flags for admin init
|
||||
adminInitCmd.Flags().Bool("force", false, "Force re-initialization (overwrites existing config)")
|
||||
@@ -225,8 +255,7 @@ 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")
|
||||
@@ -421,8 +450,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,
|
||||
}
|
||||
|
||||
@@ -532,12 +561,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
// 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...")
|
||||
@@ -560,28 +584,55 @@ 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: hive\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")
|
||||
hiveRoot = DefaultDataDir()
|
||||
}
|
||||
hiveRoot = filepath.Clean(hiveRoot)
|
||||
|
||||
// Determine if this is a multi-node formation
|
||||
isMultiNode := nodes >= 2 && bindIP != "0.0.0.0"
|
||||
@@ -642,7 +693,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)
|
||||
@@ -663,6 +714,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
Region: region,
|
||||
NatsToken: natsToken,
|
||||
DataDir: hiveRoot,
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
Az: az,
|
||||
@@ -689,15 +741,14 @@ 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")
|
||||
@@ -705,6 +756,16 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
admin.CreateServiceDirectories(hiveRoot)
|
||||
|
||||
// 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(hiveRoot, sudoUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Print success message
|
||||
fmt.Println("\n🎉 Hive initialization complete!")
|
||||
fmt.Println("\n📋 Next steps:")
|
||||
@@ -734,6 +795,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 +822,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,
|
||||
@@ -816,7 +896,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)
|
||||
@@ -841,6 +921,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
Region: region,
|
||||
NatsToken: natsToken,
|
||||
DataDir: hiveRoot,
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
Az: az,
|
||||
@@ -873,9 +954,9 @@ 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")
|
||||
@@ -945,12 +1026,7 @@ 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...")
|
||||
@@ -1051,12 +1127,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 +1154,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)
|
||||
@@ -1116,7 +1204,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)
|
||||
@@ -1145,6 +1233,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
Region: creds.Region,
|
||||
NatsToken: creds.NatsToken,
|
||||
DataDir: dataDir,
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
Az: az,
|
||||
@@ -1178,9 +1267,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")
|
||||
@@ -1223,3 +1312,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 := "hive-" + 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)
|
||||
}
|
||||
|
||||
+14
-6
@@ -19,11 +19,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/types"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/pterm/pterm"
|
||||
@@ -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/hive.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 {
|
||||
@@ -153,9 +161,9 @@ func runGetNodes(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -227,14 +235,14 @@ func runGetVMs(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultConfigDir returns the default configuration directory.
|
||||
// Production: /etc/hive (when running as root or /etc/hive exists)
|
||||
// Development: ~/hive/config
|
||||
func DefaultConfigDir() string {
|
||||
if isProductionLayout() {
|
||||
return "/etc/hive"
|
||||
}
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, "hive", "config")
|
||||
}
|
||||
|
||||
// DefaultDataDir returns the default data directory.
|
||||
// Production: /var/lib/hive
|
||||
// Development: ~/hive
|
||||
func DefaultDataDir() string {
|
||||
if isProductionLayout() {
|
||||
return "/var/lib/hive"
|
||||
}
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, "hive")
|
||||
}
|
||||
|
||||
// DefaultConfigFile returns the default path to hive.toml.
|
||||
func DefaultConfigFile() string {
|
||||
return filepath.Join(DefaultConfigDir(), "hive.toml")
|
||||
}
|
||||
|
||||
// isProductionLayout returns true when running in a production install.
|
||||
// Detected by: running as root, or /etc/hive directory exists.
|
||||
func isProductionLayout() bool {
|
||||
if os.Getuid() == 0 {
|
||||
if info, err := os.Stat("/etc/hive"); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigDir_Dev(t *testing.T) {
|
||||
// Non-root user without /etc/hive should get dev paths
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "hive", "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, "hive")
|
||||
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, "hive", "config", "hive.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")
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,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., hive --help)
|
||||
}
|
||||
}
|
||||
|
||||
+130
-18
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/mulgadc/hive/hive/services/nats"
|
||||
"github.com/mulgadc/hive/hive/services/predastore"
|
||||
"github.com/mulgadc/hive/hive/services/viperblockd"
|
||||
"github.com/mulgadc/hive/hive/services/vpcd"
|
||||
"github.com/mulgadc/predastore/s3"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -62,6 +63,11 @@ var awsgwCmd = &cobra.Command{
|
||||
Short: "Manage the awsgw (AWS gateway) service",
|
||||
}
|
||||
|
||||
var vpcdCmd = &cobra.Command{
|
||||
Use: "vpcd",
|
||||
Short: "Manage the vpcd (VPC daemon) service",
|
||||
}
|
||||
|
||||
var hiveUICmd = &cobra.Command{
|
||||
Use: "hive-ui",
|
||||
Aliases: []string{"ui", "hiveui"},
|
||||
@@ -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")
|
||||
},
|
||||
@@ -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")
|
||||
@@ -444,7 +463,10 @@ var hiveStartCmd = &cobra.Command{
|
||||
hiveSvc.SetConfigPath(cfgFile)
|
||||
}
|
||||
|
||||
svc.Start()
|
||||
if _, err = svc.Start(); err != nil {
|
||||
fmt.Println("Error starting hive service:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("HIVE service started")
|
||||
},
|
||||
}
|
||||
@@ -462,7 +484,10 @@ var hiveStopCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
service.Stop()
|
||||
if err = service.Stop(); err != nil {
|
||||
fmt.Println("Error stopping hive service:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Hive service stopped")
|
||||
},
|
||||
@@ -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")
|
||||
},
|
||||
@@ -594,7 +624,10 @@ var hiveUIStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
svc.Start()
|
||||
if _, err = svc.Start(); err != nil {
|
||||
fmt.Println("Error starting hive-ui service:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("hive-ui service started")
|
||||
},
|
||||
}
|
||||
@@ -612,7 +645,10 @@ var hiveUIStopCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
svc.Stop()
|
||||
if err = svc.Stop(); err != nil {
|
||||
fmt.Println("Error stopping hive-ui service:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("hive-ui service stopped")
|
||||
},
|
||||
}
|
||||
@@ -638,6 +674,75 @@ var hiveUIStatusCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
@@ -817,4 +922,11 @@ func init() {
|
||||
hiveUICmd.AddCommand(hiveUIStopCmd)
|
||||
hiveUICmd.AddCommand(hiveUIStatusCmd)
|
||||
|
||||
// vpcd
|
||||
serviceCmd.AddCommand(vpcdCmd)
|
||||
|
||||
vpcdCmd.AddCommand(vpcdStartCmd)
|
||||
vpcdCmd.AddCommand(vpcdStopCmd)
|
||||
vpcdCmd.AddCommand(vpcdStatusCmd)
|
||||
|
||||
}
|
||||
|
||||
@@ -4,10 +4,3 @@ 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"] },
|
||||
]
|
||||
|
||||
@@ -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 = "tcp:127.0.0.1:6641"
|
||||
ovn_sb_addr = "tcp:127.0.0.1:6642"
|
||||
|
||||
[nodes.{{.Node}}.predastore]
|
||||
host = "{{.BindIP}}:8443"
|
||||
region = "{{.Region}}"
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
# Generated by Hive 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,8 +20,7 @@ cluster {
|
||||
{{- end }}
|
||||
]
|
||||
}
|
||||
{{ end }}
|
||||
|
||||
{{- end }}
|
||||
|
||||
server_name: hive-nats-{{.Node}}
|
||||
|
||||
@@ -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 = "hive-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 = "hive-iam-access-keys"
|
||||
+3
-3
@@ -22,7 +22,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/types"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -62,9 +62,9 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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/hive/cmd/hive/cmd.Version=v1.0.0"
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the Hive version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("hive %s (%s) %s/%s\n", Version, Commit, runtime.GOOS, runtime.GOARCH)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+197
-76
@@ -1,22 +1,98 @@
|
||||
# AWS Command Implementation Matrix
|
||||
# Command Implementation Matrix
|
||||
|
||||
## EC2 - Instance Management
|
||||
## Hive 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 Hive cluster services. Each service subcommand supports `start`, `stop`, and `status` operations.
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `hive 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** |
|
||||
| `hive service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
|
||||
| `hive service predastore status` | — | None | Reports predastore service status | **DONE** |
|
||||
| `hive 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/hive/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** |
|
||||
| `hive service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
|
||||
| `hive service viperblock status` | — | None | Reports viperblock service status | **DONE** |
|
||||
| `hive 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** |
|
||||
| `hive service nats stop` | — | NATS must be running | Stops the NATS service | **DONE** |
|
||||
| `hive service nats status` | — | None | Reports NATS service status | **DONE** |
|
||||
| `hive service hive start` | `--wal-dir` | `--config` required | Loads cluster config → starts hive daemon (VM orchestration, NATS subscriptions, health endpoint) | **DONE** |
|
||||
| `hive service hive stop` | — | Hive daemon must be running | Stops the hive daemon service | **DONE** |
|
||||
| `hive service hive status` | — | None | Reports hive daemon service status | **DONE** |
|
||||
| `hive 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** |
|
||||
| `hive service awsgw stop` | — | AWS gateway must be running | Stops the AWS gateway service | **DONE** |
|
||||
| `hive service awsgw status` | — | None | Reports AWS gateway service status | **DONE** |
|
||||
| `hive service hive-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`, `hiveui` | **DONE** |
|
||||
| `hive service hive-ui stop` | — | hive-ui must be running | Stops the hive-ui service | **DONE** |
|
||||
| `hive service hive-ui status` | — | None | Reports hive-ui service status | **DONE** |
|
||||
| `hive 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** |
|
||||
| `hive service vpcd stop` | — | vpcd must be running | Stops the vpcd service | **DONE** |
|
||||
| `hive 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 |
|
||||
|---------|-------|---------------|-------------|----------------|--------|
|
||||
| `hive get nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Loads config → publishes to `hive.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** |
|
||||
| `hive get vms` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `hive.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `hive get instances` | INSTANCE, STATUS, TYPE, VCPU, MEM, NODE, IP, AGE | **DONE** |
|
||||
|
||||
### Resource Monitoring
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Output | Status |
|
||||
|---------|-------|---------------|-------------|--------|--------|
|
||||
| `hive top nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `hive.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 |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive 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 hive.toml, awsgw.toml, predastore.toml → configures AWS CLI `hive` profile → creates directory structure under `~/hive/` | 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 `hive` auto-configured | **DONE** |
|
||||
| `hive 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 |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive 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 |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `hive-account-counter:next_id` for sequential 12-digit ID → creates Account record in `hive-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** |
|
||||
| `hive 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 |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `hive 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** |
|
||||
| `hive 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 `hive-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 `hive 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** |
|
||||
|
||||
## 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 `hive-workers` queue group → daemon stores `EbsEncryptionByDefault=true` in JetStream KV (`hive-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 `hive-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 `hive-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 `hive-workers` queue group → daemon stores `SerialConsoleAccessEnabled=true` in JetStream KV (`hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 Hive v1. IPAM uses CAS-based allocation from NATS KV (`hive-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 `hive-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `hive-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 `hive-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-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 `hive-vpc-enis` KV bucket with CAS-based IPAM allocation from `hive-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 `hive-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 `hive-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 `hive-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 `hive-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,7 +407,7 @@ 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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# Hive Documentation
|
||||
|
||||
This directory contains the Hive 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/)
|
||||
@@ -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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
---
|
||||
|
||||
# Creating Accounts
|
||||
|
||||
> Create and manage isolated user accounts with their own resources.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hive supports multi-tenant account isolation. Each account gets its own IAM credentials, AWS CLI profile, and isolated resource namespace.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Create Account
|
||||
|
||||
```bash
|
||||
./bin/hive admin account create --name myteam
|
||||
export AWS_PROFILE=hive-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=hive-myteam
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- 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 Hive 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 "hive-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 HIVE_VPC="vpc-XXX"
|
||||
|
||||
aws ec2 create-subnet --vpc-id $HIVE_VPC --cidr-block 10.200.1.0/24
|
||||
export HIVE_SUBNET="subnet-XXX"
|
||||
```
|
||||
|
||||
## 3. Import AMI
|
||||
|
||||
```bash
|
||||
./bin/hive admin images list
|
||||
./bin/hive admin images import --name debian-12-x86_64
|
||||
export HIVE_AMI="ami-XXX"
|
||||
```
|
||||
|
||||
## 4. Launch Instance
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $HIVE_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name hive-key \
|
||||
--subnet-id $HIVE_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/hive-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Instance stuck in pending
|
||||
|
||||
Check the Hive daemon and QEMU logs for boot errors:
|
||||
|
||||
```bash
|
||||
ls ~/hive/logs/
|
||||
cat ~/hive/logs/daemon.log
|
||||
```
|
||||
|
||||
Verify the AMI exists and architecture matches your host:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --image-ids $HIVE_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/hive-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: "Hive Admin CLI"
|
||||
description: "Complete reference for the Hive administration CLI. Manage accounts, nodes, VMs, and services."
|
||||
category: "Administration"
|
||||
tags:
|
||||
- cli
|
||||
- admin
|
||||
- reference
|
||||
badge: cli
|
||||
resources:
|
||||
- title: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
---
|
||||
|
||||
# Hive Admin CLI
|
||||
|
||||
> Complete reference for the Hive administration CLI.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The `hive` binary is the central administration tool for managing your Hive infrastructure. It provides commands for cluster initialization, account management, node operations, VM lifecycle, and service control.
|
||||
|
||||
**Binary location:** `~/Development/mulga/hive/bin/hive`
|
||||
|
||||
All services in the Hive platform are managed through this single binary.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Account Management
|
||||
|
||||
```bash
|
||||
./bin/hive admin account create --name myteam
|
||||
export AWS_PROFILE=hive-myteam
|
||||
```
|
||||
|
||||
## Node Management
|
||||
|
||||
```bash
|
||||
./bin/hive 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/hive top nodes
|
||||
```
|
||||
|
||||
## Image Management
|
||||
|
||||
```bash
|
||||
./bin/hive admin images list
|
||||
./bin/hive admin images import --name debian-12-arm64
|
||||
```
|
||||
|
||||
## Cluster Shutdown
|
||||
|
||||
```bash
|
||||
./bin/hive admin cluster shutdown
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Permission denied running hive
|
||||
|
||||
The binary may not be executable. Fix permissions:
|
||||
|
||||
```bash
|
||||
chmod +x ./bin/hive
|
||||
```
|
||||
|
||||
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 ~/hive/logs/
|
||||
cat ~/hive/logs/daemon.log
|
||||
```
|
||||
|
||||
Common causes include port conflicts, missing OVN configuration, or untrusted CA certificates.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Hybrid Sync"
|
||||
description: "Synchronize data between Hive and AWS when network connectivity is available."
|
||||
category: "Migration"
|
||||
tags:
|
||||
- hybrid
|
||||
- sync
|
||||
- s3
|
||||
resources:
|
||||
- title: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
---
|
||||
|
||||
# Hybrid Sync
|
||||
|
||||
> Synchronize data between Hive and AWS when network connectivity is available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hive'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 hive --region us-east-1
|
||||
```
|
||||
|
||||
## Pull Cloud to Local
|
||||
|
||||
```bash
|
||||
aws s3 sync s3://cloud-bucket/ s3://local-bucket/ \
|
||||
--source-region us-east-1 --region hive
|
||||
```
|
||||
|
||||
## EBS Volume Backup
|
||||
|
||||
```bash
|
||||
rsync -avz /data/ user@cloud-server:/backup/hive-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 hive --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 hive
|
||||
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 Hive 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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- title: "apt-cacher-ng"
|
||||
url: "https://wiki.debian.org/AptCacherNg"
|
||||
---
|
||||
|
||||
# Air-Gapped Installation
|
||||
|
||||
> Deploy Hive in environments without internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In air-gapped or disconnected environments, Hive 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 Hive 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/hive-deploy/{apt-packages,go-cache,hive-source}
|
||||
cp /var/cache/apt/archives/*.deb /media/hive-deploy/apt-packages/
|
||||
cp -r ~/go/pkg/mod/cache/ /media/hive-deploy/go-cache/
|
||||
cp -r ~/Development/mulga/hive /media/hive-deploy/hive-source/
|
||||
```
|
||||
|
||||
## Step 3. Verify package integrity
|
||||
|
||||
Sign and verify packages before transferring to the target:
|
||||
|
||||
```bash
|
||||
gpg --import /media/hive-deploy/mulga-signing-key.asc
|
||||
gpg --verify hive-v1.0.tar.gz.sig hive-v1.0.tar.gz
|
||||
sha256sum -c /media/hive-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/hive-source ~/Development/mulga/hive
|
||||
cd ~/Development/mulga/hive && 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/hive-deploy/mulga-signing-key.asc
|
||||
gpg --verify hive-v1.0.tar.gz.sig hive-v1.0.tar.gz
|
||||
```
|
||||
|
||||
If verification still fails, re-download the package on the connected machine and compare checksums:
|
||||
|
||||
```bash
|
||||
sha256sum hive-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/hive-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,285 @@
|
||||
---
|
||||
title: "Multi-Node Installation"
|
||||
description: "Deploy Hive 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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
---
|
||||
|
||||
# Multi-Node Installation
|
||||
|
||||
> Deploy Hive across multiple servers to create an availability zone.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Hive 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
|
||||
- **Hive 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 Hive 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 HIVE_NODE1=192.168.1.10
|
||||
export HIVE_NODE2=192.168.1.11
|
||||
export HIVE_NODE3=192.168.1.12
|
||||
```
|
||||
|
||||
To find your server's IP:
|
||||
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
```
|
||||
|
||||
## 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
|
||||
hive setup-ovn --management --encap-ip=$HIVE_NODE1
|
||||
```
|
||||
|
||||
Verify OVN central is ready before proceeding:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
```
|
||||
|
||||
**Server 2 — Compute node (after server 1 is ready):**
|
||||
|
||||
```bash
|
||||
hive setup-ovn --ovn-remote=tcp:$HIVE_NODE1:6642 --encap-ip=$HIVE_NODE2
|
||||
```
|
||||
|
||||
**Server 3 — Compute node (after server 1 is ready):**
|
||||
|
||||
```bash
|
||||
hive setup-ovn --ovn-remote=tcp:$HIVE_NODE1:6642 --encap-ip=$HIVE_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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
## Step 5. Start services and verify
|
||||
|
||||
Start services on **all servers**:
|
||||
|
||||
```bash
|
||||
hive start
|
||||
```
|
||||
|
||||
From any node, verify the cluster:
|
||||
|
||||
```bash
|
||||
hive 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 ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
|
||||
sudo update-ca-certificates
|
||||
export AWS_PROFILE=hive
|
||||
```
|
||||
|
||||
## 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 $HIVE_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name hive-key \
|
||||
--subnet-id $HIVE_SUBNET \
|
||||
--count 3
|
||||
```
|
||||
|
||||
Hive distributes instances across nodes based on available capacity. Check which node each instance landed on:
|
||||
|
||||
```bash
|
||||
hive get vms
|
||||
```
|
||||
|
||||
## Shutdown
|
||||
|
||||
A graceful shutdown coordinates all nodes — drains VMs, flushes storage, and stops services:
|
||||
|
||||
```bash
|
||||
hive 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://$HIVE_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://$HIVE_NODE1:4432/health
|
||||
```
|
||||
|
||||
Review the daemon logs for errors:
|
||||
|
||||
```bash
|
||||
cat ~/hive/logs/daemon.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" ~/hive/logs/nats.log
|
||||
```
|
||||
|
||||
Ensure `--cluster-bind` IPs are reachable between all servers.
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: "Installing Hive"
|
||||
description: "Install Hive 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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
---
|
||||
|
||||
# Installing Hive
|
||||
|
||||
> Install Hive on a single server using the binary installer.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hive 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 Hive on a single server using the binary installer. For multi-server clusters, see [Multi-Node Installation](/docs/installing-hive-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:**
|
||||
- Hive 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 Hive
|
||||
|
||||
```bash
|
||||
curl https://install.mulgadc.com/ | bash
|
||||
```
|
||||
|
||||
The installer downloads the Hive binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
|
||||
|
||||
## Step 2. Initialize a region
|
||||
|
||||
Create your first region and availability zone:
|
||||
|
||||
```bash
|
||||
hive admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
|
||||
```
|
||||
|
||||
## Step 3. Trust the CA certificate
|
||||
|
||||
Hive generates a local CA during initialization. Add it to your system trust store:
|
||||
|
||||
```bash
|
||||
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
## Step 4. Start services
|
||||
|
||||
```bash
|
||||
hive start
|
||||
```
|
||||
|
||||
Set the AWS profile to use the default admin account:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=hive
|
||||
```
|
||||
|
||||
## Step 5. Import an AMI
|
||||
|
||||
List available images and import one matching your architecture:
|
||||
|
||||
```bash
|
||||
hive admin images list
|
||||
hive 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 HIVE_VPC="vpc-XXX"
|
||||
|
||||
aws ec2 create-subnet --vpc-id $HIVE_VPC --cidr-block 10.200.1.0/24
|
||||
export HIVE_SUBNET="subnet-XXX"
|
||||
|
||||
aws ec2 import-key-pair --key-name "hive-key" --public-key-material fileb://~/.ssh/id_rsa.pub
|
||||
|
||||
aws ec2 run-instances \
|
||||
--image-id $HIVE_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name hive-key \
|
||||
--subnet-id $HIVE_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/hive-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## hive 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:~/hive/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 Hive CA is not trusted. Re-add the certificate:
|
||||
|
||||
```bash
|
||||
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
Verify it was added:
|
||||
|
||||
```bash
|
||||
ls -la /usr/local/share/ca-certificates/hive-ca.crt
|
||||
```
|
||||
|
||||
## Instance stuck in pending
|
||||
|
||||
Check the Hive daemon logs for errors:
|
||||
|
||||
```bash
|
||||
ls ~/hive/logs/
|
||||
cat ~/hive/logs/daemon.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
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: "Launching Instances"
|
||||
description: "Launch, manage, and connect to EC2-compatible virtual machines on Hive."
|
||||
category: "Environments"
|
||||
tags:
|
||||
- ec2
|
||||
- instances
|
||||
- vm
|
||||
resources:
|
||||
- title: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- 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 Hive.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hive 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 $HIVE_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name hive-key \
|
||||
--subnet-id $HIVE_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/hive-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 ~/hive/logs/daemon.log
|
||||
aws ec2 describe-images --image-ids $HIVE_AMI
|
||||
```
|
||||
|
||||
If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), import the correct image:
|
||||
|
||||
```bash
|
||||
./bin/hive admin images list
|
||||
./bin/hive 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.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Moving an AWS Workload to Mulga"
|
||||
description: "Migrate existing AWS workloads to Hive using compatible APIs, SDKs, and Terraform."
|
||||
category: "Migration"
|
||||
tags:
|
||||
- migration
|
||||
- aws
|
||||
- terraform
|
||||
badge: new
|
||||
resources:
|
||||
- title: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- 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 Hive using compatible APIs, SDKs, and Terraform.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hive 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=hive
|
||||
aws ec2 describe-instances
|
||||
aws s3 ls
|
||||
```
|
||||
|
||||
## Terraform
|
||||
|
||||
```hcl
|
||||
provider "aws" {
|
||||
region = "ap-southeast-2"
|
||||
access_key = "your-hive-access-key"
|
||||
secret_key = "your-hive-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 hive --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
|
||||
|
||||
Hive 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
|
||||
```
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
title: "Source Install"
|
||||
description: "Build Hive 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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- 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 Hive from source for development, custom builds, or contributing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers building Hive from source code. Use this if you want to contribute to Hive, build custom versions, or inspect the codebase. For production deployments, the [binary installer](/docs/installing-hive) 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 hive 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/hive.git
|
||||
cd hive
|
||||
./scripts/clone-deps.sh # Clone viperblock + predastore
|
||||
./scripts/dev-setup.sh # Setup complete dev environment
|
||||
```
|
||||
|
||||
Confirm `./bin/hive` 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/hive admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
|
||||
```
|
||||
|
||||
## Step 5. Trust the CA certificate
|
||||
|
||||
```bash
|
||||
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
## Step 6. Start services
|
||||
|
||||
```bash
|
||||
./scripts/start-dev.sh
|
||||
export AWS_PROFILE=hive
|
||||
```
|
||||
|
||||
## 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/hive 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 Hive services. Re-add the certificate:
|
||||
|
||||
```bash
|
||||
sudo cp ~/hive/config/ca.pem /usr/local/share/ca-certificates/hive-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
|
||||
```
|
||||
@@ -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: "Hive Repository"
|
||||
url: "https://github.com/mulgadc/hive"
|
||||
- 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. Hive 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 HIVE_VPC="vpc-XXX"
|
||||
```
|
||||
|
||||
## Create Subnet
|
||||
|
||||
```bash
|
||||
aws ec2 create-subnet --vpc-id $HIVE_VPC --cidr-block 10.200.1.0/24
|
||||
export HIVE_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 ~/hive/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
|
||||
```
|
||||
@@ -1,130 +1,138 @@
|
||||
module github.com/mulgadc/hive
|
||||
|
||||
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.0.3
|
||||
github.com/mulgadc/viperblock v1.0.17
|
||||
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
|
||||
golang.org/x/net v0.51.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/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 v1.1.0 // 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/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.1 // 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 (
|
||||
|
||||
@@ -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,8 +27,6 @@ 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=
|
||||
@@ -36,12 +34,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
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 +83,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,6 +91,12 @@ 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/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 v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -101,6 +104,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
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 +113,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 +147,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 +173,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 +184,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 +208,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 +248,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 +267,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.0.3 h1:qkuOEUH9B98QzVByn5osKeXm7SSPgk+ardQ3MibPSok=
|
||||
github.com/mulgadc/predastore v1.0.3/go.mod h1:x3Z6Ts/PONpmVx/R6t/hkHDJ3+0xRieYS3BuuVaNgyA=
|
||||
github.com/mulgadc/viperblock v1.0.17 h1:bGRVlXgcqnmH+Xxon03W/ZsbXqu/EE5+/VG3J4E3EBU=
|
||||
github.com/mulgadc/viperblock v1.0.17/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=
|
||||
@@ -310,18 +333,26 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn
|
||||
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 +360,42 @@ 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 +403,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 +414,59 @@ 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.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/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
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 +477,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 +487,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 +497,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 +513,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 +555,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.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/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 +568,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 +589,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=
|
||||
|
||||
+73
-18
@@ -8,11 +8,14 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
@@ -37,6 +40,7 @@ type ConfigSettings struct {
|
||||
Region string
|
||||
NatsToken string
|
||||
DataDir string
|
||||
ConfigDir string
|
||||
|
||||
// Add more fields as needed
|
||||
Node string
|
||||
@@ -204,6 +208,42 @@ func FileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ChownRecursive changes ownership of a path and all its contents to the
|
||||
// specified username. Used after init to hand production directories back
|
||||
// to the service user when init runs as root via sudo.
|
||||
// Best-effort: errors are logged but do not halt the operation.
|
||||
func ChownRecursive(path, username string) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
|
||||
// Use os.Root to scope filesystem operations and avoid symlink TOCTOU races.
|
||||
// Falls back to direct chown if os.Root is not available.
|
||||
root, rootErr := os.OpenRoot(path)
|
||||
if rootErr != nil {
|
||||
// Fallback: direct chown on the top-level path only
|
||||
if chownErr := os.Chown(path, uid, gid); chownErr != nil { // #nosec G122
|
||||
slog.Debug("chown failed", "path", path, "err", chownErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
_ = fs.WalkDir(root.FS(), ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fullPath := filepath.Join(path, p)
|
||||
if chownErr := os.Lchown(fullPath, uid, gid); chownErr != nil {
|
||||
slog.Debug("chown failed", "path", fullPath, "err", chownErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateAWSINIFile updates or creates an AWS INI file section with given key-value pairs
|
||||
func UpdateAWSINIFile(path, section string, values map[string]string) error {
|
||||
var cfg *ini.File
|
||||
@@ -240,45 +280,57 @@ func UpdateAWSINIFile(path, section string, values map[string]string) error {
|
||||
|
||||
// generateAWSAccessKey generates an AWS-style access key
|
||||
// Format: AKIA + 16 random uppercase alphanumeric characters
|
||||
func GenerateAWSAccessKey() string {
|
||||
func GenerateAWSAccessKey() (string, error) {
|
||||
const prefix = "AKIA"
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 16
|
||||
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crypto/rand failure: %w", err)
|
||||
}
|
||||
result[i] = charset[num.Int64()]
|
||||
}
|
||||
|
||||
return prefix + string(result)
|
||||
return prefix + string(result), nil
|
||||
}
|
||||
|
||||
// generateAWSSecretKey generates an AWS-style secret key
|
||||
// 40 character base64-encoded string
|
||||
func GenerateAWSSecretKey() string {
|
||||
func GenerateAWSSecretKey() (string, error) {
|
||||
bytes := make([]byte, 30) // 30 bytes = 40 chars in base64
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("crypto/rand failure: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
return base64.StdEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateAccountID generates a 12-digit AWS account ID
|
||||
func GenerateAccountID() string {
|
||||
// Generate random 12-digit number
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(900000000000))
|
||||
accountID := num.Int64() + 100000000000 // Ensure it's 12 digits
|
||||
return fmt.Sprintf("%012d", accountID)
|
||||
// SystemAccountID returns the system/root account ID (000000000000).
|
||||
// Used for service-to-service auth credentials baked into config files.
|
||||
func SystemAccountID() string {
|
||||
return "000000000000"
|
||||
}
|
||||
|
||||
// generateNATSToken generates a secure random token for NATS
|
||||
func GenerateNATSToken() string {
|
||||
// DefaultAccountID returns the default admin account ID (000000000001).
|
||||
// This is the first human-facing account created during bootstrap.
|
||||
func DefaultAccountID() string {
|
||||
return "000000000001"
|
||||
}
|
||||
|
||||
// DefaultAccountName returns the default admin account name ("hive").
|
||||
func DefaultAccountName() string {
|
||||
return "hive"
|
||||
}
|
||||
|
||||
// GenerateNATSToken generates a secure random token for NATS
|
||||
func GenerateNATSToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
panic(err)
|
||||
return "", fmt.Errorf("crypto/rand failure: %w", err)
|
||||
}
|
||||
return "nats_" + base64.URLEncoding.EncodeToString(bytes)[:32]
|
||||
return "nats_" + base64.URLEncoding.EncodeToString(bytes)[:32], nil
|
||||
}
|
||||
|
||||
// GenerateCACert generates a Certificate Authority certificate and key
|
||||
@@ -601,7 +653,7 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
|
||||
// GenerateMultiNodePredastoreConfig produces a complete predastore.toml for a
|
||||
// multi-node Predastore cluster. Each node gets its own DB entry (port 6660)
|
||||
// and shard entry (port 9991) on a distinct IP. Node ID 1 is the bootstrap leader.
|
||||
func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region string) (string, error) {
|
||||
func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region, natsToken, configDir, bindIP string) (string, error) {
|
||||
if len(nodes) < 3 {
|
||||
return "", fmt.Errorf("multi-node predastore requires at least 3 nodes, got %d", len(nodes))
|
||||
}
|
||||
@@ -611,7 +663,10 @@ func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNod
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
}{nodes, accessKey, secretKey, region}
|
||||
NatsToken string
|
||||
ConfigDir string
|
||||
BindIP string
|
||||
}{nodes, accessKey, secretKey, region, natsToken, configDir, bindIP}
|
||||
|
||||
tmpl, err := template.New("predastore-multinode").Parse(templateStr)
|
||||
if err != nil {
|
||||
|
||||
+75
-19
@@ -18,7 +18,8 @@ import (
|
||||
// --- Key / Token generation ---
|
||||
|
||||
func TestGenerateAWSAccessKey_Format(t *testing.T) {
|
||||
key := GenerateAWSAccessKey()
|
||||
key, err := GenerateAWSAccessKey()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, key, 20)
|
||||
assert.True(t, strings.HasPrefix(key, "AKIA"))
|
||||
for _, c := range key[4:] {
|
||||
@@ -28,36 +29,44 @@ func TestGenerateAWSAccessKey_Format(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateAWSAccessKey_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateAWSAccessKey(), GenerateAWSAccessKey())
|
||||
k1, err := GenerateAWSAccessKey()
|
||||
assert.NoError(t, err)
|
||||
k2, err := GenerateAWSAccessKey()
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, k1, k2)
|
||||
}
|
||||
|
||||
func TestGenerateAWSSecretKey_Format(t *testing.T) {
|
||||
key := GenerateAWSSecretKey()
|
||||
key, err := GenerateAWSSecretKey()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, key, 40)
|
||||
_, err := base64.StdEncoding.DecodeString(key)
|
||||
_, err = base64.StdEncoding.DecodeString(key)
|
||||
assert.NoError(t, err, "secret key should be valid base64")
|
||||
}
|
||||
|
||||
func TestGenerateAWSSecretKey_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateAWSSecretKey(), GenerateAWSSecretKey())
|
||||
k1, err := GenerateAWSSecretKey()
|
||||
assert.NoError(t, err)
|
||||
k2, err := GenerateAWSSecretKey()
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, k1, k2)
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_Format(t *testing.T) {
|
||||
id := GenerateAccountID()
|
||||
func TestSystemAccountID(t *testing.T) {
|
||||
id := SystemAccountID()
|
||||
assert.Equal(t, "000000000000", id)
|
||||
assert.Len(t, id, 12)
|
||||
for _, c := range id {
|
||||
assert.True(t, c >= '0' && c <= '9', "expected digit, got %c", c)
|
||||
}
|
||||
// First digit should not be 0
|
||||
assert.NotEqual(t, byte('0'), id[0], "12-digit account ID should not start with 0")
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateAccountID(), GenerateAccountID())
|
||||
func TestDefaultAccountID(t *testing.T) {
|
||||
id := DefaultAccountID()
|
||||
assert.Equal(t, "000000000001", id)
|
||||
assert.Len(t, id, 12)
|
||||
}
|
||||
|
||||
func TestGenerateNATSToken_Format(t *testing.T) {
|
||||
token := GenerateNATSToken()
|
||||
token, err := GenerateNATSToken()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(token, "nats_"))
|
||||
assert.Len(t, token, 37) // 5 prefix + 32 random
|
||||
// URL-safe base64: no '+' or '/'
|
||||
@@ -66,7 +75,11 @@ func TestGenerateNATSToken_Format(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateNATSToken_Uniqueness(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateNATSToken(), GenerateNATSToken())
|
||||
t1, err := GenerateNATSToken()
|
||||
assert.NoError(t, err)
|
||||
t2, err := GenerateNATSToken()
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, t1, t2)
|
||||
}
|
||||
|
||||
// --- Config file generation ---
|
||||
@@ -527,7 +540,7 @@ host = "{{.Host}}"
|
||||
{ID: 3, Host: "10.0.0.3"},
|
||||
}
|
||||
|
||||
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1")
|
||||
result, err := GenerateMultiNodePredastoreConfig(tmpl, nodes, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, `host = "10.0.0.1"`)
|
||||
assert.Contains(t, result, `host = "10.0.0.3"`)
|
||||
@@ -539,7 +552,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) {
|
||||
_, err := GenerateMultiNodePredastoreConfig(tmpl, []PredastoreNodeConfig{
|
||||
{ID: 1, Host: "10.0.0.1"},
|
||||
{ID: 2, Host: "10.0.0.2"},
|
||||
}, "AK", "SK", "us-east-1")
|
||||
}, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least 3 nodes")
|
||||
}
|
||||
@@ -547,7 +560,7 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) {
|
||||
func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) {
|
||||
_, err := GenerateMultiNodePredastoreConfig("{{.Unclosed", []PredastoreNodeConfig{
|
||||
{ID: 1, Host: "a"}, {ID: 2, Host: "b"}, {ID: 3, Host: "c"},
|
||||
}, "AK", "SK", "us-east-1")
|
||||
}, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse")
|
||||
}
|
||||
@@ -619,3 +632,46 @@ secretkey = "{{.SecretKey}}"
|
||||
assert.Contains(t, content, `region = "us-east-1"`)
|
||||
assert.Contains(t, content, fmt.Sprintf(`accesskey = "%s"`, settings.AccessKey))
|
||||
}
|
||||
|
||||
func TestChownRecursive_InvalidUser(t *testing.T) {
|
||||
// ChownRecursive should silently return on invalid username
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test"), 0644)
|
||||
|
||||
// Should not panic or error with a non-existent user
|
||||
ChownRecursive(tmpDir, "nonexistent-user-that-does-not-exist-12345")
|
||||
|
||||
// File should still exist and be readable
|
||||
_, err := os.ReadFile(testFile)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestChownRecursive_CurrentUser(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
subDir := filepath.Join(tmpDir, "sub")
|
||||
os.MkdirAll(subDir, 0755)
|
||||
testFile := filepath.Join(subDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test"), 0644)
|
||||
|
||||
// Chown to current user should succeed (no-op effectively)
|
||||
currentUser := os.Getenv("USER")
|
||||
if currentUser == "" {
|
||||
t.Skip("USER env not set")
|
||||
}
|
||||
|
||||
ChownRecursive(tmpDir, currentUser)
|
||||
|
||||
// Verify files are still accessible
|
||||
_, err := os.ReadFile(testFile)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestChownRecursive_NonExistentPath(t *testing.T) {
|
||||
// Should not panic on a path that doesn't exist
|
||||
currentUser := os.Getenv("USER")
|
||||
if currentUser == "" {
|
||||
t.Skip("USER env not set")
|
||||
}
|
||||
ChownRecursive("/tmp/nonexistent-path-12345", currentUser)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func QueryParamsToStruct(params map[string]string, out any) error {
|
||||
v := reflect.ValueOf(out)
|
||||
|
||||
// Must be a pointer to a struct
|
||||
if v.Kind() != reflect.Ptr {
|
||||
if v.Kind() != reflect.Pointer {
|
||||
return fmt.Errorf("out must be a pointer to a struct")
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func setStructFields(v reflect.Value, params map[string]string, prefix string) e
|
||||
}
|
||||
|
||||
// Handle pointer to struct (nested structs)
|
||||
if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct {
|
||||
if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct {
|
||||
for _, queryKey := range queryKeys {
|
||||
// Check if there are any params with this prefix
|
||||
hasParams := false
|
||||
@@ -122,7 +122,7 @@ func setStructFields(v reflect.Value, params map[string]string, prefix string) e
|
||||
}
|
||||
|
||||
// Handle pointer to slice
|
||||
if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Slice {
|
||||
if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Slice {
|
||||
for _, queryKey := range queryKeys {
|
||||
sliceField := reflect.New(field.Type().Elem()).Elem()
|
||||
if err := setSliceField(sliceField, params, queryKey); err != nil {
|
||||
@@ -161,7 +161,7 @@ func setFieldValue(field reflect.Value, value string) error {
|
||||
|
||||
case reflect.String:
|
||||
field.SetString(value)
|
||||
case reflect.Ptr:
|
||||
case reflect.Pointer:
|
||||
// Handle pointer types
|
||||
if field.IsNil() {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
@@ -229,7 +229,7 @@ func setSliceField(field reflect.Value, params map[string]string, prefix string)
|
||||
if val, ok := params[indexPrefix]; ok {
|
||||
elem.SetString(val)
|
||||
}
|
||||
case reflect.Ptr:
|
||||
case reflect.Pointer:
|
||||
// Handle pointer to string
|
||||
if elemType.Elem().Kind() == reflect.String {
|
||||
if val, ok := params[indexPrefix]; ok {
|
||||
|
||||
@@ -379,6 +379,106 @@ func TestQueryParamsToStruct_CompleteRunInstancesExample(t *testing.T) {
|
||||
assert.Equal(t, "Production", aws.StringValue(tagSpec.Tags[1].Value))
|
||||
}
|
||||
|
||||
// --- Gap-filling tests for setFieldValue and setStructFields uncovered branches ---
|
||||
|
||||
func TestQueryParamsToStruct_ByteSliceBase64(t *testing.T) {
|
||||
// ImportKeyPairInput.PublicKeyMaterial is []byte — tests base64 decoding path
|
||||
args := map[string]string{
|
||||
"KeyName": "my-imported-key",
|
||||
"PublicKeyMaterial": "c3NoLXJzYSB0ZXN0LWtleS1kYXRh", // base64 of "ssh-rsa test-key-data"
|
||||
}
|
||||
|
||||
input := &ec2.ImportKeyPairInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "my-imported-key", aws.StringValue(input.KeyName))
|
||||
assert.Equal(t, "ssh-rsa test-key-data", string(input.PublicKeyMaterial))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_ByteSliceRawText(t *testing.T) {
|
||||
// Non-base64 text falls back to raw bytes
|
||||
args := map[string]string{
|
||||
"KeyName": "raw-key",
|
||||
"PublicKeyMaterial": "not-valid-base64!!!",
|
||||
}
|
||||
|
||||
input := &ec2.ImportKeyPairInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("not-valid-base64!!!"), input.PublicKeyMaterial)
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_InvalidIntValue(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"MinCount": "not-a-number",
|
||||
}
|
||||
|
||||
input := &ec2.RunInstancesInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error setting field MinCount")
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_InvalidBoolValue(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"InstanceId.1": "i-1234567890abcdef0",
|
||||
"Force": "not-a-bool",
|
||||
}
|
||||
|
||||
input := &ec2.StopInstancesInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error setting field Force")
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_LocationNameCamelCaseFallback(t *testing.T) {
|
||||
// DeleteTagsInput.Resources has locationName:"resourceId"
|
||||
// AWS query params use "ResourceId" (titled), testing the camelCase → titleCase fallback
|
||||
args := map[string]string{
|
||||
"ResourceId.1": "i-1234567890abcdef0",
|
||||
"ResourceId.2": "i-0987654321fedcba0",
|
||||
"Tag.1.Key": "Environment",
|
||||
"Tag.1.Value": "Production",
|
||||
}
|
||||
|
||||
input := &ec2.DeleteTagsInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, input.Resources, 2)
|
||||
assert.Equal(t, "i-1234567890abcdef0", aws.StringValue(input.Resources[0]))
|
||||
assert.Equal(t, "i-0987654321fedcba0", aws.StringValue(input.Resources[1]))
|
||||
assert.Len(t, input.Tags, 1)
|
||||
assert.Equal(t, "Environment", aws.StringValue(input.Tags[0].Key))
|
||||
assert.Equal(t, "Production", aws.StringValue(input.Tags[0].Value))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_LocationNameDryRun(t *testing.T) {
|
||||
// DryRun has locationName:"dryRun" — tests that titled locationName matches "DryRun" query param
|
||||
args := map[string]string{
|
||||
"InstanceId.1": "i-1234567890abcdef0",
|
||||
"DryRun": "true",
|
||||
}
|
||||
|
||||
input := &ec2.TerminateInstancesInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, aws.BoolValue(input.DryRun))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_NonStructPointer(t *testing.T) {
|
||||
args := map[string]string{"Key": "value"}
|
||||
str := "hello"
|
||||
err := QueryParamsToStruct(args, &str)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must be a pointer to a struct")
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_BlockDeviceMappingsWithIOPS(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"Action": "RunInstances",
|
||||
|
||||
@@ -367,6 +367,7 @@ var (
|
||||
ErrorSecurityGroupLimitExceeded = "SecurityGroupLimitExceeded"
|
||||
ErrorSecurityGroupsPerInstanceLimitExceeded = "SecurityGroupsPerInstanceLimitExceeded"
|
||||
ErrorSecurityGroupsPerInterfaceLimitExceeded = "SecurityGroupsPerInterfaceLimitExceeded"
|
||||
ErrorSerialConsoleSessionUnavailable = "SerialConsoleSessionUnavailable"
|
||||
ErrorServerInternal = "ServerInternal"
|
||||
ErrorServiceUnavailable = "ServiceUnavailable"
|
||||
ErrorSignatureDoesNotMatch = "SignatureDoesNotMatch"
|
||||
@@ -422,8 +423,27 @@ var (
|
||||
ErrorVpnGatewayAttachmentLimitExceeded = "VpnGatewayAttachmentLimitExceeded"
|
||||
ErrorVpnGatewayLimitExceeded = "VpnGatewayLimitExceeded"
|
||||
ErrorZonesMismatched = "ZonesMismatched"
|
||||
|
||||
// IAM-specific error codes
|
||||
ErrorIAMNoSuchEntity = "NoSuchEntity"
|
||||
ErrorIAMEntityAlreadyExists = "EntityAlreadyExists"
|
||||
ErrorIAMDeleteConflict = "DeleteConflict"
|
||||
ErrorIAMLimitExceeded = "LimitExceeded"
|
||||
ErrorIAMInvalidInput = ErrorInvalidInput // alias for IAM usage
|
||||
ErrorIAMMalformedPolicyDocument = "MalformedPolicyDocument"
|
||||
ErrorAccessDenied = "AccessDenied"
|
||||
)
|
||||
|
||||
// ValidErrorCode returns the error code if it exists in ErrorLookup,
|
||||
// otherwise returns ErrorServerInternal. Use this to sanitize error strings
|
||||
// before sending them to clients.
|
||||
func ValidErrorCode(code string) string {
|
||||
if _, ok := ErrorLookup[code]; ok {
|
||||
return code
|
||||
}
|
||||
return ErrorServerInternal
|
||||
}
|
||||
|
||||
var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorAccountDisabled: {HTTPCode: 400, Message: "The functionality you have requested has been administratively disabled for this account."},
|
||||
ErrorActiveVpcPeeringConnectionPerVpcLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of active VPC peering connections you can have for the specified VPC."},
|
||||
@@ -786,6 +806,7 @@ var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorSecurityGroupLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of security groups that you can create, or that you can assign to an instance."},
|
||||
ErrorSecurityGroupsPerInstanceLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of security groups that you can assign to an instance. For more information, see Amazon EC2 security groups."},
|
||||
ErrorSecurityGroupsPerInterfaceLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of security groups you can associate with the specified network interface. For more information, see Amazon VPC quotas."},
|
||||
ErrorSerialConsoleSessionUnavailable: {HTTPCode: 403, Message: "The serial console access is not enabled for this account. Use EnableSerialConsoleAccess to enable access."},
|
||||
ErrorServerInternal: {HTTPCode: 500, Message: "An internal error has occurred. Retry your request, but if the problem persists, contact us with details by posting a message on AWS re:Post."},
|
||||
ErrorServiceUnavailable: {HTTPCode: 503, Message: "The request has failed due to a temporary failure of the server."},
|
||||
ErrorSignatureDoesNotMatch: {HTTPCode: 403, Message: "The request signature that Amazon has does not match the signature that you provided. Check your AWS credentials and signing method."},
|
||||
@@ -841,4 +862,12 @@ var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorVpnGatewayAttachmentLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of VPCs that can be attached to the specified virtual private gateway."},
|
||||
ErrorVpnGatewayLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of virtual private gateways that you can create. For more information about limits, see Amazon VPC quotas."},
|
||||
ErrorZonesMismatched: {HTTPCode: 400, Message: "The Availability Zone for the instance does not match that of the Dedicated Host"},
|
||||
|
||||
// IAM error codes
|
||||
ErrorIAMNoSuchEntity: {HTTPCode: 404, Message: "The request was rejected because it referenced a resource entity that does not exist."},
|
||||
ErrorIAMEntityAlreadyExists: {HTTPCode: 409, Message: "The request was rejected because it attempted to create a resource that already exists."},
|
||||
ErrorIAMDeleteConflict: {HTTPCode: 409, Message: "The request was rejected because it attempted to delete a resource that has attached subordinate entities."},
|
||||
ErrorIAMLimitExceeded: {HTTPCode: 409, Message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
ErrorIAMMalformedPolicyDocument: {HTTPCode: 400, Message: "The policy document is malformed."},
|
||||
ErrorAccessDenied: {HTTPCode: 403, Message: "User is not authorized to perform this action."},
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ func TestErrorLookup(t *testing.T) {
|
||||
{code: "SecurityGroupLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups that you can create, or that you can assign to an instance."},
|
||||
{code: "SecurityGroupsPerInstanceLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups that you can assign to an instance. For more information, see Amazon EC2 security groups."},
|
||||
{code: "SecurityGroupsPerInterfaceLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups you can associate with the specified network interface. For more information, see Amazon VPC quotas."},
|
||||
{code: "SerialConsoleSessionUnavailable", http: 403, message: "The serial console access is not enabled for this account. Use EnableSerialConsoleAccess to enable access."},
|
||||
{code: "ServerInternal", http: 500, message: "An internal error has occurred. Retry your request, but if the problem persists, contact us with details by posting a message on AWS re:Post."},
|
||||
{code: "ServiceUnavailable", http: 503, message: "The request has failed due to a temporary failure of the server."},
|
||||
{code: "SignatureDoesNotMatch", http: 403, message: "The request signature that Amazon has does not match the signature that you provided. Check your AWS credentials and signing method."},
|
||||
@@ -424,6 +425,14 @@ func TestErrorLookup(t *testing.T) {
|
||||
{code: "VpnGatewayAttachmentLimitExceeded", http: 400, message: "You've reached the limit on the number of VPCs that can be attached to the specified virtual private gateway."},
|
||||
{code: "VpnGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of virtual private gateways that you can create. For more information about limits, see Amazon VPC quotas."},
|
||||
{code: "ZonesMismatched", http: 400, message: "The Availability Zone for the instance does not match that of the Dedicated Host"},
|
||||
|
||||
// IAM error codes
|
||||
{code: "NoSuchEntity", http: 404, message: "The request was rejected because it referenced a resource entity that does not exist."},
|
||||
{code: "EntityAlreadyExists", http: 409, message: "The request was rejected because it attempted to create a resource that already exists."},
|
||||
{code: "DeleteConflict", http: 409, message: "The request was rejected because it attempted to delete a resource that has attached subordinate entities."},
|
||||
{code: "LimitExceeded", http: 409, message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
{code: "MalformedPolicyDocument", http: 400, message: "The policy document is malformed."},
|
||||
{code: "AccessDenied", http: 403, message: "User is not authorized to perform this action."},
|
||||
}
|
||||
|
||||
if len(ErrorLookup) != len(expected) {
|
||||
@@ -443,3 +452,25 @@ func TestErrorLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidErrorCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
{name: "known error code", code: ErrorAuthFailure, want: ErrorAuthFailure},
|
||||
{name: "another known code", code: ErrorInvalidParameterValue, want: ErrorInvalidParameterValue},
|
||||
{name: "unknown code returns ServerInternal", code: "CompletelyMadeUp", want: ErrorServerInternal},
|
||||
{name: "empty string returns ServerInternal", code: "", want: ErrorServerInternal},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ValidErrorCode(tt.code)
|
||||
if got != tt.want {
|
||||
t.Errorf("ValidErrorCode(%q) = %q, want %q", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+58
-237
@@ -1,13 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"slices"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
@@ -33,13 +31,65 @@ type Config struct {
|
||||
Predastore PredastoreConfig `mapstructure:"predastore"`
|
||||
Viperblock ViperblockConfig `mapstructure:"viperblock"`
|
||||
AWSGW AWSGWConfig `mapstructure:"awsgw"`
|
||||
VPCD VPCDConfig `mapstructure:"vpcd"`
|
||||
|
||||
// Authentication
|
||||
// TODO: Move to more appropriate setting above
|
||||
BaseDir string `mapstructure:"base_dir"`
|
||||
WalDir string `mapstructure:"wal_dir"`
|
||||
}
|
||||
|
||||
type AWSGWConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
TLSKey string `mapstructure:"tlskey"`
|
||||
TLSCert string `mapstructure:"tlscert"`
|
||||
Config string `mapstructure:"config"`
|
||||
|
||||
Debug bool `mapstructure:"debug"`
|
||||
ExpectedNodes int `mapstructure:"expected_nodes"` // TODO: Replace with root cluster config
|
||||
}
|
||||
|
||||
type ViperblockConfig struct {
|
||||
ShardWAL *bool `mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
|
||||
}
|
||||
|
||||
// VPCDConfig holds the VPC daemon (vpcd) configuration.
|
||||
type VPCDConfig struct {
|
||||
OVNNBAddr string `mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
|
||||
OVNSBAddr string `mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
|
||||
}
|
||||
|
||||
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"`
|
||||
WalDir string `mapstructure:"wal_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"`
|
||||
DevNetworking bool `mapstructure:"dev_networking"` // VPC instances get both TAP + hostfwd for SSH dev access
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
|
||||
@@ -61,7 +111,7 @@ func (cc *ClusterConfig) NodeBaseDir() string {
|
||||
}
|
||||
|
||||
// AllServices is the default service list when Services is empty (backward compat).
|
||||
var AllServices = []string{"nats", "predastore", "viperblock", "daemon", "awsgw", "ui"}
|
||||
var AllServices = []string{"nats", "predastore", "viperblock", "daemon", "awsgw", "vpcd", "ui"}
|
||||
|
||||
// HasService reports whether the node runs the named service.
|
||||
// An empty Services list means all services (backward compat).
|
||||
@@ -70,12 +120,7 @@ func (c Config) HasService(name string) bool {
|
||||
if len(services) == 0 {
|
||||
services = AllServices
|
||||
}
|
||||
for _, s := range services {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(services, name)
|
||||
}
|
||||
|
||||
// GetServices returns the configured service list, defaulting to AllServices.
|
||||
@@ -86,230 +131,6 @@ func (c Config) GetServices() []string {
|
||||
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
|
||||
|
||||
@@ -28,9 +28,6 @@ node = "node1"
|
||||
node = "node1"
|
||||
region = "us-east-1"
|
||||
az = "us-east-1a"
|
||||
accesskey = "AKIATEST"
|
||||
secretkey = "SECRET"
|
||||
|
||||
[nodes.node1.daemon]
|
||||
host = "127.0.0.1:8080"
|
||||
|
||||
@@ -59,7 +56,6 @@ region = "us-east-1"
|
||||
require.True(t, ok, "node1 should exist in Nodes map")
|
||||
assert.Equal(t, "us-east-1", node.Region)
|
||||
assert.Equal(t, "us-east-1a", node.AZ)
|
||||
assert.Equal(t, "AKIATEST", node.AccessKey)
|
||||
assert.Equal(t, "127.0.0.1:8080", node.Daemon.Host)
|
||||
assert.Equal(t, "127.0.0.1:4222", node.NATS.Host)
|
||||
assert.Equal(t, "nats_testtoken", node.NATS.ACL.Token)
|
||||
@@ -304,13 +300,6 @@ func TestNodeBaseDir_EmptyBaseDir(t *testing.T) {
|
||||
assert.Equal(t, "", cc.NodeBaseDir())
|
||||
}
|
||||
|
||||
// Tests for type constants
|
||||
|
||||
func TestNBDTransportConstants(t *testing.T) {
|
||||
assert.Equal(t, NBDTransport("socket"), NBDTransportSocket)
|
||||
assert.Equal(t, NBDTransport("tcp"), NBDTransportTCP)
|
||||
}
|
||||
|
||||
// Tests for ViperblockConfig
|
||||
|
||||
func TestLoadConfig_ViperblockShardWAL_Explicit(t *testing.T) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+491
-216
File diff suppressed because it is too large
Load Diff
+65
-1706
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2EnableEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.EnableEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisableEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.DisableEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2GetEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.GetEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2GetSerialConsoleAccessStatus(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.GetSerialConsoleAccessStatus)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2EnableSerialConsoleAccess(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.EnableSerialConsoleAccess)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisableSerialConsoleAccess(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.DisableSerialConsoleAccess)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// handleEC2GetConsoleOutput reads the console log file for an instance and returns
|
||||
// base64-encoded output matching the AWS GetConsoleOutput API response format.
|
||||
func (d *Daemon) handleEC2GetConsoleOutput(msg *nats.Msg) {
|
||||
slog.Debug("Received GetConsoleOutput request", "subject", msg.Subject, "data", string(msg.Data))
|
||||
|
||||
var input ec2.GetConsoleOutputInput
|
||||
if errResp := utils.UnmarshalJsonPayload(&input, msg.Data); errResp != nil {
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if input.InstanceId == nil {
|
||||
respondWithError(msg, awserrors.ErrorMissingParameter)
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := *input.InstanceId
|
||||
|
||||
// Find the instance on this node
|
||||
d.Instances.Mu.Lock()
|
||||
instance, exists := d.Instances.VMS[instanceID]
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the caller owns this instance
|
||||
if !checkInstanceOwnership(msg, instanceID, instance.AccountID) {
|
||||
return
|
||||
}
|
||||
|
||||
logPath := instance.Config.ConsoleLogPath
|
||||
var outputData []byte
|
||||
var modTime time.Time
|
||||
|
||||
if logPath != "" {
|
||||
info, err := os.Stat(logPath)
|
||||
if err == nil {
|
||||
modTime = info.ModTime()
|
||||
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read console log", "path", logPath, "err", err)
|
||||
} else {
|
||||
// Return last 64KB (AWS limit)
|
||||
const maxConsoleOutput = 64 * 1024
|
||||
if len(data) > maxConsoleOutput {
|
||||
data = data[len(data)-maxConsoleOutput:]
|
||||
}
|
||||
outputData = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base64-encode the output (AWS returns base64)
|
||||
var encodedOutput string
|
||||
if len(outputData) > 0 {
|
||||
encodedOutput = base64.StdEncoding.EncodeToString(outputData)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if modTime.IsZero() {
|
||||
modTime = now
|
||||
}
|
||||
|
||||
output := &ec2.GetConsoleOutputOutput{
|
||||
InstanceId: &instanceID,
|
||||
Output: &encodedOutput,
|
||||
Timestamp: &modTime,
|
||||
}
|
||||
|
||||
respondWithJSON(msg, output)
|
||||
slog.Info("handleEC2GetConsoleOutput completed", "instance_id", instanceID, "output_bytes", len(outputData))
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.CreateInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DeleteInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeInternetGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DescribeInternetGateways)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AttachInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.AttachInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DetachInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DetachInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateEgressOnlyInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.CreateEgressOnlyInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteEgressOnlyInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.DeleteEgressOnlyInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeEgressOnlyInternetGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.DescribeEgressOnlyInternetGateways)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_ec2_image "github.com/mulgadc/hive/hive/handlers/ec2/image"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2DescribeImages(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.DescribeImages)
|
||||
}
|
||||
|
||||
// handleEC2CreateImage is a stateful handler that extracts instance context
|
||||
// (root volume ID, source AMI, running state) before delegating to the image service.
|
||||
func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
|
||||
slog.Debug("Received message", "subject", msg.Subject)
|
||||
|
||||
input := &ec2.CreateImageInput{}
|
||||
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
if input.InstanceId == nil || *input.InstanceId == "" {
|
||||
respondWithError(msg, awserrors.ErrorMissingParameter)
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := *input.InstanceId
|
||||
|
||||
// Extract all instance context in a single critical section
|
||||
d.Instances.Mu.Lock()
|
||||
instance, ok := d.Instances.VMS[instanceID]
|
||||
var status vm.InstanceState
|
||||
var rootVolumeID, sourceImageID string
|
||||
if ok {
|
||||
status = instance.Status
|
||||
if instance.Instance != nil {
|
||||
for _, bdm := range instance.Instance.BlockDeviceMappings {
|
||||
if bdm.Ebs != nil && bdm.Ebs.VolumeId != nil {
|
||||
rootVolumeID = *bdm.Ebs.VolumeId
|
||||
break
|
||||
}
|
||||
}
|
||||
if instance.Instance.ImageId != nil {
|
||||
sourceImageID = *instance.Instance.ImageId
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
slog.Warn("CreateImage: instance not found", "instanceId", instanceID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the caller owns this instance
|
||||
if !checkInstanceOwnership(msg, instanceID, instance.AccountID) {
|
||||
return
|
||||
}
|
||||
|
||||
if status != vm.StateRunning && status != vm.StateStopped {
|
||||
slog.Warn("CreateImage: instance not in valid state", "instanceId", instanceID, "status", status)
|
||||
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
|
||||
return
|
||||
}
|
||||
|
||||
if rootVolumeID == "" {
|
||||
slog.Error("CreateImage: no root volume found", "instanceId", instanceID)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
params := handlers_ec2_image.CreateImageParams{
|
||||
Input: input,
|
||||
RootVolumeID: rootVolumeID,
|
||||
SourceImageID: sourceImageID,
|
||||
IsRunning: status == vm.StateRunning,
|
||||
}
|
||||
|
||||
output, err := d.imageService.CreateImageFromInstance(params, accountID)
|
||||
if err != nil {
|
||||
slog.Error("CreateImage: service failed", "instanceId", instanceID, "err", err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondWithJSON(msg, output)
|
||||
slog.Info("CreateImage completed", "instanceId", instanceID, "imageId", *output.ImageId)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.CreateKeyPair)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.DeleteKeyPair)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeKeyPairs(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.DescribeKeyPairs)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ImportKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.ImportKeyPair)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CreateSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSnapshots(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DescribeSnapshots)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DeleteSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CopySnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CopySnapshot)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2CreateTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.CreateTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.DeleteTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.DescribeTags)
|
||||
}
|
||||
+2330
-53
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/qmp"
|
||||
"github.com/mulgadc/hive/hive/types"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// handleAttachVolume performs a three-phase hot-plug:
|
||||
//
|
||||
// Phase 1: ebs.mount via NATS (rolls back with ebs.unmount)
|
||||
// Phase 2: QMP blockdev-add (rolls back Phase 1)
|
||||
// Phase 3: QMP device_add (rolls back Phase 2 + Phase 1)
|
||||
func (d *Daemon) handleAttachVolume(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
|
||||
slog.Info("Attaching volume to instance", "instanceId", command.ID)
|
||||
|
||||
// Validate AttachVolumeData
|
||||
if command.AttachVolumeData == nil || command.AttachVolumeData.VolumeID == "" {
|
||||
slog.Error("AttachVolume: missing attach volume data")
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
volumeID := command.AttachVolumeData.VolumeID
|
||||
device := command.AttachVolumeData.Device
|
||||
|
||||
// Validate instance is running
|
||||
d.Instances.Mu.Lock()
|
||||
status := instance.Status
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if status != vm.StateRunning {
|
||||
slog.Error("AttachVolume: instance not running", "instanceId", command.ID, "status", status)
|
||||
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate volume exists and is available
|
||||
volCfg, err := d.volumeService.GetVolumeConfig(volumeID)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: failed to get volume config", "volumeId", volumeID, "err", err)
|
||||
respondWithError(msg, awserrors.ErrorInvalidVolumeNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Account scoping: verify the caller owns this volume
|
||||
callerAccountID := utils.AccountIDFromMsg(msg)
|
||||
if callerAccountID != "" && volCfg.VolumeMetadata.TenantID != "" && volCfg.VolumeMetadata.TenantID != callerAccountID {
|
||||
slog.Warn("AttachVolume: account does not own volume", "volumeId", volumeID, "callerAccount", callerAccountID, "ownerAccount", volCfg.VolumeMetadata.TenantID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidVolumeNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if volCfg.VolumeMetadata.State != "available" {
|
||||
slog.Error("AttachVolume: volume not available", "volumeId", volumeID, "state", volCfg.VolumeMetadata.State)
|
||||
respondWithError(msg, awserrors.ErrorVolumeInUse)
|
||||
return
|
||||
}
|
||||
|
||||
// Check AZ compatibility — volume and instance must be in the same AZ
|
||||
if volCfg.VolumeMetadata.AvailabilityZone != "" && d.config.AZ != "" &&
|
||||
volCfg.VolumeMetadata.AvailabilityZone != d.config.AZ {
|
||||
slog.Error("AttachVolume: volume and instance are in different AZs",
|
||||
"volumeId", volumeID, "volumeAZ", volCfg.VolumeMetadata.AvailabilityZone,
|
||||
"instanceAZ", d.config.AZ)
|
||||
respondWithError(msg, awserrors.ErrorInvalidVolumeZoneMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine device name
|
||||
if device == "" {
|
||||
d.Instances.Mu.Lock()
|
||||
device = nextAvailableDevice(instance)
|
||||
d.Instances.Mu.Unlock()
|
||||
if device == "" {
|
||||
slog.Error("AttachVolume: no available device names")
|
||||
respondWithError(msg, awserrors.ErrorAttachmentLimitExceeded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create EBS mount request
|
||||
ebsRequest := types.EBSRequest{
|
||||
Name: volumeID,
|
||||
DeviceName: device,
|
||||
}
|
||||
|
||||
ebsMountData, err := json.Marshal(ebsRequest)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: failed to marshal ebs.mount request", "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
mountReply, err := d.natsConn.Request(d.ebsTopic("mount"), ebsMountData, 30*time.Second)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: ebs.mount failed", "volumeId", volumeID, "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
var mountResp types.EBSMountResponse
|
||||
if err := json.Unmarshal(mountReply.Data, &mountResp); err != nil {
|
||||
slog.Error("AttachVolume: failed to unmarshal mount response", "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
if mountResp.Error != "" {
|
||||
slog.Error("AttachVolume: mount returned error", "volumeId", volumeID, "err", mountResp.Error)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
nbdURI := mountResp.URI
|
||||
if nbdURI == "" {
|
||||
slog.Error("AttachVolume: mount response has empty URI", "volumeId", volumeID)
|
||||
d.rollbackEBSMount(ebsRequest)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
ebsRequest.NBDURI = nbdURI
|
||||
|
||||
// Parse NBDURI for QMP blockdev-add
|
||||
serverType, socketPath, nbdHost, nbdPort, err := utils.ParseNBDURI(nbdURI)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: failed to parse NBDURI", "uri", nbdURI, "err", err)
|
||||
// Rollback: unmount
|
||||
d.rollbackEBSMount(ebsRequest)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Build QMP server argument
|
||||
var serverArg map[string]any
|
||||
if serverType == "unix" {
|
||||
serverArg = map[string]any{"type": "unix", "path": socketPath}
|
||||
} else {
|
||||
serverArg = map[string]any{"type": "inet", "host": nbdHost, "port": fmt.Sprintf("%d", nbdPort)}
|
||||
}
|
||||
|
||||
nodeName := fmt.Sprintf("nbd-%s", volumeID)
|
||||
deviceID := fmt.Sprintf("vdisk-%s", volumeID)
|
||||
iothreadID := fmt.Sprintf("ioth-%s", volumeID)
|
||||
|
||||
// QMP object-add: create iothread for this volume
|
||||
iothreadCmd := qmp.QMPCommand{
|
||||
Execute: "object-add",
|
||||
Arguments: map[string]any{
|
||||
"qom-type": "iothread",
|
||||
"id": iothreadID,
|
||||
},
|
||||
}
|
||||
_, err = d.SendQMPCommand(instance.QMPClient, iothreadCmd, instance.ID)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: QMP object-add iothread failed", "volumeId", volumeID, "err", err)
|
||||
d.rollbackEBSMount(ebsRequest)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// QMP blockdev-add
|
||||
blockdevCmd := qmp.QMPCommand{
|
||||
Execute: "blockdev-add",
|
||||
Arguments: map[string]any{
|
||||
"node-name": nodeName,
|
||||
"driver": "nbd",
|
||||
"server": serverArg,
|
||||
"export": "",
|
||||
"read-only": false,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = d.SendQMPCommand(instance.QMPClient, blockdevCmd, instance.ID)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: QMP blockdev-add failed", "volumeId", volumeID, "err", err)
|
||||
d.rollbackEBSMount(ebsRequest)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which hotplug root port to use based on device letter.
|
||||
// /dev/sdf -> hotplug1, /dev/sdg -> hotplug2, etc.
|
||||
hotplugBus := ""
|
||||
if len(device) > 0 {
|
||||
letter := device[len(device)-1]
|
||||
if letter >= 'f' && letter <= 'p' {
|
||||
hotplugBus = fmt.Sprintf("hotplug%d", letter-'f'+1)
|
||||
}
|
||||
}
|
||||
|
||||
// QMP device_add
|
||||
deviceAddArgs := map[string]any{
|
||||
"driver": "virtio-blk-pci",
|
||||
"id": deviceID,
|
||||
"drive": nodeName,
|
||||
"iothread": iothreadID,
|
||||
}
|
||||
if hotplugBus != "" {
|
||||
deviceAddArgs["bus"] = hotplugBus
|
||||
}
|
||||
deviceAddCmd := qmp.QMPCommand{
|
||||
Execute: "device_add",
|
||||
Arguments: deviceAddArgs,
|
||||
}
|
||||
|
||||
_, err = d.SendQMPCommand(instance.QMPClient, deviceAddCmd, instance.ID)
|
||||
if err != nil {
|
||||
slog.Error("AttachVolume: QMP device_add failed, rolling back blockdev", "volumeId", volumeID, "err", err)
|
||||
// Rollback: blockdev-del then unmount
|
||||
if _, delErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
|
||||
Execute: "blockdev-del",
|
||||
Arguments: map[string]any{"node-name": nodeName},
|
||||
}, instance.ID); delErr != nil {
|
||||
slog.Error("AttachVolume: rollback blockdev-del failed, skipping EBS unmount", "volumeId", volumeID, "err", delErr)
|
||||
} else {
|
||||
d.rollbackEBSMount(ebsRequest)
|
||||
}
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Discover actual guest device name via QMP query-block.
|
||||
// Use the retry variant because query-block may not include the device
|
||||
// immediately after device_add.
|
||||
guestDevice := device // fallback to AWS API name
|
||||
deviceMap, qmpErr := queryGuestDeviceMapWait(d, instance.QMPClient, instance.ID, deviceID)
|
||||
if qmpErr != nil {
|
||||
slog.Warn("AttachVolume: failed to query guest device map, using API device name", "volumeId", volumeID, "err", qmpErr)
|
||||
} else if gd, ok := deviceMap[deviceID]; ok {
|
||||
guestDevice = gd
|
||||
slog.Info("AttachVolume: discovered guest device", "volumeId", volumeID, "qemuDevice", deviceID, "guestDevice", guestDevice)
|
||||
} else {
|
||||
slog.Error("AttachVolume: device not found in QMP device map after retries, using API device name",
|
||||
"volumeId", volumeID, "qemuDevice", deviceID, "deviceMap", deviceMap)
|
||||
}
|
||||
|
||||
// Update instance state: replace existing entry for this volume (handles
|
||||
// stop/start cycles that keep stale entries) or append a new one.
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
replaced := false
|
||||
for i, req := range instance.EBSRequests.Requests {
|
||||
if req.Name == volumeID {
|
||||
instance.EBSRequests.Requests[i] = ebsRequest
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, ebsRequest)
|
||||
}
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
// Update BlockDeviceMappings on the ec2.Instance using actual guest device name
|
||||
d.Instances.Mu.Lock()
|
||||
if instance.Instance != nil {
|
||||
now := time.Now()
|
||||
mapping := &ec2.InstanceBlockDeviceMapping{}
|
||||
mapping.SetDeviceName(guestDevice)
|
||||
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
|
||||
mapping.Ebs.SetVolumeId(volumeID)
|
||||
mapping.Ebs.SetAttachTime(now)
|
||||
mapping.Ebs.SetDeleteOnTermination(false)
|
||||
mapping.Ebs.SetStatus("attached")
|
||||
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
// Update volume metadata in S3
|
||||
if err := d.volumeService.UpdateVolumeState(volumeID, "in-use", command.ID, guestDevice); err != nil {
|
||||
slog.Error("AttachVolume: failed to update volume metadata", "volumeId", volumeID, "err", err)
|
||||
}
|
||||
|
||||
// Persist state
|
||||
if err := d.WriteState(); err != nil {
|
||||
slog.Error("AttachVolume: failed to write state", "err", err)
|
||||
}
|
||||
|
||||
d.respondWithVolumeAttachment(msg, volumeID, command.ID, guestDevice, "attached")
|
||||
slog.Info("Volume attached successfully", "volumeId", volumeID, "instanceId", command.ID, "apiDevice", device, "guestDevice", guestDevice)
|
||||
}
|
||||
|
||||
// handleDetachVolume performs a three-phase hot-unplug (reverse of attach):
|
||||
//
|
||||
// Phase 1: QMP device_del (remove guest device)
|
||||
// Phase 2: QMP blockdev-del (remove block node)
|
||||
// Phase 3: ebs.unmount NATS (stop NBD server)
|
||||
func (d *Daemon) handleDetachVolume(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
|
||||
slog.Info("Detaching volume from instance", "instanceId", command.ID)
|
||||
|
||||
// Validate DetachVolumeData
|
||||
if command.DetachVolumeData == nil || command.DetachVolumeData.VolumeID == "" {
|
||||
slog.Error("DetachVolume: missing detach volume data")
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
volumeID := command.DetachVolumeData.VolumeID
|
||||
device := command.DetachVolumeData.Device
|
||||
force := command.DetachVolumeData.Force
|
||||
|
||||
// Validate instance is running
|
||||
d.Instances.Mu.Lock()
|
||||
status := instance.Status
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if status != vm.StateRunning {
|
||||
slog.Error("DetachVolume: instance not running", "instanceId", command.ID, "status", status)
|
||||
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the volume in EBSRequests
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
var ebsReq types.EBSRequest
|
||||
found := false
|
||||
for _, req := range instance.EBSRequests.Requests {
|
||||
if req.Name == volumeID {
|
||||
ebsReq = req
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
if !found {
|
||||
slog.Error("DetachVolume: volume not attached to instance", "volumeId", volumeID, "instanceId", command.ID)
|
||||
respondWithError(msg, awserrors.ErrorIncorrectState)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject detaching boot/EFI/CloudInit volumes
|
||||
if ebsReq.Boot || ebsReq.EFI || ebsReq.CloudInit {
|
||||
slog.Error("DetachVolume: cannot detach boot/EFI/CloudInit volume", "volumeId", volumeID)
|
||||
respondWithError(msg, awserrors.ErrorOperationNotPermitted)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional device cross-check
|
||||
if device != "" && ebsReq.DeviceName != "" && device != ebsReq.DeviceName {
|
||||
slog.Error("DetachVolume: device mismatch", "requested", device, "actual", ebsReq.DeviceName)
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := fmt.Sprintf("vdisk-%s", volumeID)
|
||||
nodeName := fmt.Sprintf("nbd-%s", volumeID)
|
||||
iothreadID := fmt.Sprintf("ioth-%s", volumeID)
|
||||
|
||||
// Phase 1: QMP device_del (remove guest device)
|
||||
_, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
|
||||
Execute: "device_del",
|
||||
Arguments: map[string]any{"id": deviceID},
|
||||
}, instance.ID)
|
||||
if err != nil {
|
||||
if !force {
|
||||
slog.Error("DetachVolume: QMP device_del failed", "volumeId", volumeID, "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
slog.Warn("DetachVolume: QMP device_del failed (force=true, continuing)", "volumeId", volumeID, "err", err)
|
||||
}
|
||||
|
||||
// Brief pause for guest to acknowledge PCI removal
|
||||
time.Sleep(d.detachDelay)
|
||||
|
||||
// Phase 2: QMP blockdev-del (remove block node)
|
||||
_, blockdevErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
|
||||
Execute: "blockdev-del",
|
||||
Arguments: map[string]any{"node-name": nodeName},
|
||||
}, instance.ID)
|
||||
if blockdevErr != nil {
|
||||
// Block node still referenced by QEMU; do not clean up state or unmount —
|
||||
// tearing down the NBD server would crash the VM, and removing metadata
|
||||
// would allow the volume to be double-attached.
|
||||
slog.Error("DetachVolume: QMP blockdev-del failed, leaving volume state intact", "volumeId", volumeID, "err", blockdevErr)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2b: QMP object-del (remove iothread, best-effort)
|
||||
_, iothreadErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
|
||||
Execute: "object-del",
|
||||
Arguments: map[string]any{"id": iothreadID},
|
||||
}, instance.ID)
|
||||
if iothreadErr != nil {
|
||||
slog.Warn("DetachVolume: QMP object-del iothread failed (non-fatal)", "volumeId", volumeID, "err", iothreadErr)
|
||||
}
|
||||
|
||||
// Phase 3: ebs.unmount via NATS (best-effort)
|
||||
d.rollbackEBSMount(ebsReq)
|
||||
|
||||
// State cleanup: remove volume from EBSRequests (search by name to avoid stale index)
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
for i, req := range instance.EBSRequests.Requests {
|
||||
if req.Name == volumeID {
|
||||
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests[:i], instance.EBSRequests.Requests[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
// Remove from BlockDeviceMappings
|
||||
d.Instances.Mu.Lock()
|
||||
if instance.Instance != nil {
|
||||
filtered := make([]*ec2.InstanceBlockDeviceMapping, 0, len(instance.Instance.BlockDeviceMappings))
|
||||
for _, bdm := range instance.Instance.BlockDeviceMappings {
|
||||
if bdm.Ebs != nil && bdm.Ebs.VolumeId != nil && *bdm.Ebs.VolumeId == volumeID {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, bdm)
|
||||
}
|
||||
instance.Instance.BlockDeviceMappings = filtered
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
// Update volume metadata to "available"
|
||||
if err := d.volumeService.UpdateVolumeState(volumeID, "available", "", ""); err != nil {
|
||||
slog.Error("DetachVolume: failed to update volume metadata", "volumeId", volumeID, "err", err)
|
||||
}
|
||||
|
||||
// Persist state
|
||||
if err := d.WriteState(); err != nil {
|
||||
slog.Error("DetachVolume: failed to write state", "err", err)
|
||||
}
|
||||
|
||||
d.respondWithVolumeAttachment(msg, volumeID, command.ID, ebsReq.DeviceName, "detaching")
|
||||
slog.Info("Volume detached successfully", "volumeId", volumeID, "instanceId", command.ID)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateVolume(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.CreateVolume)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVolumes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DescribeVolumes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVolumeStatus(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DescribeVolumeStatus)
|
||||
}
|
||||
|
||||
// handleEC2ModifyVolume processes incoming EC2 ModifyVolume requests
|
||||
func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
|
||||
slog.Debug("Received message", "subject", msg.Subject)
|
||||
slog.Debug("Message data", "data", string(msg.Data))
|
||||
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
modifyVolumeInput := &ec2.ModifyVolumeInput{}
|
||||
errResp := utils.UnmarshalJsonPayload(modifyVolumeInput, msg.Data)
|
||||
|
||||
if errResp != nil {
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
slog.Error("Request does not match ModifyVolumeInput")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Processing ModifyVolume request", "volumeId", modifyVolumeInput.VolumeId, "accountID", accountID)
|
||||
|
||||
output, err := d.volumeService.ModifyVolume(modifyVolumeInput, accountID)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("handleEC2ModifyVolume service.ModifyVolume failed", "err", err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondWithJSON(msg, output)
|
||||
|
||||
// Notify viperblockd to reload state after volume modification (e.g. resize)
|
||||
if modifyVolumeInput.VolumeId != nil {
|
||||
syncData, err := json.Marshal(types.EBSSyncRequest{Volume: *modifyVolumeInput.VolumeId})
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal ebs.sync request", "volumeId", *modifyVolumeInput.VolumeId, "err", err)
|
||||
} else {
|
||||
_, syncErr := d.natsConn.Request("ebs.sync", syncData, 5*time.Second)
|
||||
if syncErr != nil {
|
||||
slog.Warn("ebs.sync notification failed (volume may not be mounted)",
|
||||
"volumeId", *modifyVolumeInput.VolumeId, "err", syncErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("handleEC2ModifyVolume completed", "volumeId", modifyVolumeInput.VolumeId)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteVolume(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DeleteVolume)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateVpc(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateVpc)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteVpc(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteVpc)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVpcs(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeVpcs)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateSubnet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateSubnet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSubnet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteSubnet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSubnets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeSubnets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateNetworkInterface)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
|
||||
}
|
||||
|
||||
// handleAccountCreated creates a default VPC for a newly created account.
|
||||
func (d *Daemon) handleAccountCreated(msg *nats.Msg) {
|
||||
var evt struct {
|
||||
AccountID string `json:"account_id"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &evt); err != nil {
|
||||
slog.Error("Failed to unmarshal account creation event", "error", err)
|
||||
return
|
||||
}
|
||||
if evt.AccountID == "" {
|
||||
slog.Error("Account creation event has empty account ID")
|
||||
return
|
||||
}
|
||||
if err := d.vpcService.EnsureDefaultVPC(evt.AccountID); err != nil {
|
||||
slog.Error("Failed to create default VPC for new account",
|
||||
"accountID", evt.AccountID, "error", err)
|
||||
}
|
||||
}
|
||||
+594
-235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/qmp"
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
)
|
||||
|
||||
// pciAddrRegexp extracts the device index from a boot-time QDev path.
|
||||
// Example: "/machine/peripheral-anon/device[0]/virtio-backend" → 0
|
||||
var pciAddrRegexp = regexp.MustCompile(`device\[(\d+)\]`)
|
||||
|
||||
// hotplugPortRegexp extracts the hotplug port number from a hot-plugged QDev path.
|
||||
// Example: "/machine/peripheral/vdisk-vol-xxx/hotplug3/virtio-backend" or
|
||||
//
|
||||
// "/machine/peripheral/vdisk-vol-xxx/virtio-backend" (bus assigned by QEMU)
|
||||
//
|
||||
// The chassis number from the QEMU command line determines PCI slot order.
|
||||
var hotplugPortRegexp = regexp.MustCompile(`hotplug(\d+)`)
|
||||
|
||||
// queryGuestDeviceMap uses QMP query-block to build a map from QEMU device ID
|
||||
// (e.g. "os", "cloudinit", "vdisk-vol-xxx") to the guest device path
|
||||
// (e.g. "/dev/vda", "/dev/vdb", "/dev/vdc").
|
||||
//
|
||||
// The mapping is derived from PCI address order: virtio-blk-pci devices are
|
||||
// enumerated by the guest kernel in PCI bus order, which corresponds to the
|
||||
// device index in the QDev path.
|
||||
func queryGuestDeviceMap(d *Daemon, qmpClient *qmp.QMPClient, instanceID string) (map[string]string, error) {
|
||||
resp, err := d.SendQMPCommand(qmpClient, qmp.QMPCommand{Execute: "query-block"}, instanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query-block failed: %w", err)
|
||||
}
|
||||
|
||||
var devices []qmp.BlockDevice
|
||||
if err := json.Unmarshal(resp.Return, &devices); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse query-block response: %w", err)
|
||||
}
|
||||
|
||||
return buildDeviceMap(devices), nil
|
||||
}
|
||||
|
||||
// queryGuestDeviceMapWait retries queryGuestDeviceMap until expectedDevice
|
||||
// appears in the result. This handles the race where query-block is called
|
||||
// immediately after device_add, before QEMU has registered the new device.
|
||||
func queryGuestDeviceMapWait(d *Daemon, qmpClient *qmp.QMPClient, instanceID, expectedDevice string) (map[string]string, error) {
|
||||
const maxAttempts = 5
|
||||
const retryDelay = 200 * time.Millisecond
|
||||
|
||||
for attempt := range maxAttempts {
|
||||
deviceMap, err := queryGuestDeviceMap(d, qmpClient, instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := deviceMap[expectedDevice]; ok {
|
||||
return deviceMap, nil
|
||||
}
|
||||
if attempt < maxAttempts-1 {
|
||||
slog.Debug("queryGuestDeviceMapWait: device not yet visible, retrying",
|
||||
"expectedDevice", expectedDevice, "attempt", attempt+1, "maxAttempts", maxAttempts)
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the last result even though the device wasn't found — caller decides fallback.
|
||||
return queryGuestDeviceMap(d, qmpClient, instanceID)
|
||||
}
|
||||
|
||||
// buildDeviceMap takes a list of BlockDevices from QMP and returns a map
|
||||
// from QEMU device ID to guest /dev/vdX path, sorted by PCI address.
|
||||
//
|
||||
// Two QDev path formats exist:
|
||||
// - Boot-time: /machine/peripheral-anon/device[N]/virtio-backend
|
||||
// device field contains the ID (e.g. "os", "cloudinit")
|
||||
// - Hot-plugged: /machine/peripheral/<device-id>/virtio-backend
|
||||
// device field is empty; ID is extracted from the QDev path
|
||||
func buildDeviceMap(devices []qmp.BlockDevice) map[string]string {
|
||||
type deviceEntry struct {
|
||||
name string
|
||||
pciIndex int
|
||||
}
|
||||
|
||||
var virtioDevices []deviceEntry
|
||||
|
||||
for _, dev := range devices {
|
||||
if dev.QDev == "" || !strings.Contains(dev.QDev, "virtio-backend") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := dev.Device
|
||||
pciIndex := extractPCIIndex(dev.QDev)
|
||||
|
||||
if pciIndex < 0 {
|
||||
// Hot-plugged device: extract name and port from QDev path.
|
||||
// QDev looks like "/machine/peripheral/<name>/virtio-backend"
|
||||
if name == "" {
|
||||
name = extractPeripheralName(dev.QDev)
|
||||
}
|
||||
if name == "" {
|
||||
slog.Warn("Could not extract device name from QDev path", "qdev", dev.QDev)
|
||||
continue
|
||||
}
|
||||
// Use hotplug port number for ordering; fall back to a high
|
||||
// index so hot-plugged devices sort after boot devices.
|
||||
pciIndex = extractHotplugPort(dev.QDev)
|
||||
if pciIndex < 0 {
|
||||
pciIndex = 1000 // arbitrary high value
|
||||
}
|
||||
}
|
||||
|
||||
virtioDevices = append(virtioDevices, deviceEntry{
|
||||
name: name,
|
||||
pciIndex: pciIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by PCI index — this determines /dev/vd* letter assignment
|
||||
sort.Slice(virtioDevices, func(i, j int) bool {
|
||||
return virtioDevices[i].pciIndex < virtioDevices[j].pciIndex
|
||||
})
|
||||
|
||||
result := make(map[string]string, len(virtioDevices))
|
||||
for i, entry := range virtioDevices {
|
||||
if i >= 26 {
|
||||
slog.Warn("buildDeviceMap: more than 26 virtio devices, cannot map remaining",
|
||||
"totalDevices", len(virtioDevices))
|
||||
break
|
||||
}
|
||||
guestDev := fmt.Sprintf("/dev/vd%c", 'a'+i)
|
||||
result[entry.name] = guestDev
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// updateGuestDeviceNames queries the running VM's QMP to discover actual guest device
|
||||
// paths and updates the instance's BlockDeviceMappings accordingly.
|
||||
func (d *Daemon) updateGuestDeviceNames(instance *vm.VM) {
|
||||
if instance.QMPClient == nil {
|
||||
slog.Warn("updateGuestDeviceNames: QMPClient is nil, cannot discover guest device names",
|
||||
"instanceId", instance.ID)
|
||||
return
|
||||
}
|
||||
|
||||
deviceMap, err := queryGuestDeviceMap(d, instance.QMPClient, instance.ID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query guest device map, BlockDeviceMappings will use API names",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build volume ID → guest device path mapping from EBSRequests.
|
||||
// Collect under EBSRequests lock, then release before acquiring Instances lock
|
||||
// to maintain consistent lock ordering.
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
volToGuest := make(map[string]string, len(instance.EBSRequests.Requests))
|
||||
for _, req := range instance.EBSRequests.Requests {
|
||||
var qemuID string
|
||||
if req.Boot {
|
||||
qemuID = "os"
|
||||
} else if req.CloudInit {
|
||||
qemuID = "cloudinit"
|
||||
} else {
|
||||
qemuID = fmt.Sprintf("vdisk-%s", req.Name)
|
||||
}
|
||||
if gd, ok := deviceMap[qemuID]; ok {
|
||||
volToGuest[req.Name] = gd
|
||||
}
|
||||
}
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
// Update BlockDeviceMappings using the mapping
|
||||
d.Instances.Mu.Lock()
|
||||
if instance.Instance != nil {
|
||||
for _, bdm := range instance.Instance.BlockDeviceMappings {
|
||||
if bdm.Ebs == nil || bdm.Ebs.VolumeId == nil || bdm.DeviceName == nil {
|
||||
continue
|
||||
}
|
||||
if gd, ok := volToGuest[*bdm.Ebs.VolumeId]; ok {
|
||||
bdm.DeviceName = &gd
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if err := d.WriteState(); err != nil {
|
||||
slog.Error("Failed to persist state after guest device name update", "instanceId", instance.ID, "err", err)
|
||||
}
|
||||
|
||||
slog.Info("Updated guest device names", "instanceId", instance.ID, "deviceMap", deviceMap)
|
||||
}
|
||||
|
||||
// extractPCIIndex parses the device index from a QDev path.
|
||||
// For example: "/machine/peripheral-anon/device[3]/virtio-backend" → 3
|
||||
func extractPCIIndex(qdev string) int {
|
||||
matches := pciAddrRegexp.FindStringSubmatch(qdev)
|
||||
if len(matches) < 2 {
|
||||
return -1
|
||||
}
|
||||
idx, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// extractPeripheralName extracts the device ID from a hot-plugged QDev path.
|
||||
// For example: "/machine/peripheral/vdisk-vol-abc123/virtio-backend" → "vdisk-vol-abc123"
|
||||
func extractPeripheralName(qdev string) string {
|
||||
// Path format: /machine/peripheral/<name>/virtio-backend
|
||||
const prefix = "/machine/peripheral/"
|
||||
if !strings.HasPrefix(qdev, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := qdev[len(prefix):]
|
||||
if idx := strings.Index(rest, "/"); idx > 0 {
|
||||
return rest[:idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHotplugPort extracts the hotplug port number from a QDev path.
|
||||
// For example: "/machine/peripheral/vdisk-vol-xxx/hotplug3/virtio-backend" → 3
|
||||
// Returns -1 if no hotplug port is found.
|
||||
func extractHotplugPort(qdev string) int {
|
||||
matches := hotplugPortRegexp.FindStringSubmatch(qdev)
|
||||
if len(matches) < 2 {
|
||||
return -1
|
||||
}
|
||||
idx, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/hive/hive/qmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractPCIIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
qdev string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "standard peripheral-anon device",
|
||||
qdev: "/machine/peripheral-anon/device[0]/virtio-backend",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "device index 3",
|
||||
qdev: "/machine/peripheral-anon/device[3]/virtio-backend",
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "hotplug device with higher index",
|
||||
qdev: "/machine/peripheral/hotplug1/device[12]/virtio-backend",
|
||||
want: 12,
|
||||
},
|
||||
{
|
||||
name: "unattached device path",
|
||||
qdev: "/machine/unattached/device[24]",
|
||||
want: 24,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
qdev: "",
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "no device brackets",
|
||||
qdev: "/machine/peripheral/something",
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractPCIIndex(tt.qdev)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDeviceMap tests the core device mapping logic using the example
|
||||
// QMP query-block response from qmp.go:72
|
||||
func TestBuildDeviceMap(t *testing.T) {
|
||||
// Simulate the exact QMP response from the example in qmp.go
|
||||
devices := []qmp.BlockDevice{
|
||||
{
|
||||
IOStatus: "ok",
|
||||
Device: "os",
|
||||
Locked: false,
|
||||
Inserted: &qmp.BlockInserted{
|
||||
Image: qmp.BlockImage{
|
||||
VirtualSize: 4294967296,
|
||||
Filename: "nbd://127.0.0.1:44801",
|
||||
Format: "raw",
|
||||
},
|
||||
},
|
||||
QDev: "/machine/peripheral-anon/device[0]/virtio-backend",
|
||||
Type: "unknown",
|
||||
},
|
||||
{
|
||||
IOStatus: "ok",
|
||||
Device: "cloudinit",
|
||||
Locked: false,
|
||||
Inserted: &qmp.BlockInserted{
|
||||
Image: qmp.BlockImage{
|
||||
VirtualSize: 1048576,
|
||||
Filename: "nbd://127.0.0.1:42911",
|
||||
Format: "raw",
|
||||
},
|
||||
RO: true,
|
||||
},
|
||||
QDev: "/machine/peripheral-anon/device[3]/virtio-backend",
|
||||
Type: "unknown",
|
||||
},
|
||||
{
|
||||
IOStatus: "ok",
|
||||
Device: "ide1-cd0",
|
||||
Locked: false,
|
||||
Removable: true,
|
||||
QDev: "/machine/unattached/device[24]",
|
||||
Type: "unknown",
|
||||
},
|
||||
{
|
||||
Device: "floppy0",
|
||||
Locked: false,
|
||||
Removable: true,
|
||||
QDev: "/machine/unattached/device[18]",
|
||||
Type: "unknown",
|
||||
},
|
||||
{
|
||||
Device: "sd0",
|
||||
Locked: false,
|
||||
Removable: true,
|
||||
Type: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
result := buildDeviceMap(devices)
|
||||
|
||||
assert.Equal(t, "/dev/vda", result["os"], "root disk should be /dev/vda")
|
||||
assert.Equal(t, "/dev/vdb", result["cloudinit"], "cloudinit should be /dev/vdb")
|
||||
assert.NotContains(t, result, "ide1-cd0", "ide device should be excluded")
|
||||
assert.NotContains(t, result, "floppy0", "floppy should be excluded")
|
||||
assert.NotContains(t, result, "sd0", "sd device should be excluded")
|
||||
assert.Len(t, result, 2, "should only have 2 virtio devices")
|
||||
}
|
||||
|
||||
func TestBuildDeviceMapWithHotplug(t *testing.T) {
|
||||
// Hot-plugged devices have empty Device field and use
|
||||
// /machine/peripheral/<id>/virtio-backend QDev path format
|
||||
devices := []qmp.BlockDevice{
|
||||
{
|
||||
Device: "os",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral-anon/device[0]/virtio-backend",
|
||||
},
|
||||
{
|
||||
Device: "cloudinit",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral-anon/device[3]/virtio-backend",
|
||||
},
|
||||
{
|
||||
Device: "",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral/vdisk-vol-abc123/virtio-backend",
|
||||
},
|
||||
{
|
||||
Device: "",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral/vdisk-vol-def456/virtio-backend",
|
||||
},
|
||||
// Legacy devices to filter out
|
||||
{Device: "floppy0", QDev: "/machine/unattached/device[18]"},
|
||||
{Device: "sd0"},
|
||||
{Device: "ide1-cd0", QDev: "/machine/unattached/device[24]"},
|
||||
}
|
||||
|
||||
result := buildDeviceMap(devices)
|
||||
|
||||
assert.Equal(t, "/dev/vda", result["os"])
|
||||
assert.Equal(t, "/dev/vdb", result["cloudinit"])
|
||||
assert.Equal(t, "/dev/vdc", result["vdisk-vol-abc123"])
|
||||
assert.Equal(t, "/dev/vdd", result["vdisk-vol-def456"])
|
||||
assert.Len(t, result, 4)
|
||||
}
|
||||
|
||||
func TestBuildDeviceMapEmpty(t *testing.T) {
|
||||
result := buildDeviceMap(nil)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestBuildDeviceMapPCIOrdering(t *testing.T) {
|
||||
// Boot devices returned out of order + hot-plugged device
|
||||
devices := []qmp.BlockDevice{
|
||||
{
|
||||
Device: "cloudinit",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral-anon/device[5]/virtio-backend",
|
||||
},
|
||||
{
|
||||
Device: "os",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral-anon/device[1]/virtio-backend",
|
||||
},
|
||||
{
|
||||
Device: "",
|
||||
Inserted: &qmp.BlockInserted{},
|
||||
QDev: "/machine/peripheral/vdisk-vol-123/virtio-backend",
|
||||
},
|
||||
}
|
||||
|
||||
result := buildDeviceMap(devices)
|
||||
|
||||
assert.Equal(t, "/dev/vda", result["os"], "lowest PCI index gets /dev/vda")
|
||||
assert.Equal(t, "/dev/vdb", result["cloudinit"], "second boot device")
|
||||
assert.Equal(t, "/dev/vdc", result["vdisk-vol-123"], "hot-plugged device sorts after boot devices")
|
||||
}
|
||||
|
||||
func TestExtractPeripheralName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
qdev string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "hot-plugged volume",
|
||||
qdev: "/machine/peripheral/vdisk-vol-abc123/virtio-backend",
|
||||
want: "vdisk-vol-abc123",
|
||||
},
|
||||
{
|
||||
name: "boot device path",
|
||||
qdev: "/machine/peripheral-anon/device[0]/virtio-backend",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
qdev: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no trailing slash",
|
||||
qdev: "/machine/peripheral/",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, extractPeripheralName(tt.qdev))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHotplugPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
qdev string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "hotplug port 3",
|
||||
qdev: "/machine/peripheral/vdisk-vol-xxx/hotplug3/virtio-backend",
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "no hotplug in path",
|
||||
qdev: "/machine/peripheral/vdisk-vol-xxx/virtio-backend",
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "boot device",
|
||||
qdev: "/machine/peripheral-anon/device[0]/virtio-backend",
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, extractHotplugPort(tt.qdev))
|
||||
})
|
||||
}
|
||||
}
|
||||
+14
-9
@@ -20,6 +20,19 @@ const (
|
||||
restartBackoffMax = 2 * time.Minute
|
||||
)
|
||||
|
||||
// restartBackoff computes the exponential backoff delay for the given
|
||||
// restart count. Pure function — no side effects.
|
||||
func restartBackoff(restartCount int) time.Duration {
|
||||
delay := restartBackoffBase
|
||||
for range restartCount {
|
||||
delay *= 2
|
||||
if delay > restartBackoffMax {
|
||||
return restartBackoffMax
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// classifyCrashReason extracts a human-readable crash reason from the error
|
||||
// returned by cmd.Wait(). Uses exec.ExitError + syscall.WaitStatus to
|
||||
// distinguish OOM kills (SIGKILL), segfaults (SIGSEGV), etc.
|
||||
@@ -210,15 +223,7 @@ func (d *Daemon) maybeRestartInstance(instance *vm.VM) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate exponential backoff
|
||||
delay := restartBackoffBase
|
||||
for i := 0; i < restartCount; i++ {
|
||||
delay *= 2
|
||||
if delay > restartBackoffMax {
|
||||
delay = restartBackoffMax
|
||||
break
|
||||
}
|
||||
}
|
||||
delay := restartBackoff(restartCount)
|
||||
|
||||
slog.Info("Scheduling instance restart",
|
||||
"instance", instance.ID,
|
||||
|
||||
+282
-19
@@ -2,7 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -177,26 +179,287 @@ func TestMaybeRestart_ResetsAfterWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
tests := []struct {
|
||||
restartCount int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 5 * time.Second},
|
||||
{1, 10 * time.Second},
|
||||
{2, 20 * time.Second},
|
||||
{3, 40 * time.Second},
|
||||
{4, 80 * time.Second},
|
||||
{5, 120 * time.Second}, // capped at 2min
|
||||
{6, 120 * time.Second}, // stays capped
|
||||
{100, 120 * time.Second}, // large count stays capped
|
||||
}
|
||||
|
||||
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)
|
||||
for _, tc := range tests {
|
||||
got := restartBackoff(tc.restartCount)
|
||||
assert.Equal(t, tc.want, got, "Backoff mismatch at restart count %d", tc.restartCount)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestDaemon creates a Daemon with shared NATS and a ResourceManager for health tests.
|
||||
// jsManager is nil — WriteState will error (logged, not fatal).
|
||||
func newTestDaemon(t *testing.T) (*Daemon, func()) {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
natsConn: nc,
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
return d, func() { nc.Close() }
|
||||
}
|
||||
|
||||
// smallestAllocType returns the name of the smallest allocatable instance type
|
||||
// in the resource manager, considering both vCPU and memory to ensure it fits
|
||||
// on the current host (important for CI runners with limited resources).
|
||||
func smallestAllocType(t *testing.T, rm *ResourceManager) string {
|
||||
t.Helper()
|
||||
var smallest string
|
||||
var smallestScore int64 = 1<<63 - 1
|
||||
for name, it := range rm.instanceTypes {
|
||||
vcpu := instanceTypeVCPUs(it)
|
||||
memMiB := instanceTypeMemoryMiB(it)
|
||||
// Score by total resource footprint so we pick the type smallest in both dimensions
|
||||
score := vcpu*1024 + memMiB
|
||||
if score < smallestScore {
|
||||
smallestScore = score
|
||||
smallest = name
|
||||
}
|
||||
}
|
||||
if smallest == "" {
|
||||
t.Fatal("resource manager has no instance types")
|
||||
}
|
||||
return smallest
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_CoreFlow(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
instanceType := d.resourceMgr.instanceTypes[allocType]
|
||||
require.NoError(t, d.resourceMgr.allocate(instanceType))
|
||||
|
||||
// Record resource levels before crash
|
||||
d.resourceMgr.mu.RLock()
|
||||
allocVCPUBefore := d.resourceMgr.allocatedVCPU
|
||||
allocMemBefore := d.resourceMgr.allocatedMem
|
||||
d.resourceMgr.mu.RUnlock()
|
||||
require.Greater(t, allocVCPUBefore, 0)
|
||||
|
||||
// Create a temp QMP socket file to verify removal
|
||||
tmpDir := t.TempDir()
|
||||
qmpPath := filepath.Join(tmpDir, "qmp.sock")
|
||||
require.NoError(t, os.WriteFile(qmpPath, []byte("dummy"), 0o600))
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-core",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 12345,
|
||||
InstanceType: allocType,
|
||||
Config: vm.Config{QMPSocket: qmpPath},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("test crash"))
|
||||
|
||||
// Status transitioned to error
|
||||
assert.Equal(t, vm.StateError, instance.Status)
|
||||
|
||||
// Health tracking updated
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.False(t, instance.Health.LastCrashTime.IsZero())
|
||||
assert.Equal(t, "unknown", instance.Health.LastCrashReason) // fmt.Errorf is not *exec.ExitError
|
||||
assert.False(t, instance.Health.FirstCrashTime.IsZero())
|
||||
|
||||
// Running and PID cleared
|
||||
assert.False(t, instance.Running)
|
||||
assert.Equal(t, 0, instance.PID)
|
||||
|
||||
// Resources deallocated
|
||||
d.resourceMgr.mu.RLock()
|
||||
assert.Less(t, d.resourceMgr.allocatedVCPU, allocVCPUBefore)
|
||||
assert.Less(t, d.resourceMgr.allocatedMem, allocMemBefore)
|
||||
d.resourceMgr.mu.RUnlock()
|
||||
|
||||
// QMP socket file removed
|
||||
_, err := os.Stat(qmpPath)
|
||||
assert.True(t, os.IsNotExist(err), "QMP socket should be removed")
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_FirstCrashSetsTime(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-firstcrash",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 111,
|
||||
InstanceType: allocType,
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// First crash
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash 1"))
|
||||
firstTime := instance.Health.FirstCrashTime
|
||||
assert.False(t, firstTime.IsZero())
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
|
||||
// Reset to running for a second crash
|
||||
d.Instances.Mu.Lock()
|
||||
instance.Status = vm.StateRunning
|
||||
instance.Running = true
|
||||
instance.PID = 222
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
time.Sleep(time.Millisecond) // ensure time advances
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash 2"))
|
||||
|
||||
// FirstCrashTime should be unchanged from the first crash
|
||||
assert.Equal(t, firstTime, instance.Health.FirstCrashTime)
|
||||
assert.Equal(t, 2, instance.Health.CrashCount)
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_UnknownInstanceType(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-unknown-type",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 333,
|
||||
InstanceType: "z99.nonexistent",
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// Should not panic even though instance type is not in resourceMgr
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash"))
|
||||
|
||||
assert.Equal(t, vm.StateError, instance.Status)
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.False(t, instance.Running)
|
||||
assert.Equal(t, 0, instance.PID)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_SkipsShuttingDown(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
d.shuttingDown.Store(true)
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-shutdown",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// RestartCount should not have changed — restart was skipped
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_UnknownInstanceType(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-unknown",
|
||||
Status: vm.StateError,
|
||||
InstanceType: "z99.nonexistent",
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// Should not panic — just log and return
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// No restart scheduled
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_InsufficientResources(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
// Exhaust all resources by setting allocated = available
|
||||
d.resourceMgr.mu.Lock()
|
||||
d.resourceMgr.allocatedVCPU = d.resourceMgr.availableVCPU
|
||||
d.resourceMgr.allocatedMem = d.resourceMgr.availableMem
|
||||
d.resourceMgr.mu.Unlock()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-nores",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// No restart scheduled
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_SchedulesRestart(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-schedule",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
RestartCount: 0,
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// maybeRestartInstance should pass all guards and call time.AfterFunc.
|
||||
// The scheduled restartCrashedInstance will increment RestartCount and
|
||||
// attempt LaunchInstance (which will fail without real infra — that's fine).
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// The restart is scheduled via time.AfterFunc with 5s delay.
|
||||
// We verify that the scheduling code path was reached by checking that
|
||||
// the function didn't bail out early (all early returns leave RestartCount at 0
|
||||
// and don't call time.AfterFunc). The counter reset branch does not apply since
|
||||
// FirstCrashTime is recent. If we got here without panic, the scheduling path ran.
|
||||
// We can't easily assert the timer fired without waiting 5s, but we can
|
||||
// verify the counters were not reset (proving we didn't take the window-expired path).
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.Equal(t, 0, instance.Health.RestartCount) // incremented by restartCrashedInstance, not maybeRestart
|
||||
}
|
||||
|
||||
+409
-49
@@ -3,8 +3,10 @@ package daemon
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
@@ -20,14 +22,20 @@ const (
|
||||
InstanceStatePrefix = "node."
|
||||
// StoppedInstancePrefix is the key prefix for stopped instances in shared KV
|
||||
StoppedInstancePrefix = "instance."
|
||||
// TerminatedInstanceBucket is the name of the KV bucket for terminated instances (auto-expiry via TTL)
|
||||
TerminatedInstanceBucket = "hive-terminated-instances"
|
||||
// TerminatedInstancePrefix is the key prefix for terminated instances
|
||||
TerminatedInstancePrefix = "terminated."
|
||||
)
|
||||
|
||||
// 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
|
||||
js nats.JetStreamContext
|
||||
kv nats.KeyValue // hive-instance-state
|
||||
clusterKV nats.KeyValue // hive-cluster-state
|
||||
terminatedKV nats.KeyValue // hive-terminated-instances
|
||||
replicas int
|
||||
kvMu sync.Mutex // protects kv during recovery
|
||||
}
|
||||
|
||||
// NewJetStreamManager creates a new JetStreamManager from a NATS connection.
|
||||
@@ -99,6 +107,102 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL.
|
||||
// JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances.
|
||||
func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
|
||||
kv, err := m.js.KeyValue(TerminatedInstanceBucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
slog.Debug("Creating JetStream KV bucket", "bucket", TerminatedInstanceBucket, "replicas", m.replicas)
|
||||
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: TerminatedInstanceBucket,
|
||||
Description: "Terminated instances (auto-expire after 1 hour)",
|
||||
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", TerminatedInstanceBucket)
|
||||
}
|
||||
|
||||
m.terminatedKV = kv
|
||||
return nil
|
||||
}
|
||||
|
||||
// isStreamUnavailable checks if an error indicates the underlying JetStream stream
|
||||
// was lost or is unreachable. This can happen during NATS cluster formation when
|
||||
// streams created with low replication are disrupted by node join/catchup operations.
|
||||
// Different KV operations surface different errors when the stream is gone:
|
||||
// - Get/Keys → ErrNoResponders ("no responders available for request")
|
||||
// - Put/Delete → ErrNoStreamResponse ("no response from stream")
|
||||
// - Direct stream queries → ErrStreamNotFound ("stream not found")
|
||||
func isStreamUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrStreamNotFound) ||
|
||||
errors.Is(err, nats.ErrNoStreamResponse) ||
|
||||
errors.Is(err, nats.ErrNoResponders) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(err.Error(), "stream not found")
|
||||
}
|
||||
|
||||
// recoverBucket attempts to reconnect to or re-create a KV bucket after the
|
||||
// underlying JetStream stream was lost during cluster formation.
|
||||
// Returns the recovered KV handle directly so callers avoid a racy re-read.
|
||||
func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.KeyValue) (nats.KeyValue, error) {
|
||||
m.kvMu.Lock()
|
||||
defer m.kvMu.Unlock()
|
||||
|
||||
// Try to reconnect to existing bucket first (another goroutine may have recovered it)
|
||||
kv, err := m.js.KeyValue(cfg.Bucket)
|
||||
if err == nil {
|
||||
*field = kv
|
||||
slog.Info("Reconnected to KV bucket", "bucket", cfg.Bucket)
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, nats.ErrBucketNotFound) && !isStreamUnavailable(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bucket truly doesn't exist — recreate it
|
||||
slog.Warn("KV bucket stream lost, recreating", "bucket", cfg.Bucket, "replicas", m.replicas)
|
||||
cfg.History = 1
|
||||
cfg.Replicas = m.replicas
|
||||
kv, err = m.js.CreateKeyValue(cfg)
|
||||
if err != nil {
|
||||
slog.Error("Failed to recreate KV bucket", "bucket", cfg.Bucket, "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
*field = kv
|
||||
slog.Info("KV bucket recreated successfully", "bucket", cfg.Bucket)
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (m *JetStreamManager) recoverKVBucket() (nats.KeyValue, error) {
|
||||
return m.recoverBucket(&nats.KeyValueConfig{
|
||||
Bucket: InstanceStateBucket,
|
||||
Description: "Hive instance state storage",
|
||||
}, &m.kv)
|
||||
}
|
||||
|
||||
func (m *JetStreamManager) recoverTerminatedKVBucket() (nats.KeyValue, error) {
|
||||
return m.recoverBucket(&nats.KeyValueConfig{
|
||||
Bucket: TerminatedInstanceBucket,
|
||||
Description: "Terminated instances (auto-expire after 1 hour)",
|
||||
TTL: 1 * time.Hour,
|
||||
}, &m.terminatedKV)
|
||||
}
|
||||
|
||||
// Heartbeat represents a daemon's periodic health status published to cluster KV.
|
||||
type Heartbeat struct {
|
||||
Node string `json:"node"`
|
||||
@@ -198,11 +302,14 @@ 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{
|
||||
data, err := json.Marshal(map[string]any{
|
||||
"node": nodeID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_, err := m.clusterKV.Put("shutdown."+nodeID, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal shutdown marker: %w", err)
|
||||
}
|
||||
_, err = m.clusterKV.Put("shutdown."+nodeID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -238,14 +345,17 @@ func (m *JetStreamManager) WriteServiceManifest(nodeID string, services []string
|
||||
if m.clusterKV == nil {
|
||||
return errors.New("cluster state KV not initialized")
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
data, err := 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal service manifest: %w", err)
|
||||
}
|
||||
_, err = m.clusterKV.Put("node."+nodeID+".services", data)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -274,6 +384,18 @@ func (m *JetStreamManager) WriteState(nodeID string, instances *vm.Instances) er
|
||||
key := InstanceStatePrefix + nodeID
|
||||
_, err = m.kv.Put(key, jsonData)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if _, retryErr := kv.Put(key, jsonData); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote state to JetStream KV (after recovery)", "key", key, "instances", len(instances.VMS))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -291,11 +413,28 @@ func (m *JetStreamManager) LoadState(nodeID string) (*vm.Instances, error) {
|
||||
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
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry the read — if we reconnected, data may still exist
|
||||
entry, err = kv.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
slog.Warn("No state found after KV recovery", "key", key)
|
||||
return &vm.Instances{VMS: make(map[string]*vm.VM)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Fall through to unmarshal below
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var instances vm.Instances
|
||||
@@ -321,6 +460,18 @@ func (m *JetStreamManager) DeleteState(nodeID string) error {
|
||||
key := InstanceStatePrefix + nodeID
|
||||
err := m.kv.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
// Retry — if we reconnected the key may still exist
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -328,52 +479,48 @@ func (m *JetStreamManager) DeleteState(nodeID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateReplicas updates the replica count for the KV bucket's underlying stream.
|
||||
// UpdateReplicas updates the replica count for ALL JetStream KV buckets.
|
||||
// It iterates over every KV_* stream and bumps replicas to match the cluster size.
|
||||
// This ensures service buckets (IAM, VPC, IGW, etc.) are replicated alongside daemon buckets.
|
||||
// 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)
|
||||
}
|
||||
// Iterate all streams and update any KV-backed stream (prefixed "KV_")
|
||||
updated := 0
|
||||
for name := range m.js.StreamNames() {
|
||||
if !strings.HasPrefix(name, "KV_") {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := m.js.StreamInfo(name)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get stream info", "stream", name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Config.Replicas >= newReplicas {
|
||||
continue
|
||||
}
|
||||
|
||||
oldReplicas := info.Config.Replicas
|
||||
info.Config.Replicas = newReplicas
|
||||
if _, err := m.js.UpdateStream(&info.Config); err != nil {
|
||||
slog.Warn("Failed to update KV bucket replicas", "stream", name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
bucket := strings.TrimPrefix(name, "KV_")
|
||||
slog.Info("Updated KV bucket replicas", "bucket", bucket, "oldReplicas", oldReplicas, "newReplicas", newReplicas)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
slog.Info("KV replication update complete", "bucketsUpdated", updated, "replicas", newReplicas)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -393,6 +540,18 @@ func (m *JetStreamManager) WriteStoppedInstance(instanceID string, instance *vm.
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
_, err = m.kv.Put(key, jsonData)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if _, retryErr := kv.Put(key, jsonData); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote stopped instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -413,7 +572,24 @@ func (m *JetStreamManager) LoadStoppedInstance(instanceID string) (*vm.VM, error
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry — if we reconnected, data may still exist
|
||||
entry, err = kv.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Fall through to unmarshal below
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var instance vm.VM
|
||||
@@ -434,6 +610,18 @@ func (m *JetStreamManager) DeleteStoppedInstance(instanceID string) error {
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
err := m.kv.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
// Retry — if we reconnected the key may still exist
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -452,7 +640,24 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListStoppedInstances", "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry — if we reconnected, data may still exist
|
||||
keys, err = kv.Keys()
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Fall through to iterate keys below
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var instances []*vm.VM
|
||||
@@ -480,3 +685,158 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// WriteTerminatedInstance writes a terminated instance to the terminated KV bucket.
|
||||
// The entry will auto-expire after the bucket's TTL (1 hour).
|
||||
func (m *JetStreamManager) WriteTerminatedInstance(instanceID string, instance *vm.VM) error {
|
||||
if m.terminatedKV == nil {
|
||||
return errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(instance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
_, err = m.terminatedKV.Put(key, jsonData)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if _, retryErr := kv.Put(key, jsonData); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote terminated instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("Wrote terminated instance to JetStream KV", "key", key, "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTerminatedInstances returns all terminated instances from the terminated KV bucket.
|
||||
func (m *JetStreamManager) ListTerminatedInstances() ([]*vm.VM, error) {
|
||||
if m.terminatedKV == nil {
|
||||
return nil, errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
keys, err := m.terminatedKV.Keys()
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListTerminatedInstances", "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err = kv.Keys()
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var instances []*vm.VM
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, TerminatedInstancePrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := m.terminatedKV.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 terminated instance", "key", key, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
instances = append(instances, &instance)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// DeleteTerminatedInstance removes a terminated instance from the terminated KV bucket.
|
||||
func (m *JetStreamManager) DeleteTerminatedInstance(instanceID string) error {
|
||||
if m.terminatedKV == nil {
|
||||
return errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
err := m.terminatedKV.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("Deleted terminated instance from JetStream KV", "key", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadTerminatedInstance loads a single terminated instance from the terminated KV bucket.
|
||||
// Returns nil, nil if the key does not exist.
|
||||
func (m *JetStreamManager) LoadTerminatedInstance(instanceID string) (*vm.VM, error) {
|
||||
if m.terminatedKV == nil {
|
||||
return nil, errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
entry, err := m.terminatedKV.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err = kv.Get(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var instance vm.VM
|
||||
if err := json.Unmarshal(entry.Value(), &instance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
@@ -571,3 +573,638 @@ func TestJetStreamManager_StoppedInstance_KVNotInitialized(t *testing.T) {
|
||||
_, err = jsm.ListStoppedInstances()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- WriteServiceManifest KV tests ---
|
||||
|
||||
// TestJetStreamManager_WriteServiceManifest tests writing a service manifest to the cluster-state KV.
|
||||
func TestJetStreamManager_WriteServiceManifest(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.InitClusterStateBucket()
|
||||
require.NoError(t, err)
|
||||
|
||||
services := []string{"daemon", "nats", "predastore"}
|
||||
err = jsm.WriteServiceManifest("test-node-svc", services, "10.0.0.1:4222", "10.0.0.1:8443")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read the KV entry directly and verify JSON contents
|
||||
entry, err := jsm.clusterKV.Get("node.test-node-svc.services")
|
||||
require.NoError(t, err)
|
||||
|
||||
var manifest map[string]any
|
||||
err = json.Unmarshal(entry.Value(), &manifest)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test-node-svc", manifest["node"])
|
||||
assert.Equal(t, "10.0.0.1:4222", manifest["nats_host"])
|
||||
assert.Equal(t, "10.0.0.1:8443", manifest["predastore_host"])
|
||||
assert.NotEmpty(t, manifest["timestamp"])
|
||||
|
||||
// Verify services list
|
||||
svcList, ok := manifest["services"].([]any)
|
||||
require.True(t, ok, "services should be a JSON array")
|
||||
assert.Len(t, svcList, 3)
|
||||
assert.Equal(t, "daemon", svcList[0])
|
||||
assert.Equal(t, "nats", svcList[1])
|
||||
assert.Equal(t, "predastore", svcList[2])
|
||||
}
|
||||
|
||||
// TestJetStreamManager_WriteServiceManifest_EmptyServices tests writing a manifest with no services.
|
||||
func TestJetStreamManager_WriteServiceManifest_EmptyServices(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.InitClusterStateBucket()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = jsm.WriteServiceManifest("empty-svc-node", []string{}, "10.0.0.2:4222", "10.0.0.2:8443")
|
||||
require.NoError(t, err)
|
||||
|
||||
entry, err := jsm.clusterKV.Get("node.empty-svc-node.services")
|
||||
require.NoError(t, err)
|
||||
|
||||
var manifest map[string]any
|
||||
err = json.Unmarshal(entry.Value(), &manifest)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "empty-svc-node", manifest["node"])
|
||||
svcList, ok := manifest["services"].([]any)
|
||||
require.True(t, ok, "services should be a JSON array")
|
||||
assert.Empty(t, svcList)
|
||||
}
|
||||
|
||||
// TestJetStreamManager_WriteServiceManifest_ClusterKVNotInitialized tests error when clusterKV is nil.
|
||||
func TestJetStreamManager_WriteServiceManifest_ClusterKVNotInitialized(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 InitClusterStateBucket
|
||||
|
||||
err = jsm.WriteServiceManifest("test-node", []string{"daemon"}, "10.0.0.1:4222", "10.0.0.1:8443")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cluster state KV not initialized")
|
||||
}
|
||||
|
||||
// --- KV bucket recovery tests ---
|
||||
// These tests simulate the stream being deleted after initialization (which happens
|
||||
// during NATS cluster formation when catchup resets corrupt a stream) and verify
|
||||
// that operations recover by re-creating the bucket.
|
||||
|
||||
// deleteInstanceStateBucket deletes the underlying JetStream stream for the instance-state KV bucket.
|
||||
func deleteInstanceStateBucket(t *testing.T, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
err = js.DeleteStream("KV_" + InstanceStateBucket)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteState_RecoverAfterStreamLost(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)
|
||||
|
||||
// Delete the underlying stream to simulate cluster formation loss
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// WriteState should recover by recreating the bucket
|
||||
testInstances := &vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-recover-001": {ID: "i-recover-001", Status: vm.StateRunning},
|
||||
},
|
||||
}
|
||||
err = jsm.WriteState("recovery-node", testInstances)
|
||||
require.NoError(t, err, "WriteState should recover after stream loss")
|
||||
|
||||
// Verify data was written
|
||||
loaded, err := jsm.LoadState("recovery-node")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, loaded.VMS, 1)
|
||||
assert.NotNil(t, loaded.VMS["i-recover-001"])
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadState_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// LoadState should recover and return empty state
|
||||
loaded, err := jsm.LoadState("any-node")
|
||||
require.NoError(t, err, "LoadState should recover after stream loss")
|
||||
assert.Empty(t, loaded.VMS)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteState_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// DeleteState should recover (nothing to delete in fresh bucket)
|
||||
err = jsm.DeleteState("any-node")
|
||||
require.NoError(t, err, "DeleteState should recover after stream loss")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteStoppedInstance_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// WriteStoppedInstance should recover
|
||||
testVM := &vm.VM{ID: "i-stopped-recover", Status: vm.StateStopped, InstanceType: "t3.micro"}
|
||||
err = jsm.WriteStoppedInstance(testVM.ID, testVM)
|
||||
require.NoError(t, err, "WriteStoppedInstance should recover after stream loss")
|
||||
|
||||
// Verify data was written
|
||||
loaded, err := jsm.LoadStoppedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, "i-stopped-recover", loaded.ID)
|
||||
|
||||
_ = jsm.DeleteStoppedInstance(testVM.ID)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadStoppedInstance_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// LoadStoppedInstance should recover and return nil (not found)
|
||||
loaded, err := jsm.LoadStoppedInstance("i-nonexistent")
|
||||
require.NoError(t, err, "LoadStoppedInstance should recover after stream loss")
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteStoppedInstance_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
err = jsm.DeleteStoppedInstance("i-any")
|
||||
require.NoError(t, err, "DeleteStoppedInstance should recover after stream loss")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_ListStoppedInstances_RecoverAfterStreamLost(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
|
||||
// ListStoppedInstances should recover and return empty
|
||||
instances, err := jsm.ListStoppedInstances()
|
||||
require.NoError(t, err, "ListStoppedInstances should recover after stream loss")
|
||||
assert.Empty(t, instances)
|
||||
}
|
||||
|
||||
// --- KV bucket recovery failure tests ---
|
||||
// These tests verify that when recovery itself fails (e.g., NATS is unreachable),
|
||||
// errors are properly propagated rather than silently returning empty state.
|
||||
|
||||
// swapToNonJSContext replaces the JetStreamManager's JS context with one from the
|
||||
// non-JetStream NATS server. The original kv handle still targets the JS server
|
||||
// (where the stream was deleted), so operations fail with stream-unavailable errors.
|
||||
// Recovery then also fails because the non-JS server has no JetStream support.
|
||||
func swapToNonJSContext(t *testing.T, jsm *JetStreamManager) {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
jsm.js = js
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteState_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
testInstances := &vm.Instances{VMS: map[string]*vm.VM{"i-fail": {ID: "i-fail"}}}
|
||||
err = jsm.WriteState("fail-node", testInstances)
|
||||
assert.Error(t, err, "WriteState should return error when recovery fails")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadState_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
loaded, err := jsm.LoadState("fail-node")
|
||||
assert.Error(t, err, "LoadState should return error when recovery fails")
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteState_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
err = jsm.DeleteState("fail-node")
|
||||
assert.Error(t, err, "DeleteState should return error when recovery fails")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteStoppedInstance_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
testVM := &vm.VM{ID: "i-fail", Status: vm.StateStopped}
|
||||
err = jsm.WriteStoppedInstance(testVM.ID, testVM)
|
||||
assert.Error(t, err, "WriteStoppedInstance should return error when recovery fails")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadStoppedInstance_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
loaded, err := jsm.LoadStoppedInstance("i-fail")
|
||||
assert.Error(t, err, "LoadStoppedInstance should return error when recovery fails")
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteStoppedInstance_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
err = jsm.DeleteStoppedInstance("i-fail")
|
||||
assert.Error(t, err, "DeleteStoppedInstance should return error when recovery fails")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_ListStoppedInstances_RecoveryFailure(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)
|
||||
|
||||
deleteInstanceStateBucket(t, nc)
|
||||
swapToNonJSContext(t, jsm)
|
||||
|
||||
instances, err := jsm.ListStoppedInstances()
|
||||
assert.Error(t, err, "ListStoppedInstances should return error when recovery fails")
|
||||
assert.Nil(t, instances)
|
||||
}
|
||||
|
||||
func TestIsStreamUnavailable(t *testing.T) {
|
||||
assert.False(t, isStreamUnavailable(nil))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrStreamNotFound))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrNoStreamResponse))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrNoResponders))
|
||||
assert.True(t, isStreamUnavailable(errors.New("nats: stream not found")))
|
||||
assert.False(t, isStreamUnavailable(errors.New("some other error")))
|
||||
assert.False(t, isStreamUnavailable(nats.ErrKeyNotFound))
|
||||
}
|
||||
|
||||
// --- Terminated instance KV tests ---
|
||||
|
||||
func TestJetStreamManager_WriteAndLoadTerminatedInstance(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
testVM := &vm.VM{
|
||||
ID: "i-term-001",
|
||||
Status: vm.StateTerminated,
|
||||
InstanceType: "t3.micro",
|
||||
LastNode: "node-1",
|
||||
}
|
||||
|
||||
err = jsm.WriteTerminatedInstance(testVM.ID, testVM)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := jsm.LoadTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, testVM.ID, loaded.ID)
|
||||
assert.Equal(t, vm.StateTerminated, loaded.Status)
|
||||
assert.Equal(t, "t3.micro", loaded.InstanceType)
|
||||
assert.Equal(t, "node-1", loaded.LastNode)
|
||||
|
||||
_ = jsm.DeleteTerminatedInstance(testVM.ID)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadTerminatedInstance_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)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
loaded, err := jsm.LoadTerminatedInstance("i-nonexistent")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteTerminatedInstance(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
testVM := &vm.VM{
|
||||
ID: "i-term-del",
|
||||
Status: vm.StateTerminated,
|
||||
}
|
||||
|
||||
err = jsm.WriteTerminatedInstance(testVM.ID, testVM)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = jsm.DeleteTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := jsm.LoadTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, loaded)
|
||||
|
||||
// Delete non-existent should not error
|
||||
err = jsm.DeleteTerminatedInstance("i-does-not-exist")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_ListTerminatedInstances(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
vms := []*vm.VM{
|
||||
{ID: "i-tlist-001", Status: vm.StateTerminated, InstanceType: "t3.micro"},
|
||||
{ID: "i-tlist-002", Status: vm.StateTerminated, InstanceType: "t3.small"},
|
||||
}
|
||||
|
||||
for _, v := range vms {
|
||||
require.NoError(t, jsm.WriteTerminatedInstance(v.ID, v))
|
||||
}
|
||||
defer func() {
|
||||
for _, v := range vms {
|
||||
_ = jsm.DeleteTerminatedInstance(v.ID)
|
||||
}
|
||||
}()
|
||||
|
||||
instances, err := jsm.ListTerminatedInstances()
|
||||
require.NoError(t, err)
|
||||
// Use >= because other tests may leave entries in the shared bucket
|
||||
assert.GreaterOrEqual(t, len(instances), 2)
|
||||
|
||||
idSet := map[string]bool{}
|
||||
for _, inst := range instances {
|
||||
idSet[inst.ID] = true
|
||||
}
|
||||
assert.True(t, idSet["i-tlist-001"])
|
||||
assert.True(t, idSet["i-tlist-002"])
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteLoadDeleteTerminatedInstance_RoundTrip(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
testVM := &vm.VM{
|
||||
ID: "i-trt-001",
|
||||
Status: vm.StateTerminated,
|
||||
}
|
||||
|
||||
// Write
|
||||
require.NoError(t, jsm.WriteTerminatedInstance(testVM.ID, testVM))
|
||||
|
||||
// Load — should find it
|
||||
loaded, err := jsm.LoadTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, vm.StateTerminated, loaded.Status)
|
||||
|
||||
// Delete
|
||||
require.NoError(t, jsm.DeleteTerminatedInstance(testVM.ID))
|
||||
|
||||
// Load — should be gone
|
||||
loaded, err = jsm.LoadTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_TerminatedInstance_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 InitTerminatedInstanceBucket
|
||||
|
||||
err = jsm.WriteTerminatedInstance("i-test", &vm.VM{})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = jsm.LoadTerminatedInstance("i-test")
|
||||
assert.Error(t, err)
|
||||
|
||||
err = jsm.DeleteTerminatedInstance("i-test")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = jsm.ListTerminatedInstances()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- Terminated KV bucket recovery tests ---
|
||||
|
||||
// deleteTerminatedInstanceBucket deletes the underlying JetStream stream for the terminated-instances KV bucket.
|
||||
func deleteTerminatedInstanceBucket(t *testing.T, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
err = js.DeleteStream("KV_" + TerminatedInstanceBucket)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_WriteTerminatedInstance_RecoverAfterStreamLost(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
deleteTerminatedInstanceBucket(t, nc)
|
||||
|
||||
testVM := &vm.VM{ID: "i-term-recover", Status: vm.StateTerminated, InstanceType: "t3.micro"}
|
||||
err = jsm.WriteTerminatedInstance(testVM.ID, testVM)
|
||||
require.NoError(t, err, "WriteTerminatedInstance should recover after stream loss")
|
||||
|
||||
loaded, err := jsm.LoadTerminatedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, "i-term-recover", loaded.ID)
|
||||
|
||||
_ = jsm.DeleteTerminatedInstance(testVM.ID)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_LoadTerminatedInstance_RecoverAfterStreamLost(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
deleteTerminatedInstanceBucket(t, nc)
|
||||
|
||||
loaded, err := jsm.LoadTerminatedInstance("i-nonexistent")
|
||||
require.NoError(t, err, "LoadTerminatedInstance should recover after stream loss")
|
||||
assert.Nil(t, loaded)
|
||||
}
|
||||
|
||||
func TestJetStreamManager_DeleteTerminatedInstance_RecoverAfterStreamLost(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
deleteTerminatedInstanceBucket(t, nc)
|
||||
|
||||
err = jsm.DeleteTerminatedInstance("i-any")
|
||||
require.NoError(t, err, "DeleteTerminatedInstance should recover after stream loss")
|
||||
}
|
||||
|
||||
func TestJetStreamManager_ListTerminatedInstances_RecoverAfterStreamLost(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
deleteTerminatedInstanceBucket(t, nc)
|
||||
|
||||
instances, err := jsm.ListTerminatedInstances()
|
||||
require.NoError(t, err, "ListTerminatedInstances should recover after stream loss")
|
||||
assert.Empty(t, instances)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sudoCommand wraps exec.Command with sudo when running as non-root.
|
||||
// OVS/OVN and ip commands require elevated privileges; in Docker and
|
||||
// production the daemon runs as root, but in dev environments it may not.
|
||||
func sudoCommand(name string, args ...string) *exec.Cmd {
|
||||
if os.Getuid() == 0 {
|
||||
return exec.Command(name, args...)
|
||||
}
|
||||
return exec.Command("sudo", append([]string{name}, args...)...)
|
||||
}
|
||||
|
||||
// NetworkPlumber handles tap device and OVS bridge operations for VM networking.
|
||||
// The live implementation runs system commands (ip, ovs-vsctl); tests use a mock.
|
||||
type NetworkPlumber interface {
|
||||
// SetupTapDevice creates a tap device and adds it to the OVS br-int bridge
|
||||
// with the correct iface-id for OVN port binding.
|
||||
SetupTapDevice(eniId, mac string) error
|
||||
|
||||
// CleanupTapDevice removes the tap device from br-int and deletes it.
|
||||
CleanupTapDevice(eniId string) error
|
||||
}
|
||||
|
||||
// OVSNetworkPlumber implements NetworkPlumber using system commands.
|
||||
type OVSNetworkPlumber struct{}
|
||||
|
||||
func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
||||
tapName := TapDeviceName(eniId)
|
||||
ifaceID := OVSIfaceID(eniId)
|
||||
|
||||
// 0. If tap already exists (e.g. unclean shutdown), clean it up first
|
||||
if _, err := os.Stat("/sys/class/net/" + tapName); err == nil {
|
||||
slog.Warn("Stale tap device found, cleaning up before recreate", "tap", tapName)
|
||||
_ = sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).Run()
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
}
|
||||
|
||||
// 1. Create tap device
|
||||
if out, err := sudoCommand("ip", "tuntap", "add", "dev", tapName, "mode", "tap").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("create tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// 2. Bring tap up
|
||||
if out, err := sudoCommand("ip", "link", "set", tapName, "up").CombinedOutput(); err != nil {
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
return fmt.Errorf("bring up tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// 3. Add to br-int with iface-id for OVN port binding
|
||||
if out, err := sudoCommand("ovs-vsctl",
|
||||
"add-port", "br-int", tapName,
|
||||
"--", "set", "Interface", tapName,
|
||||
fmt.Sprintf("external_ids:iface-id=%s", ifaceID),
|
||||
fmt.Sprintf("external_ids:attached-mac=%s", mac),
|
||||
).CombinedOutput(); err != nil {
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
return fmt.Errorf("add tap to br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
slog.Info("Network plumbing complete", "tap", tapName, "iface-id", ifaceID, "mac", mac)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OVSNetworkPlumber) CleanupTapDevice(eniId string) error {
|
||||
tapName := TapDeviceName(eniId)
|
||||
|
||||
// 1. Remove from br-int (--if-exists avoids error if already gone)
|
||||
if out, err := sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).CombinedOutput(); err != nil {
|
||||
slog.Warn("Failed to remove tap from br-int", "tap", tapName, "err", err, "out", strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// 2. Delete tap device
|
||||
if out, err := sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("delete tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
slog.Info("Network cleanup complete", "tap", tapName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TapDeviceName returns the Linux tap device name for an ENI.
|
||||
// Linux IFNAMSIZ limits interface names to 15 characters.
|
||||
// ENI IDs like "eni-abc123def456789" are too long, so we truncate.
|
||||
func TapDeviceName(eniId string) string {
|
||||
id := strings.TrimPrefix(eniId, "eni-")
|
||||
name := "tap" + id
|
||||
if len(name) > 15 {
|
||||
name = name[:15]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// OVSIfaceID returns the OVS external_ids:iface-id value for an ENI.
|
||||
// This must match the OVN LogicalSwitchPort name for ovn-controller binding.
|
||||
func OVSIfaceID(eniId string) string {
|
||||
return "port-" + eniId
|
||||
}
|
||||
|
||||
// generateDevMAC creates a locally-administered unicast MAC for the dev/hostfwd NIC.
|
||||
// Uses prefix 02:de:00 to distinguish from ENI MACs (02:00:00).
|
||||
// All octets must be valid hex for QEMU's virtio-net-pci mac property.
|
||||
func generateDevMAC(instanceId string) string {
|
||||
h := uint32(0)
|
||||
for _, c := range instanceId {
|
||||
h = h*31 + uint32(c) // #nosec G115 -- intentional overflow for hashing
|
||||
}
|
||||
return fmt.Sprintf("02:de:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
|
||||
}
|
||||
|
||||
// OVNHealthStatus reports the readiness of OVN networking on this compute node.
|
||||
type OVNHealthStatus struct {
|
||||
BrIntExists bool `json:"br_int_exists"`
|
||||
OVNControllerUp bool `json:"ovn_controller_up"`
|
||||
ChassisID string `json:"chassis_id,omitempty"`
|
||||
EncapIP string `json:"encap_ip,omitempty"`
|
||||
OVNRemote string `json:"ovn_remote,omitempty"`
|
||||
}
|
||||
|
||||
// CheckOVNHealth probes local OVS/OVN state to determine network readiness.
|
||||
func CheckOVNHealth() OVNHealthStatus {
|
||||
status := OVNHealthStatus{}
|
||||
|
||||
// Check br-int exists
|
||||
if err := sudoCommand("ovs-vsctl", "br-exists", "br-int").Run(); err == nil {
|
||||
status.BrIntExists = true
|
||||
}
|
||||
|
||||
// Check ovn-controller is running via ovs-appctl (more reliable than pgrep)
|
||||
if out, err := sudoCommand("ovs-appctl", "-t", "ovn-controller", "version").CombinedOutput(); err == nil && len(out) > 0 {
|
||||
status.OVNControllerUp = true
|
||||
}
|
||||
|
||||
// Read chassis identity from OVS external_ids
|
||||
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:system-id").CombinedOutput(); err == nil {
|
||||
status.ChassisID = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
||||
}
|
||||
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-encap-ip").CombinedOutput(); err == nil {
|
||||
status.EncapIP = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
||||
}
|
||||
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-remote").CombinedOutput(); err == nil {
|
||||
status.OVNRemote = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// SetupComputeNode configures OVS for OVN on this compute node.
|
||||
// It creates br-int with secure fail-mode and sets the OVN external_ids.
|
||||
func SetupComputeNode(chassisID, ovnRemote, encapIP string) error {
|
||||
// Create br-int if it doesn't exist
|
||||
if out, err := sudoCommand("ovs-vsctl", "--may-exist", "add-br", "br-int").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("create br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Set fail-mode=secure (preserves flows during ovn-controller restart)
|
||||
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "fail-mode=secure").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("set br-int fail-mode: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Disable in-band management (prevents OVS from adding its own flows)
|
||||
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "other-config:disable-in-band=true").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("set br-int disable-in-band: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Bring br-int up
|
||||
if out, err := sudoCommand("ip", "link", "set", "br-int", "up").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("bring up br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Set OVN external_ids on the Open_vSwitch table
|
||||
if out, err := sudoCommand("ovs-vsctl", "set", "Open_vSwitch", ".",
|
||||
fmt.Sprintf("external_ids:system-id=%s", chassisID),
|
||||
fmt.Sprintf("external_ids:ovn-remote=%s", ovnRemote),
|
||||
fmt.Sprintf("external_ids:ovn-encap-ip=%s", encapIP),
|
||||
"external_ids:ovn-encap-type=geneve",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("set OVN external_ids: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
slog.Info("OVN compute node configured",
|
||||
"chassis_id", chassisID,
|
||||
"ovn_remote", ovnRemote,
|
||||
"encap_ip", encapIP,
|
||||
)
|
||||
|
||||
// Ensure the data NIC is preferred for Geneve tunnel routing.
|
||||
if err := EnsureDataRoute(encapIP); err != nil {
|
||||
slog.Warn("Failed to configure data NIC routing (Geneve tunnels may use wrong source IP)", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDataRoute ensures the kernel routes Geneve tunnel traffic through the
|
||||
// data NIC (the interface holding the encap IP). When management and data NICs
|
||||
// share the same subnet with equal route metrics, the kernel may pick the
|
||||
// management NIC, causing Geneve packets to have the wrong source IP. Remote
|
||||
// OVS nodes then drop these packets because the source doesn't match the
|
||||
// expected tunnel remote_ip.
|
||||
//
|
||||
// Fix: find the data NIC's subnet route and replace it with a lower metric (50)
|
||||
// so it's preferred over the management NIC's route (typically metric 100+).
|
||||
func EnsureDataRoute(encapIP string) error {
|
||||
dataIface, err := findInterfaceByIP(encapIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find data interface for %s: %w", encapIP, err)
|
||||
}
|
||||
|
||||
// Read the existing subnet route for the data interface
|
||||
out, err := sudoCommand("ip", "-o", "-4", "route", "show", "dev", dataIface, "proto", "kernel", "scope", "link").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read routes for %s: %s: %w", dataIface, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Parse the subnet CIDR from the route output (e.g. "10.1.0.0/16 dev eth1 ...")
|
||||
fields := strings.Fields(strings.TrimSpace(string(out)))
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("no kernel route found for %s", dataIface)
|
||||
}
|
||||
subnet := fields[0]
|
||||
|
||||
// Replace the route with a lower metric so the data NIC is preferred
|
||||
if out, err := sudoCommand("ip", "route", "replace", subnet,
|
||||
"dev", dataIface, "src", encapIP, "metric", "50",
|
||||
).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("replace route for %s: %s: %w", subnet, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
slog.Info("Data NIC route configured for Geneve tunnels",
|
||||
"interface", dataIface,
|
||||
"subnet", subnet,
|
||||
"encap_ip", encapIP,
|
||||
"metric", 50,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// findInterfaceByIP returns the network interface name that holds the given IP address.
|
||||
func findInterfaceByIP(ipAddr string) (string, error) {
|
||||
targetIP := net.ParseIP(ipAddr)
|
||||
if targetIP == nil {
|
||||
return "", fmt.Errorf("invalid IP address: %s", ipAddr)
|
||||
}
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list interfaces: %w", err)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.Equal(targetIP) {
|
||||
return iface.Name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no interface found with IP %s", ipAddr)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
)
|
||||
|
||||
func TestTapDeviceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
eniId string
|
||||
expected string
|
||||
}{
|
||||
{"eni-abc123", "tapabc123"}, // Short ID
|
||||
{"eni-abc123def456789", "tapabc123def456"}, // Truncated to 15 chars
|
||||
{"eni-a", "tapa"}, // Minimal
|
||||
{"eni-123456789abcdef", "tap123456789abc"}, // Exactly 15 chars
|
||||
{"eni-123456789abcdefg", "tap123456789abc"}, // Truncated at 15
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eniId, func(t *testing.T) {
|
||||
got := TapDeviceName(tt.eniId)
|
||||
if got != tt.expected {
|
||||
t.Errorf("TapDeviceName(%q) = %q, want %q", tt.eniId, got, tt.expected)
|
||||
}
|
||||
if len(got) > 15 {
|
||||
t.Errorf("TapDeviceName(%q) = %q (len %d), exceeds IFNAMSIZ limit of 15", tt.eniId, got, len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOVSIfaceID(t *testing.T) {
|
||||
tests := []struct {
|
||||
eniId string
|
||||
expected string
|
||||
}{
|
||||
{"eni-abc123", "port-eni-abc123"},
|
||||
{"eni-abc123def456789", "port-eni-abc123def456789"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eniId, func(t *testing.T) {
|
||||
got := OVSIfaceID(tt.eniId)
|
||||
if got != tt.expected {
|
||||
t.Errorf("OVSIfaceID(%q) = %q, want %q", tt.eniId, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MockNetworkPlumber records calls for testing.
|
||||
type MockNetworkPlumber struct {
|
||||
SetupCalls []mockSetupCall
|
||||
CleanupCalls []string
|
||||
SetupErr error
|
||||
CleanupErr error
|
||||
}
|
||||
|
||||
type mockSetupCall struct {
|
||||
ENIId string
|
||||
MAC string
|
||||
}
|
||||
|
||||
func (m *MockNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
||||
m.SetupCalls = append(m.SetupCalls, mockSetupCall{ENIId: eniId, MAC: mac})
|
||||
return m.SetupErr
|
||||
}
|
||||
|
||||
func (m *MockNetworkPlumber) CleanupTapDevice(eniId string) error {
|
||||
m.CleanupCalls = append(m.CleanupCalls, eniId)
|
||||
return m.CleanupErr
|
||||
}
|
||||
|
||||
func TestStartInstance_VPCNetworking(t *testing.T) {
|
||||
instance := &vm.VM{
|
||||
ID: "i-test123",
|
||||
InstanceType: "t3.micro",
|
||||
ENIId: "eni-abc123def456789",
|
||||
ENIMac: "02:00:00:11:22:33",
|
||||
}
|
||||
|
||||
// When ENI is set, config should use tap networking with MAC
|
||||
instance.Config = vm.Config{
|
||||
CPUCount: 1,
|
||||
Memory: 512,
|
||||
Architecture: "x86_64",
|
||||
}
|
||||
|
||||
// Simulate what StartInstance does for VPC mode
|
||||
tapName := TapDeviceName(instance.ENIId)
|
||||
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
||||
Value: "tap,id=net0,ifname=" + tapName + ",script=no,downscript=no",
|
||||
})
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: "virtio-net-pci,netdev=net0,mac=" + instance.ENIMac,
|
||||
})
|
||||
|
||||
// Verify QEMU args
|
||||
if len(instance.Config.NetDevs) != 1 {
|
||||
t.Fatalf("expected 1 netdev, got %d", len(instance.Config.NetDevs))
|
||||
}
|
||||
|
||||
expected := "tap,id=net0,ifname=tapabc123def456,script=no,downscript=no"
|
||||
if instance.Config.NetDevs[0].Value != expected {
|
||||
t.Errorf("netdev = %q, want %q", instance.Config.NetDevs[0].Value, expected)
|
||||
}
|
||||
|
||||
expectedDev := "virtio-net-pci,netdev=net0,mac=02:00:00:11:22:33"
|
||||
if instance.Config.Devices[0].Value != expectedDev {
|
||||
t.Errorf("device = %q, want %q", instance.Config.Devices[0].Value, expectedDev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartInstance_FallbackNetworking(t *testing.T) {
|
||||
instance := &vm.VM{
|
||||
ID: "i-test456",
|
||||
InstanceType: "t3.micro",
|
||||
// No ENI — should use user-mode networking
|
||||
}
|
||||
|
||||
instance.Config = vm.Config{
|
||||
CPUCount: 1,
|
||||
Memory: 512,
|
||||
Architecture: "x86_64",
|
||||
}
|
||||
|
||||
// Simulate what StartInstance does for non-VPC mode
|
||||
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
||||
Value: "user,id=net0,hostfwd=tcp:127.0.0.1:22222-:22",
|
||||
})
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: "virtio-net-pci,netdev=net0",
|
||||
})
|
||||
|
||||
// Verify no MAC is specified (QEMU auto-assigns)
|
||||
if len(instance.Config.Devices) != 1 {
|
||||
t.Fatalf("expected 1 device, got %d", len(instance.Config.Devices))
|
||||
}
|
||||
|
||||
// User-mode networking should not include MAC
|
||||
dev := instance.Config.Devices[0].Value
|
||||
if dev != "virtio-net-pci,netdev=net0" {
|
||||
t.Errorf("device = %q, want 'virtio-net-pci,netdev=net0'", dev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockNetworkPlumber_SetupAndCleanup(t *testing.T) {
|
||||
mock := &MockNetworkPlumber{}
|
||||
|
||||
err := mock.SetupTapDevice("eni-abc123", "02:00:00:aa:bb:cc")
|
||||
if err != nil {
|
||||
t.Fatalf("SetupTapDevice: %v", err)
|
||||
}
|
||||
if len(mock.SetupCalls) != 1 {
|
||||
t.Fatalf("expected 1 setup call, got %d", len(mock.SetupCalls))
|
||||
}
|
||||
if mock.SetupCalls[0].ENIId != "eni-abc123" {
|
||||
t.Errorf("setup eniId = %q, want 'eni-abc123'", mock.SetupCalls[0].ENIId)
|
||||
}
|
||||
if mock.SetupCalls[0].MAC != "02:00:00:aa:bb:cc" {
|
||||
t.Errorf("setup mac = %q, want '02:00:00:aa:bb:cc'", mock.SetupCalls[0].MAC)
|
||||
}
|
||||
|
||||
err = mock.CleanupTapDevice("eni-abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupTapDevice: %v", err)
|
||||
}
|
||||
if len(mock.CleanupCalls) != 1 {
|
||||
t.Fatalf("expected 1 cleanup call, got %d", len(mock.CleanupCalls))
|
||||
}
|
||||
if mock.CleanupCalls[0] != "eni-abc123" {
|
||||
t.Errorf("cleanup eniId = %q, want 'eni-abc123'", mock.CleanupCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSudoCommand_NonRoot(t *testing.T) {
|
||||
// When not root, sudoCommand should prefix with sudo
|
||||
cmd := sudoCommand("ovs-vsctl", "br-exists", "br-int")
|
||||
args := cmd.Args
|
||||
|
||||
if os.Getuid() == 0 {
|
||||
// Running as root: should NOT use sudo
|
||||
if args[0] != "ovs-vsctl" {
|
||||
t.Errorf("as root, expected args[0]='ovs-vsctl', got %q", args[0])
|
||||
}
|
||||
} else {
|
||||
// Running as non-root: should use sudo
|
||||
if args[0] != "sudo" {
|
||||
t.Errorf("as non-root, expected args[0]='sudo', got %q", args[0])
|
||||
}
|
||||
if args[1] != "ovs-vsctl" {
|
||||
t.Errorf("as non-root, expected args[1]='ovs-vsctl', got %q", args[1])
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Errorf("expected 4 args [sudo ovs-vsctl br-exists br-int], got %d: %v", len(args), args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDevMAC(t *testing.T) {
|
||||
tests := []struct {
|
||||
instanceId string
|
||||
}{
|
||||
{"i-abc123"},
|
||||
{"i-def456"},
|
||||
{"i-ghi789"},
|
||||
}
|
||||
|
||||
// All MACs should be unique and have the 02:de:v0 prefix
|
||||
seen := make(map[string]bool)
|
||||
for _, tt := range tests {
|
||||
mac := generateDevMAC(tt.instanceId)
|
||||
if !strings.HasPrefix(mac, "02:de:00:") {
|
||||
t.Errorf("generateDevMAC(%q) = %q, want prefix '02:de:00:'", tt.instanceId, mac)
|
||||
}
|
||||
if seen[mac] {
|
||||
t.Errorf("generateDevMAC(%q) = %q, duplicate MAC", tt.instanceId, mac)
|
||||
}
|
||||
seen[mac] = true
|
||||
}
|
||||
|
||||
// Same input should produce same output (deterministic)
|
||||
mac1 := generateDevMAC("i-test123")
|
||||
mac2 := generateDevMAC("i-test123")
|
||||
if mac1 != mac2 {
|
||||
t.Errorf("generateDevMAC not deterministic: %q != %q", mac1, mac2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkPlumber_InterfaceCompliance(t *testing.T) {
|
||||
// Verify both types satisfy the interface
|
||||
var _ NetworkPlumber = &OVSNetworkPlumber{}
|
||||
var _ NetworkPlumber = &MockNetworkPlumber{}
|
||||
}
|
||||
|
||||
func TestOVNHealthStatus_Fields(t *testing.T) {
|
||||
// Verify OVNHealthStatus struct can be used for health reporting
|
||||
status := OVNHealthStatus{
|
||||
BrIntExists: true,
|
||||
OVNControllerUp: true,
|
||||
ChassisID: "chassis-node1",
|
||||
EncapIP: "10.0.0.1",
|
||||
OVNRemote: "tcp:10.0.0.1:6642",
|
||||
}
|
||||
|
||||
if !status.BrIntExists {
|
||||
t.Error("expected BrIntExists to be true")
|
||||
}
|
||||
if !status.OVNControllerUp {
|
||||
t.Error("expected OVNControllerUp to be true")
|
||||
}
|
||||
if status.ChassisID != "chassis-node1" {
|
||||
t.Errorf("ChassisID = %q, want 'chassis-node1'", status.ChassisID)
|
||||
}
|
||||
if status.EncapIP != "10.0.0.1" {
|
||||
t.Errorf("EncapIP = %q, want '10.0.0.1'", status.EncapIP)
|
||||
}
|
||||
if status.OVNRemote != "tcp:10.0.0.1:6642" {
|
||||
t.Errorf("OVNRemote = %q, want 'tcp:10.0.0.1:6642'", status.OVNRemote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOVNHealthStatus_Defaults(t *testing.T) {
|
||||
// Zero-value OVNHealthStatus should indicate nothing is ready
|
||||
var status OVNHealthStatus
|
||||
|
||||
if status.BrIntExists {
|
||||
t.Error("zero-value BrIntExists should be false")
|
||||
}
|
||||
if status.OVNControllerUp {
|
||||
t.Error("zero-value OVNControllerUp should be false")
|
||||
}
|
||||
if status.ChassisID != "" {
|
||||
t.Errorf("zero-value ChassisID should be empty, got %q", status.ChassisID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOVNHealth_ReturnsStatus(t *testing.T) {
|
||||
// CheckOVNHealth should return a status struct without panicking,
|
||||
// even when OVS/OVN tools are not installed (CI environment).
|
||||
// On a dev machine without OVN, all fields will be zero values.
|
||||
status := CheckOVNHealth()
|
||||
|
||||
// On CI without OVS, both should be false — just verify no panic
|
||||
_ = status.BrIntExists
|
||||
_ = status.OVNControllerUp
|
||||
_ = status.ChassisID
|
||||
_ = status.EncapIP
|
||||
_ = status.OVNRemote
|
||||
}
|
||||
|
||||
func TestFindInterfaceByIP_InvalidIP(t *testing.T) {
|
||||
_, err := findInterfaceByIP("not-an-ip")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid IP")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid IP address") {
|
||||
t.Errorf("expected 'invalid IP address' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindInterfaceByIP_Loopback(t *testing.T) {
|
||||
// 127.0.0.1 should always be on the loopback interface
|
||||
iface, err := findInterfaceByIP("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("findInterfaceByIP(127.0.0.1): %v", err)
|
||||
}
|
||||
if iface != "lo" {
|
||||
t.Errorf("findInterfaceByIP(127.0.0.1) = %q, want 'lo'", iface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindInterfaceByIP_NotFound(t *testing.T) {
|
||||
_, err := findInterfaceByIP("192.0.2.1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent IP")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no interface found") {
|
||||
t.Errorf("expected 'no interface found' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDataRoute_NoOVS(t *testing.T) {
|
||||
// EnsureDataRoute requires ip commands which may not work in CI.
|
||||
// On loopback, there's no kernel subnet route, so it should return an error.
|
||||
err := EnsureDataRoute("127.0.0.1")
|
||||
// We expect an error (no kernel route for lo), but no panic.
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestSetupComputeNode_ValidatesArgs(t *testing.T) {
|
||||
// SetupComputeNode requires ovs-vsctl which may not be available in CI.
|
||||
// This test verifies the function signature and that it returns an error
|
||||
// when OVS is not installed (expected on CI).
|
||||
err := SetupComputeNode("chassis-test", "tcp:127.0.0.1:6642", "10.0.0.1")
|
||||
|
||||
// We expect an error in CI (no OVS), but the function should not panic.
|
||||
// On a dev machine with OVS, it would succeed. Either result is acceptable.
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestMockNetworkPlumber_SetupError(t *testing.T) {
|
||||
mock := &MockNetworkPlumber{
|
||||
SetupErr: fmt.Errorf("simulated setup failure"),
|
||||
}
|
||||
err := mock.SetupTapDevice("eni-abc123", "02:00:00:aa:bb:cc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from SetupTapDevice")
|
||||
}
|
||||
if err.Error() != "simulated setup failure" {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// Call should still be recorded
|
||||
if len(mock.SetupCalls) != 1 {
|
||||
t.Fatalf("expected 1 setup call, got %d", len(mock.SetupCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockNetworkPlumber_CleanupError(t *testing.T) {
|
||||
mock := &MockNetworkPlumber{
|
||||
CleanupErr: fmt.Errorf("simulated cleanup failure"),
|
||||
}
|
||||
err := mock.CleanupTapDevice("eni-abc123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CleanupTapDevice")
|
||||
}
|
||||
if err.Error() != "simulated cleanup failure" {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if len(mock.CleanupCalls) != 1 {
|
||||
t.Fatalf("expected 1 cleanup call, got %d", len(mock.CleanupCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDevMAC_Format(t *testing.T) {
|
||||
tests := []string{
|
||||
"i-abc123",
|
||||
"i-def456",
|
||||
"i-00000000",
|
||||
"i-ffffffff",
|
||||
"i-a",
|
||||
"i-very-long-instance-id-with-many-characters",
|
||||
}
|
||||
for _, id := range tests {
|
||||
mac := generateDevMAC(id)
|
||||
// Must be 17 chars: xx:xx:xx:xx:xx:xx
|
||||
if len(mac) != 17 {
|
||||
t.Errorf("generateDevMAC(%q) = %q, expected 17 chars", id, mac)
|
||||
}
|
||||
// Must start with locally-administered unicast prefix 02:de:00
|
||||
if !strings.HasPrefix(mac, "02:de:00:") {
|
||||
t.Errorf("generateDevMAC(%q) = %q, expected prefix 02:de:00:", id, mac)
|
||||
}
|
||||
// All chars must be valid hex or colons
|
||||
for i, c := range mac {
|
||||
if i%3 == 2 {
|
||||
if c != ':' {
|
||||
t.Errorf("generateDevMAC(%q) = %q, expected ':' at pos %d", id, mac, i)
|
||||
}
|
||||
} else {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Errorf("generateDevMAC(%q) = %q, invalid hex char '%c' at pos %d", id, mac, c, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTapDeviceName_EmptyInput(t *testing.T) {
|
||||
// Even with empty string (no eni- prefix), should not panic
|
||||
name := TapDeviceName("")
|
||||
if name != "tap" {
|
||||
t.Errorf("TapDeviceName('') = %q, want 'tap'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOVSIfaceID_Format(t *testing.T) {
|
||||
tests := []struct {
|
||||
eniId string
|
||||
expected string
|
||||
}{
|
||||
{"eni-short", "port-eni-short"},
|
||||
{"eni-", "port-eni-"},
|
||||
{"", "port-"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := OVSIfaceID(tt.eniId)
|
||||
if got != tt.expected {
|
||||
t.Errorf("OVSIfaceID(%q) = %q, want %q", tt.eniId, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// checkInstanceOwnership verifies the caller owns the instance.
|
||||
// Returns true if access is allowed, false if denied (error already sent).
|
||||
// Pre-Phase4 instances (empty AccountID) are only visible to root (GlobalAccountID).
|
||||
func checkInstanceOwnership(msg *nats.Msg, instanceID, ownerAccountID string) bool {
|
||||
callerAccountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
// Pre-Phase4 instance: only root can access
|
||||
if ownerAccountID == "" {
|
||||
if callerAccountID != utils.GlobalAccountID {
|
||||
slog.Warn("Pre-Phase4 instance access denied (not root)",
|
||||
"instanceId", instanceID, "callerAccount", callerAccountID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Normal ownership check
|
||||
if callerAccountID != ownerAccountID {
|
||||
slog.Warn("Account does not own instance",
|
||||
"instanceId", instanceID, "callerAccount", callerAccountID, "ownerAccount", ownerAccountID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isInstanceVisible checks if the caller can see this instance (for Describe operations).
|
||||
// Pre-Phase4 instances (empty AccountID) are only visible to root (GlobalAccountID).
|
||||
func isInstanceVisible(callerAccountID, ownerAccountID string) bool {
|
||||
if ownerAccountID == "" {
|
||||
return callerAccountID == utils.GlobalAccountID
|
||||
}
|
||||
return callerAccountID == ownerAccountID
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/types"
|
||||
)
|
||||
|
||||
// errInsufficientCapacity is returned by allocateForLaunch when MinCount
|
||||
// cannot be satisfied.
|
||||
var errInsufficientCapacity = errors.New("insufficient capacity to satisfy MinCount")
|
||||
|
||||
// canAllocateCount returns how many instances of the given type can fit
|
||||
// in the remaining capacity, capped at maxCount. Pure function — no locks
|
||||
// or side effects.
|
||||
func canAllocateCount(availVCPU, allocVCPU int, availMem, allocMem float64,
|
||||
vCPUs int64, memMiB int64, maxCount int) int {
|
||||
|
||||
remainingVCPU := availVCPU - allocVCPU
|
||||
remainingMem := availMem - allocMem
|
||||
memoryGB := float64(memMiB) / 1024.0
|
||||
|
||||
countByCPU := maxCount
|
||||
if vCPUs > 0 {
|
||||
countByCPU = remainingVCPU / int(vCPUs)
|
||||
}
|
||||
|
||||
countByMem := maxCount
|
||||
if memoryGB > 0 {
|
||||
countByMem = int(remainingMem / memoryGB)
|
||||
}
|
||||
|
||||
result := min(countByMem, countByCPU)
|
||||
result = min(result, maxCount)
|
||||
return max(result, 0)
|
||||
}
|
||||
|
||||
// resourceStatsForType computes the InstanceTypeCap for a single instance type
|
||||
// given the remaining host resources. Pure function — no locks or side effects.
|
||||
func resourceStatsForType(remainVCPU int, remainMem float64, it *ec2.InstanceTypeInfo) types.InstanceTypeCap {
|
||||
vCPUs := instanceTypeVCPUs(it)
|
||||
memGB := float64(instanceTypeMemoryMiB(it)) / 1024.0
|
||||
|
||||
count := 0
|
||||
if vCPUs > 0 && memGB > 0 {
|
||||
countVCPU := remainVCPU / int(vCPUs)
|
||||
countMem := int(remainMem / memGB)
|
||||
count = max(min(countMem, countVCPU), 0)
|
||||
}
|
||||
|
||||
name := ""
|
||||
if it.InstanceType != nil {
|
||||
name = *it.InstanceType
|
||||
}
|
||||
|
||||
return types.InstanceTypeCap{
|
||||
Name: name,
|
||||
VCPU: int(vCPUs),
|
||||
MemoryGB: memGB,
|
||||
Available: count,
|
||||
}
|
||||
}
|
||||
|
||||
// allocateForLaunch determines the number of instances to launch given
|
||||
// available capacity and the MinCount/MaxCount constraints from a
|
||||
// RunInstances request. Returns the launch count or an error if the
|
||||
// minimum cannot be satisfied.
|
||||
func allocateForLaunch(canAlloc, minCount, maxCount int) (int, error) {
|
||||
if canAlloc < minCount {
|
||||
return 0, errInsufficientCapacity
|
||||
}
|
||||
return min(canAlloc, maxCount), nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCanAllocateCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
availVCPU int
|
||||
allocVCPU int
|
||||
availMem float64
|
||||
allocMem float64
|
||||
vCPUs int64
|
||||
memMiB int64
|
||||
maxCount int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "exact fit single instance",
|
||||
availVCPU: 4, allocVCPU: 2,
|
||||
availMem: 8.0, allocMem: 4.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 10,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple instances fit",
|
||||
availVCPU: 16, allocVCPU: 0,
|
||||
availMem: 32.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 10,
|
||||
want: 8, // limited by CPU: 16/2 = 8, mem: 32/4 = 8
|
||||
},
|
||||
{
|
||||
name: "CPU limited",
|
||||
availVCPU: 4, allocVCPU: 0,
|
||||
availMem: 64.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 10,
|
||||
want: 2, // CPU: 4/2=2, mem: 64/4=16 → min=2
|
||||
},
|
||||
{
|
||||
name: "memory limited",
|
||||
availVCPU: 64, allocVCPU: 0,
|
||||
availMem: 8.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 10,
|
||||
want: 2, // CPU: 64/2=32, mem: 8/4=2 → min=2
|
||||
},
|
||||
{
|
||||
name: "capped by maxCount",
|
||||
availVCPU: 64, allocVCPU: 0,
|
||||
availMem: 128.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 3,
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "zero remaining resources",
|
||||
availVCPU: 4, allocVCPU: 4,
|
||||
availMem: 8.0, allocMem: 8.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 5,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "negative remaining (overallocated)",
|
||||
availVCPU: 4, allocVCPU: 6,
|
||||
availMem: 8.0, allocMem: 10.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 5,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "zero vCPUs bypasses CPU check",
|
||||
availVCPU: 4, allocVCPU: 0,
|
||||
availMem: 16.0, allocMem: 0.0,
|
||||
vCPUs: 0, memMiB: 4096,
|
||||
maxCount: 5,
|
||||
want: 4, // CPU check skipped (maxCount=5), mem: 16/4=4 → min=4
|
||||
},
|
||||
{
|
||||
name: "zero memory bypasses mem check",
|
||||
availVCPU: 8, allocVCPU: 0,
|
||||
availMem: 16.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 0,
|
||||
maxCount: 5,
|
||||
want: 4, // CPU: 8/2=4, mem check skipped (maxCount=5) → min=4
|
||||
},
|
||||
{
|
||||
name: "maxCount zero",
|
||||
availVCPU: 16, allocVCPU: 0,
|
||||
availMem: 32.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 0,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "off by one CPU",
|
||||
availVCPU: 5, allocVCPU: 0,
|
||||
availMem: 64.0, allocMem: 0.0,
|
||||
vCPUs: 2, memMiB: 4096,
|
||||
maxCount: 10,
|
||||
want: 2, // 5/2 = 2 (integer division)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := canAllocateCount(tc.availVCPU, tc.allocVCPU, tc.availMem, tc.allocMem, tc.vCPUs, tc.memMiB, tc.maxCount)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceStatsForType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remainVCPU int
|
||||
remainMem float64
|
||||
it *ec2.InstanceTypeInfo
|
||||
wantName string
|
||||
wantVCPU int
|
||||
wantMemGB float64
|
||||
wantAvail int
|
||||
}{
|
||||
{
|
||||
name: "standard instance type",
|
||||
remainVCPU: 8,
|
||||
remainMem: 16.0,
|
||||
it: &ec2.InstanceTypeInfo{
|
||||
InstanceType: aws.String("t3.medium"),
|
||||
VCpuInfo: &ec2.VCpuInfo{DefaultVCpus: aws.Int64(2)},
|
||||
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(4096)},
|
||||
},
|
||||
wantName: "t3.medium",
|
||||
wantVCPU: 2,
|
||||
wantMemGB: 4.0,
|
||||
wantAvail: 4, // min(8/2=4, 16/4=4)
|
||||
},
|
||||
{
|
||||
name: "CPU limited",
|
||||
remainVCPU: 2,
|
||||
remainMem: 64.0,
|
||||
it: &ec2.InstanceTypeInfo{
|
||||
InstanceType: aws.String("c5.xlarge"),
|
||||
VCpuInfo: &ec2.VCpuInfo{DefaultVCpus: aws.Int64(4)},
|
||||
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(8192)},
|
||||
},
|
||||
wantName: "c5.xlarge",
|
||||
wantVCPU: 4,
|
||||
wantMemGB: 8.0,
|
||||
wantAvail: 0, // CPU: 2/4=0
|
||||
},
|
||||
{
|
||||
name: "nil instance type name",
|
||||
remainVCPU: 16,
|
||||
remainMem: 32.0,
|
||||
it: &ec2.InstanceTypeInfo{
|
||||
VCpuInfo: &ec2.VCpuInfo{DefaultVCpus: aws.Int64(2)},
|
||||
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(2048)},
|
||||
},
|
||||
wantName: "",
|
||||
wantVCPU: 2,
|
||||
wantMemGB: 2.0,
|
||||
wantAvail: 8, // min(16/2=8, 32/2=16)
|
||||
},
|
||||
{
|
||||
name: "zero vCPU gives zero available",
|
||||
remainVCPU: 16,
|
||||
remainMem: 32.0,
|
||||
it: &ec2.InstanceTypeInfo{
|
||||
InstanceType: aws.String("broken"),
|
||||
VCpuInfo: &ec2.VCpuInfo{DefaultVCpus: aws.Int64(0)},
|
||||
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(4096)},
|
||||
},
|
||||
wantName: "broken",
|
||||
wantVCPU: 0,
|
||||
wantMemGB: 4.0,
|
||||
wantAvail: 0,
|
||||
},
|
||||
{
|
||||
name: "nil VCpuInfo",
|
||||
remainVCPU: 16,
|
||||
remainMem: 32.0,
|
||||
it: &ec2.InstanceTypeInfo{
|
||||
InstanceType: aws.String("broken2"),
|
||||
MemoryInfo: &ec2.MemoryInfo{SizeInMiB: aws.Int64(4096)},
|
||||
},
|
||||
wantName: "broken2",
|
||||
wantVCPU: 0,
|
||||
wantMemGB: 4.0,
|
||||
wantAvail: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cap := resourceStatsForType(tc.remainVCPU, tc.remainMem, tc.it)
|
||||
assert.Equal(t, tc.wantName, cap.Name)
|
||||
assert.Equal(t, tc.wantVCPU, cap.VCPU)
|
||||
assert.InDelta(t, tc.wantMemGB, cap.MemoryGB, 0.001)
|
||||
assert.Equal(t, tc.wantAvail, cap.Available)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateForLaunch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
canAlloc int
|
||||
minCount int
|
||||
maxCount int
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "exact min equals max",
|
||||
canAlloc: 5, minCount: 5, maxCount: 5,
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
name: "capacity exceeds max",
|
||||
canAlloc: 10, minCount: 1, maxCount: 5,
|
||||
want: 5,
|
||||
},
|
||||
{
|
||||
name: "capacity between min and max",
|
||||
canAlloc: 3, minCount: 1, maxCount: 5,
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "capacity below min",
|
||||
canAlloc: 2, minCount: 3, maxCount: 5,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero capacity",
|
||||
canAlloc: 0, minCount: 1, maxCount: 5,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero min count always succeeds",
|
||||
canAlloc: 0, minCount: 0, maxCount: 5,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := allocateForLaunch(tc.canAlloc, tc.minCount, tc.maxCount)
|
||||
if tc.wantErr {
|
||||
assert.ErrorIs(t, err, errInsufficientCapacity)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+36
-8
@@ -1,16 +1,29 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// pidDir returns the directory containing shell-level PID files for this node.
|
||||
// start-dev.sh writes PID files to $DATA_DIR/logs/ and the daemon's BaseDir is
|
||||
// always $DATA_DIR/hive/, so we derive the logs directory from that. Each node
|
||||
// has its own DATA_DIR, so this is safe for simulated multi-node on a single host.
|
||||
func (d *Daemon) pidDir() string {
|
||||
if d.config.BaseDir != "" {
|
||||
return filepath.Join(filepath.Dir(filepath.Clean(d.config.BaseDir)), "logs")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShutdownRequest is sent by the coordinator to each daemon per phase.
|
||||
type ShutdownRequest struct {
|
||||
Phase string `json:"phase"`
|
||||
@@ -40,6 +53,7 @@ func (d *Daemon) handleShutdownGate(msg *nats.Msg) {
|
||||
var req ShutdownRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
slog.Error("handleShutdownGate: failed to unmarshal request", "error", err)
|
||||
d.respondShutdownACK(msg, ShutdownACK{Node: d.node, Phase: "gate", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,7 +63,7 @@ func (d *Daemon) handleShutdownGate(msg *nats.Msg) {
|
||||
|
||||
// Stop AWSGW
|
||||
if d.config.HasService("awsgw") {
|
||||
if err := utils.StopProcess("awsgw"); err != nil {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "awsgw"); err != nil {
|
||||
slog.Warn("Failed to stop awsgw", "error", err)
|
||||
} else {
|
||||
stopped = append(stopped, "awsgw")
|
||||
@@ -58,13 +72,22 @@ func (d *Daemon) handleShutdownGate(msg *nats.Msg) {
|
||||
|
||||
// Stop UI
|
||||
if d.config.HasService("ui") {
|
||||
if err := utils.StopProcess("hive-ui"); err != nil {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "hive-ui"); err != nil {
|
||||
slog.Warn("Failed to stop hive-ui", "error", err)
|
||||
} else {
|
||||
stopped = append(stopped, "hive-ui")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop vpcd (VPC daemon)
|
||||
if d.config.HasService("vpcd") {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "vpcd"); err != nil {
|
||||
slog.Warn("Failed to stop vpcd", "error", err)
|
||||
} else {
|
||||
stopped = append(stopped, "vpcd")
|
||||
}
|
||||
}
|
||||
|
||||
// Set shuttingDown flag so daemon rejects new work
|
||||
d.shuttingDown.Store(true)
|
||||
|
||||
@@ -83,6 +106,7 @@ func (d *Daemon) handleShutdownDrain(msg *nats.Msg) {
|
||||
var req ShutdownRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
slog.Error("handleShutdownDrain: failed to unmarshal request", "error", err)
|
||||
d.respondShutdownACK(msg, ShutdownACK{Node: d.node, Phase: "drain", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,6 +164,7 @@ func (d *Daemon) handleShutdownStorage(msg *nats.Msg) {
|
||||
var req ShutdownRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
slog.Error("handleShutdownStorage: failed to unmarshal request", "error", err)
|
||||
d.respondShutdownACK(msg, ShutdownACK{Node: d.node, Phase: "storage", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,7 +173,7 @@ func (d *Daemon) handleShutdownStorage(msg *nats.Msg) {
|
||||
var stopped []string
|
||||
|
||||
if d.config.HasService("viperblock") {
|
||||
if err := utils.StopProcess("viperblock"); err != nil {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "viperblock"); err != nil {
|
||||
slog.Warn("Failed to stop viperblock", "error", err)
|
||||
} else {
|
||||
stopped = append(stopped, "viperblock")
|
||||
@@ -174,6 +199,7 @@ func (d *Daemon) handleShutdownPersist(msg *nats.Msg) {
|
||||
var req ShutdownRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
slog.Error("handleShutdownPersist: failed to unmarshal request", "error", err)
|
||||
d.respondShutdownACK(msg, ShutdownACK{Node: d.node, Phase: "persist", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,7 +208,7 @@ func (d *Daemon) handleShutdownPersist(msg *nats.Msg) {
|
||||
var stopped []string
|
||||
|
||||
if d.config.HasService("predastore") {
|
||||
if err := utils.StopProcess("predastore"); err != nil {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "predastore"); err != nil {
|
||||
slog.Warn("Failed to stop predastore", "error", err)
|
||||
} else {
|
||||
stopped = append(stopped, "predastore")
|
||||
@@ -204,6 +230,7 @@ func (d *Daemon) handleShutdownInfra(msg *nats.Msg) {
|
||||
var req ShutdownRequest
|
||||
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||
slog.Error("handleShutdownInfra: failed to unmarshal request", "error", err)
|
||||
d.respondShutdownACK(msg, ShutdownACK{Node: d.node, Phase: "infra", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,8 +248,8 @@ func (d *Daemon) handleShutdownInfra(msg *nats.Msg) {
|
||||
}
|
||||
|
||||
// Shutdown cluster manager
|
||||
if d.clusterApp != nil {
|
||||
if err := d.clusterApp.Shutdown(); err != nil {
|
||||
if d.clusterServer != nil {
|
||||
if err := d.clusterServer.Shutdown(context.Background()); err != nil {
|
||||
slog.Warn("Failed to shutdown cluster manager", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -232,7 +259,7 @@ func (d *Daemon) handleShutdownInfra(msg *nats.Msg) {
|
||||
|
||||
// Stop NATS server if this node runs it
|
||||
if d.config.HasService("nats") {
|
||||
if err := utils.StopProcess("nats"); err != nil {
|
||||
if err := utils.StopProcessAt(d.pidDir(), "nats"); err != nil {
|
||||
slog.Warn("Failed to stop nats", "error", err)
|
||||
} else {
|
||||
slog.Info("NATS server stopped", "node", d.node)
|
||||
@@ -268,9 +295,10 @@ func (d *Daemon) publishShutdownProgress(phase string, total, remaining int) {
|
||||
}
|
||||
data, err := json.Marshal(progress)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal shutdown progress", "error", err)
|
||||
return
|
||||
}
|
||||
if err := d.natsConn.Publish("hive.cluster.shutdown.progress", data); err != nil {
|
||||
slog.Debug("Failed to publish shutdown progress", "error", err)
|
||||
slog.Warn("Failed to publish shutdown progress", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -145,3 +147,127 @@ func TestShuttingDownFlagSkipsVMStop(t *testing.T) {
|
||||
d.shuttingDown.Store(true)
|
||||
assert.True(t, d.shuttingDown.Load())
|
||||
}
|
||||
|
||||
// TestRespondShutdownACK verifies respondShutdownACK marshals and sends the ACK via NATS request/reply.
|
||||
func TestRespondShutdownACK(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
d := &Daemon{node: "test-node", natsConn: nc}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ack ShutdownACK
|
||||
}{
|
||||
{
|
||||
name: "gate phase with stopped services",
|
||||
ack: ShutdownACK{
|
||||
Node: "test-node",
|
||||
Phase: "gate",
|
||||
Stopped: []string{"awsgw", "hive-ui"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drain phase with error",
|
||||
ack: ShutdownACK{
|
||||
Node: "test-node",
|
||||
Phase: "drain",
|
||||
Error: "failed to stop VMs",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storage phase empty stopped list",
|
||||
ack: ShutdownACK{
|
||||
Node: "test-node",
|
||||
Phase: "storage",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
subject := fmt.Sprintf("test.shutdown.ack.%d", i)
|
||||
|
||||
// Subscribe and set up a handler that receives requests
|
||||
sub, err := nc.SubscribeSync(subject)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
// Send a NATS request — the handler will call msg.Respond()
|
||||
inbox := nc.NewRespInbox()
|
||||
replySub, err := nc.SubscribeSync(inbox)
|
||||
require.NoError(t, err)
|
||||
defer replySub.Unsubscribe()
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
err = nc.PublishRequest(subject, inbox, []byte("{}"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Receive the request message and pass it to respondShutdownACK
|
||||
msg, err := sub.NextMsg(2 * time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
d.respondShutdownACK(msg, tt.ack)
|
||||
|
||||
// Read the reply
|
||||
reply, err := replySub.NextMsg(2 * time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded ShutdownACK
|
||||
err = json.Unmarshal(reply.Data, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.ack.Node, decoded.Node)
|
||||
assert.Equal(t, tt.ack.Phase, decoded.Phase)
|
||||
assert.Equal(t, tt.ack.Stopped, decoded.Stopped)
|
||||
assert.Equal(t, tt.ack.Error, decoded.Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishShutdownProgress verifies publishShutdownProgress publishes correct progress to the NATS topic.
|
||||
func TestPublishShutdownProgress(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
d := &Daemon{node: "progress-node", natsConn: nc}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
phase string
|
||||
total int
|
||||
remaining int
|
||||
}{
|
||||
{"initial drain progress", "drain", 5, 5},
|
||||
{"partial drain progress", "drain", 5, 2},
|
||||
{"final drain progress", "drain", 5, 0},
|
||||
{"zero VMs", "drain", 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := nc.SubscribeSync("hive.cluster.shutdown.progress")
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
d.publishShutdownProgress(tt.phase, tt.total, tt.remaining)
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
msg, err := sub.NextMsg(2 * time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var progress ShutdownProgress
|
||||
err = json.Unmarshal(msg.Data, &progress)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "progress-node", progress.Node)
|
||||
assert.Equal(t, tt.phase, progress.Phase)
|
||||
assert.Equal(t, tt.total, progress.Total)
|
||||
assert.Equal(t, tt.remaining, progress.Remaining)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+218
-32
@@ -7,8 +7,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/qmp"
|
||||
"github.com/mulgadc/hive/hive/types"
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -60,6 +61,8 @@ func createDaemonWithJetStream(t *testing.T) *Daemon {
|
||||
daemon.jsManager, err = NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, daemon.jsManager.InitKVBucket())
|
||||
require.NoError(t, daemon.jsManager.InitClusterStateBucket())
|
||||
require.NoError(t, daemon.jsManager.InitTerminatedInstanceBucket())
|
||||
|
||||
return daemon
|
||||
}
|
||||
@@ -587,6 +590,15 @@ func TestTransitionState_MultipleInstancesIndependent(t *testing.T) {
|
||||
// running, isInstanceProcessRunning returns false for all instances, which
|
||||
// exercises the recovery state resolution logic.
|
||||
|
||||
// simulateCleanRestore writes a clean shutdown marker then calls restoreInstances.
|
||||
// Without the marker, restoreInstances sleeps 3s waiting for stale QEMU PIDs —
|
||||
// unnecessary in tests since no QEMU process ever runs.
|
||||
func simulateCleanRestore(t *testing.T, daemon *Daemon) {
|
||||
t.Helper()
|
||||
require.NoError(t, daemon.jsManager.WriteShutdownMarker(daemon.node))
|
||||
daemon.restoreInstances()
|
||||
}
|
||||
|
||||
// TestRestoreInstances_StoppingFinalizedToStopped verifies that an instance
|
||||
// stuck in StateStopping when the daemon died gets finalized to StateStopped
|
||||
// and migrated to shared KV.
|
||||
@@ -604,7 +616,7 @@ func TestRestoreInstances_StoppingFinalizedToStopped(t *testing.T) {
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
|
||||
// restoreInstances loads from JetStream and resolves transitional states.
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Stopped instances are migrated to shared KV and removed from local map
|
||||
_, ok := daemon.Instances.VMS["i-restore-stop"]
|
||||
@@ -629,17 +641,22 @@ func TestRestoreInstances_ShuttingDownFinalizedToTerminated(t *testing.T) {
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
instance, ok := daemon.Instances.VMS["i-restore-shut"]
|
||||
require.True(t, ok, "instance should be loaded from state")
|
||||
assert.Equal(t, vm.StateTerminated, instance.Status,
|
||||
// shutting-down→terminated should be migrated to terminated KV
|
||||
_, ok := daemon.Instances.VMS["i-restore-shut"]
|
||||
assert.False(t, ok, "terminated instance should be removed from local map")
|
||||
|
||||
terminatedInst, err := daemon.jsManager.LoadTerminatedInstance("i-restore-shut")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, terminatedInst, "terminated instance should exist in terminated KV")
|
||||
assert.Equal(t, vm.StateTerminated, terminatedInst.Status,
|
||||
"shutting-down instance should be finalized to terminated on recovery")
|
||||
}
|
||||
|
||||
// TestRestoreInstances_TerminatedSkipped verifies that terminated instances
|
||||
// are loaded but not relaunched or modified.
|
||||
func TestRestoreInstances_TerminatedSkipped(t *testing.T) {
|
||||
// TestRestoreInstances_TerminatedRemoved verifies that terminated instances
|
||||
// are removed from per-node state on restore (no migration, just cleanup).
|
||||
func TestRestoreInstances_TerminatedRemoved(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
daemon.Instances.VMS["i-restore-term"] = &vm.VM{
|
||||
@@ -649,12 +666,10 @@ func TestRestoreInstances_TerminatedSkipped(t *testing.T) {
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
instance, ok := daemon.Instances.VMS["i-restore-term"]
|
||||
require.True(t, ok, "terminated instance should still be loaded")
|
||||
assert.Equal(t, vm.StateTerminated, instance.Status,
|
||||
"terminated instance should remain terminated")
|
||||
_, ok := daemon.Instances.VMS["i-restore-term"]
|
||||
assert.False(t, ok, "terminated instance should be removed from local map")
|
||||
}
|
||||
|
||||
// TestRestoreInstances_UserStoppedMigratedToSharedKV verifies that instances
|
||||
@@ -665,14 +680,14 @@ func TestRestoreInstances_UserStoppedMigratedToSharedKV(t *testing.T) {
|
||||
daemon.Instances.VMS["i-restore-userstop"] = &vm.VM{
|
||||
ID: "i-restore-userstop",
|
||||
Status: vm.StateStopped,
|
||||
Attributes: qmp.Attributes{
|
||||
Attributes: types.EC2CommandAttributes{
|
||||
StopInstance: true,
|
||||
},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Stopped instances should be migrated to shared KV
|
||||
_, ok := daemon.Instances.VMS["i-restore-userstop"]
|
||||
@@ -699,7 +714,7 @@ func TestRestoreInstances_RunningResetToPending(t *testing.T) {
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
instance, ok := daemon.Instances.VMS["i-restore-run"]
|
||||
require.True(t, ok, "running instance should still be loaded")
|
||||
@@ -709,6 +724,39 @@ func TestRestoreInstances_RunningResetToPending(t *testing.T) {
|
||||
"instance should not remain running when QEMU process is dead")
|
||||
}
|
||||
|
||||
// TestRestoreInstances_RunningResetsLaunchTime verifies that when a running
|
||||
// instance is reset to pending for relaunch, its LaunchTime is updated to now.
|
||||
// Without this, the pending watchdog sees the stale LaunchTime and immediately
|
||||
// marks the instance as failed.
|
||||
func TestRestoreInstances_RunningResetsLaunchTime(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
staleTime := time.Now().Add(-30 * time.Minute)
|
||||
daemon.Instances.VMS["i-stale-launch"] = &vm.VM{
|
||||
ID: "i-stale-launch",
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{
|
||||
LaunchTime: &staleTime,
|
||||
},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
before := time.Now()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
instance, ok := daemon.Instances.VMS["i-stale-launch"]
|
||||
require.True(t, ok, "instance should be loaded from state")
|
||||
require.NotNil(t, instance.Instance)
|
||||
require.NotNil(t, instance.Instance.LaunchTime)
|
||||
|
||||
// LaunchTime should be reset to approximately now, not the stale 30min-ago time
|
||||
assert.True(t, instance.Instance.LaunchTime.After(before) || instance.Instance.LaunchTime.Equal(before),
|
||||
"LaunchTime should be reset to now, got %v (stale was %v)", *instance.Instance.LaunchTime, staleTime)
|
||||
assert.True(t, time.Since(*instance.Instance.LaunchTime) < pendingWatchdogTimeout,
|
||||
"LaunchTime should be within watchdog timeout window")
|
||||
}
|
||||
|
||||
// TestRestoreInstances_MixedStates verifies recovery with multiple instances
|
||||
// in different states, ensuring each is handled correctly and independently.
|
||||
// Stopped instances (including stopping→stopped transitions) are migrated to shared KV.
|
||||
@@ -726,12 +774,12 @@ func TestRestoreInstances_MixedStates(t *testing.T) {
|
||||
}
|
||||
daemon.Instances.VMS["i-mix-stopped"] = &vm.VM{
|
||||
ID: "i-mix-stopped", Status: vm.StateStopped,
|
||||
Attributes: qmp.Attributes{StopInstance: true},
|
||||
Attributes: types.EC2CommandAttributes{StopInstance: true},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Stopped instances are migrated to shared KV and removed from local map
|
||||
assert.Nil(t, daemon.Instances.VMS["i-mix-stopping"],
|
||||
@@ -741,10 +789,17 @@ func TestRestoreInstances_MixedStates(t *testing.T) {
|
||||
require.NotNil(t, stoppedFromStopping, "stopping→stopped should exist in shared KV")
|
||||
assert.Equal(t, vm.StateStopped, stoppedFromStopping.Status)
|
||||
|
||||
assert.Equal(t, vm.StateTerminated, daemon.Instances.VMS["i-mix-shutting"].Status,
|
||||
"shutting-down should finalize to terminated")
|
||||
assert.Equal(t, vm.StateTerminated, daemon.Instances.VMS["i-mix-terminated"].Status,
|
||||
"terminated should remain terminated")
|
||||
// shutting-down should finalize to terminated and migrate to terminated KV
|
||||
assert.Nil(t, daemon.Instances.VMS["i-mix-shutting"],
|
||||
"shutting-down→terminated should be migrated to terminated KV")
|
||||
terminatedFromShutting, err2 := daemon.jsManager.LoadTerminatedInstance("i-mix-shutting")
|
||||
require.NoError(t, err2)
|
||||
require.NotNil(t, terminatedFromShutting, "shutting-down→terminated should exist in terminated KV")
|
||||
assert.Equal(t, vm.StateTerminated, terminatedFromShutting.Status)
|
||||
|
||||
// already-terminated should be removed from local map (no migration, just cleanup)
|
||||
assert.Nil(t, daemon.Instances.VMS["i-mix-terminated"],
|
||||
"terminated should be removed from local map")
|
||||
|
||||
assert.Nil(t, daemon.Instances.VMS["i-mix-stopped"],
|
||||
"user-stopped should be migrated to shared KV")
|
||||
@@ -770,7 +825,7 @@ func TestRestoreInstances_StatePersistsAfterRecovery(t *testing.T) {
|
||||
|
||||
// First restart: finalize transitional states.
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// stopping→stopped migrates to shared KV
|
||||
assert.Nil(t, daemon.Instances.VMS["i-persist-stop"],
|
||||
@@ -780,12 +835,18 @@ func TestRestoreInstances_StatePersistsAfterRecovery(t *testing.T) {
|
||||
require.NotNil(t, stoppedInst)
|
||||
assert.Equal(t, vm.StateStopped, stoppedInst.Status)
|
||||
|
||||
assert.Equal(t, vm.StateTerminated, daemon.Instances.VMS["i-persist-shut"].Status)
|
||||
// shutting-down→terminated should be migrated to terminated KV
|
||||
assert.Nil(t, daemon.Instances.VMS["i-persist-shut"],
|
||||
"terminated instance should be removed from local map")
|
||||
terminatedInst, err := daemon.jsManager.LoadTerminatedInstance("i-persist-shut")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, terminatedInst)
|
||||
assert.Equal(t, vm.StateTerminated, terminatedInst.Status)
|
||||
|
||||
// Second restart: stopped instance is in shared KV (not per-node state),
|
||||
// so local map won't have it. Terminated instance persists in per-node state.
|
||||
// terminated instance is in terminated KV (not per-node state).
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Stopped instance should still be in shared KV
|
||||
stoppedInst2, err := daemon.jsManager.LoadStoppedInstance("i-persist-stop")
|
||||
@@ -793,8 +854,49 @@ func TestRestoreInstances_StatePersistsAfterRecovery(t *testing.T) {
|
||||
require.NotNil(t, stoppedInst2, "stopped instance should persist in shared KV through second restart")
|
||||
assert.Equal(t, vm.StateStopped, stoppedInst2.Status)
|
||||
|
||||
assert.Equal(t, vm.StateTerminated, daemon.Instances.VMS["i-persist-shut"].Status,
|
||||
"terminated state should persist through second restart")
|
||||
// Terminated instance should still be in terminated KV (within TTL window)
|
||||
terminatedInst2, err := daemon.jsManager.LoadTerminatedInstance("i-persist-shut")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, terminatedInst2, "terminated instance should persist in terminated KV through second restart")
|
||||
assert.Equal(t, vm.StateTerminated, terminatedInst2.Status)
|
||||
}
|
||||
|
||||
// TestMigrateInstanceToKV verifies the unified migration helper works for both stopped and terminated paths.
|
||||
func TestMigrateInstanceToKV(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
// Test stopped migration
|
||||
stoppedVM := &vm.VM{ID: "i-migrate-stop", Status: vm.StateStopped}
|
||||
daemon.Instances.VMS[stoppedVM.ID] = stoppedVM
|
||||
assert.True(t, daemon.migrateStoppedToSharedKV(stoppedVM))
|
||||
assert.Nil(t, daemon.Instances.VMS[stoppedVM.ID], "should be removed from local map")
|
||||
loaded, err := daemon.jsManager.LoadStoppedInstance(stoppedVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
assert.Equal(t, daemon.node, loaded.LastNode)
|
||||
|
||||
// Test terminated migration
|
||||
terminatedVM := &vm.VM{ID: "i-migrate-term", Status: vm.StateTerminated}
|
||||
daemon.Instances.VMS[terminatedVM.ID] = terminatedVM
|
||||
assert.True(t, daemon.migrateTerminatedToKV(terminatedVM))
|
||||
assert.Nil(t, daemon.Instances.VMS[terminatedVM.ID], "should be removed from local map")
|
||||
loadedTerm, err := daemon.jsManager.LoadTerminatedInstance(terminatedVM.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loadedTerm)
|
||||
assert.Equal(t, daemon.node, loadedTerm.LastNode)
|
||||
}
|
||||
|
||||
// TestMigrateInstanceToKV_NoJetStream verifies graceful failure when JetStream is nil.
|
||||
func TestMigrateInstanceToKV_NoJetStream(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
daemon.jsManager = nil
|
||||
|
||||
vm1 := &vm.VM{ID: "i-no-js", Status: vm.StateStopped}
|
||||
daemon.Instances.VMS[vm1.ID] = vm1
|
||||
assert.False(t, daemon.migrateStoppedToSharedKV(vm1))
|
||||
assert.False(t, daemon.migrateTerminatedToKV(vm1))
|
||||
// Instance should still be in local map since migration failed
|
||||
assert.NotNil(t, daemon.Instances.VMS[vm1.ID])
|
||||
}
|
||||
|
||||
// TestStatePersistence_RoundTrip verifies that all instance fields survive
|
||||
@@ -806,7 +908,7 @@ func TestStatePersistence_RoundTrip(t *testing.T) {
|
||||
ID: "i-roundtrip",
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: "t3.micro",
|
||||
Attributes: qmp.Attributes{
|
||||
Attributes: types.EC2CommandAttributes{
|
||||
StopInstance: false,
|
||||
TerminateInstance: false,
|
||||
},
|
||||
@@ -837,13 +939,13 @@ func TestRestoreInstances_StoppedInstanceMigratedToSharedKV(t *testing.T) {
|
||||
ID: instanceID,
|
||||
InstanceType: "t3.micro",
|
||||
Status: vm.StateStopped,
|
||||
Attributes: qmp.Attributes{StopInstance: true},
|
||||
Attributes: types.EC2CommandAttributes{StopInstance: true},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
// Simulate daemon restart: clear in-memory state and restore from JetStream.
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
daemon.restoreInstances()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Instance should not be in local map (migrated to shared KV).
|
||||
_, ok := daemon.Instances.VMS[instanceID]
|
||||
@@ -857,3 +959,87 @@ func TestRestoreInstances_StoppedInstanceMigratedToSharedKV(t *testing.T) {
|
||||
assert.Equal(t, "node-1", stoppedInst.LastNode,
|
||||
"stopped instance should record last node")
|
||||
}
|
||||
|
||||
// TestRestoreInstances_UnknownTypeMigratedToStoppedWithReason verifies that an
|
||||
// instance whose type is not in the local type map gets moved to stopped with
|
||||
// an InsufficientInstanceCapacity state reason and migrated to shared KV.
|
||||
func TestRestoreInstances_UnknownTypeMigratedToStoppedWithReason(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instanceID := "i-restore-notype"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: "m7i.small", // type that won't exist on this host
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
// Simulate daemon restart
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// Instance should not be in local map (migrated to shared KV)
|
||||
_, ok := daemon.Instances.VMS[instanceID]
|
||||
assert.False(t, ok, "instance with unknown type should be migrated to shared KV")
|
||||
|
||||
// Instance should be stopped in shared KV with capacity reason
|
||||
stoppedInst, err := daemon.jsManager.LoadStoppedInstance(instanceID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stoppedInst, "instance should exist in shared KV")
|
||||
assert.Equal(t, vm.StateStopped, stoppedInst.Status)
|
||||
require.NotNil(t, stoppedInst.Instance.StateReason)
|
||||
assert.Equal(t, "Server.InsufficientInstanceCapacity", *stoppedInst.Instance.StateReason.Code)
|
||||
assert.Contains(t, *stoppedInst.Instance.StateReason.Message, "m7i.small")
|
||||
}
|
||||
|
||||
// TestPendingWatchdog_MarksStuckInstanceFailed verifies that the pending watchdog
|
||||
// detects instances stuck in pending beyond the timeout and marks them failed.
|
||||
func TestPendingWatchdog_MarksStuckInstanceFailed(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
staleTime := time.Now().Add(-6 * time.Minute)
|
||||
recentTime := time.Now()
|
||||
|
||||
daemon.Instances.VMS["i-stuck"] = &vm.VM{
|
||||
ID: "i-stuck",
|
||||
Status: vm.StatePending,
|
||||
Instance: &ec2.Instance{
|
||||
LaunchTime: &staleTime,
|
||||
},
|
||||
}
|
||||
daemon.Instances.VMS["i-fresh"] = &vm.VM{
|
||||
ID: "i-fresh",
|
||||
Status: vm.StatePending,
|
||||
Instance: &ec2.Instance{
|
||||
LaunchTime: &recentTime,
|
||||
},
|
||||
}
|
||||
|
||||
// Run one watchdog tick manually instead of waiting for the ticker
|
||||
daemon.Instances.Mu.Lock()
|
||||
var stuck []*vm.VM
|
||||
for _, instance := range daemon.Instances.VMS {
|
||||
if (instance.Status == vm.StatePending || instance.Status == vm.StateProvisioning) &&
|
||||
instance.Instance != nil && instance.Instance.LaunchTime != nil &&
|
||||
time.Since(*instance.Instance.LaunchTime) > 5*time.Minute {
|
||||
stuck = append(stuck, instance)
|
||||
}
|
||||
}
|
||||
daemon.Instances.Mu.Unlock()
|
||||
|
||||
for _, instance := range stuck {
|
||||
daemon.markInstanceFailed(instance, "launch_timeout")
|
||||
}
|
||||
|
||||
// Stuck instance should have transitioned to shutting-down
|
||||
stuckInst := daemon.Instances.VMS["i-stuck"]
|
||||
assert.Equal(t, vm.StateShuttingDown, stuckInst.Status)
|
||||
require.NotNil(t, stuckInst.Instance.StateReason)
|
||||
assert.Equal(t, "Server.InternalError", *stuckInst.Instance.StateReason.Code)
|
||||
assert.Equal(t, "launch_timeout", *stuckInst.Instance.StateReason.Message)
|
||||
|
||||
// Fresh instance should still be pending
|
||||
freshInst := daemon.Instances.VMS["i-fresh"]
|
||||
assert.Equal(t, vm.StatePending, freshInst.Status)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -44,6 +45,7 @@ type StatusResponse struct {
|
||||
Credentials *SharedCredentials `json:"credentials,omitempty"`
|
||||
CACert string `json:"ca_cert,omitempty"`
|
||||
CAKey string `json:"ca_key,omitempty"`
|
||||
MasterKey string `json:"master_key,omitempty"`
|
||||
}
|
||||
|
||||
// SharedCredentials contains the cluster-wide credentials distributed during formation.
|
||||
@@ -54,6 +56,10 @@ type SharedCredentials struct {
|
||||
NatsToken string `json:"nats_token"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Region string `json:"region"`
|
||||
|
||||
// Admin credentials (generated by init node, shared to join nodes)
|
||||
AdminAccessKey string `json:"admin_access_key,omitempty"`
|
||||
AdminSecretKey string `json:"admin_secret_key,omitempty"`
|
||||
}
|
||||
|
||||
// FormationServer is a lightweight HTTP server that coordinates cluster formation.
|
||||
@@ -67,6 +73,7 @@ type FormationServer struct {
|
||||
credentials *SharedCredentials
|
||||
caCert string
|
||||
caKey string
|
||||
masterKey string
|
||||
done chan struct{}
|
||||
server *http.Server
|
||||
}
|
||||
@@ -147,12 +154,17 @@ func (fs *FormationServer) Nodes() map[string]NodeInfo {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
out := make(map[string]NodeInfo, len(fs.nodes))
|
||||
for k, v := range fs.nodes {
|
||||
out[k] = v
|
||||
}
|
||||
maps.Copy(out, fs.nodes)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetMasterKey sets the base64-encoded IAM master key for distribution to joining nodes.
|
||||
func (fs *FormationServer) SetMasterKey(key string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.masterKey = key
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the given address (e.g. "10.0.0.1:4432").
|
||||
func (fs *FormationServer) Start(bindAddr string) error {
|
||||
mux := http.NewServeMux()
|
||||
@@ -235,12 +247,11 @@ func (fs *FormationServer) handleStatus(w http.ResponseWriter, r *http.Request)
|
||||
// Only expose full data when formation is complete
|
||||
if fs.isComplete() {
|
||||
resp.Nodes = make(map[string]NodeInfo, len(fs.nodes))
|
||||
for k, v := range fs.nodes {
|
||||
resp.Nodes[k] = v
|
||||
}
|
||||
maps.Copy(resp.Nodes, fs.nodes)
|
||||
resp.Credentials = fs.credentials
|
||||
resp.CACert = fs.caCert
|
||||
resp.CAKey = fs.caKey
|
||||
resp.MasterKey = fs.masterKey
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
@@ -207,6 +207,34 @@ func TestStatusEndpointComplete(t *testing.T) {
|
||||
assert.Equal(t, "ca-key-data", sr.CAKey)
|
||||
}
|
||||
|
||||
func TestStatusEndpointMasterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := NewFormationServer(2, testCreds(), "ca-cert", "ca-key")
|
||||
fs.SetMasterKey("dGVzdC1tYXN0ZXIta2V5LWJhc2U2NC1lbmNvZGVk")
|
||||
ts := testServer(t, fs)
|
||||
defer ts.Close()
|
||||
|
||||
// Before completion: master key should not be exposed
|
||||
resp, err := http.Get(ts.URL + "/formation/status")
|
||||
require.NoError(t, err)
|
||||
var sr StatusResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
|
||||
resp.Body.Close()
|
||||
assert.Empty(t, sr.MasterKey)
|
||||
|
||||
// Complete formation
|
||||
require.NoError(t, fs.RegisterNode(testNode("node1", "10.0.0.1")))
|
||||
require.NoError(t, fs.RegisterNode(testNode("node2", "10.0.0.2")))
|
||||
|
||||
// After completion: master key should be present
|
||||
resp, err = http.Get(ts.URL + "/formation/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
|
||||
assert.True(t, sr.Complete)
|
||||
assert.Equal(t, "dGVzdC1tYXN0ZXIta2V5LWJhc2U2NC1lbmNvZGVk", sr.MasterKey)
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := NewFormationServer(1, testCreds(), "", "")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package formation
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/mulgadc/hive/hive/admin"
|
||||
@@ -12,12 +13,7 @@ func hasService(services []string, name string) bool {
|
||||
if len(services) == 0 {
|
||||
return true // backward compat: empty = all services
|
||||
}
|
||||
for _, s := range services {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(services, name)
|
||||
}
|
||||
|
||||
// BuildClusterRoutes returns a sorted list of "IP:4248" NATS cluster routes
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package gateway
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
import "net/http"
|
||||
|
||||
func (gw *GatewayConfig) Account_Request(ctx *fiber.Ctx) error {
|
||||
func (gw *GatewayConfig) Account_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+163
-105
@@ -1,16 +1,20 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_iam "github.com/mulgadc/hive/hive/handlers/iam"
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
)
|
||||
|
||||
@@ -19,113 +23,163 @@ const (
|
||||
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)
|
||||
// SigV4AuthMiddleware returns stdlib middleware that validates AWS Signature V4 authentication.
|
||||
func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip OPTIONS requests (CORS preflight)
|
||||
if r.Method == "OPTIONS" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Signature
|
||||
var providedSignature string
|
||||
if after, ok := strings.CutPrefix(parts[2], "Signature="); ok {
|
||||
providedSignature = after
|
||||
} else {
|
||||
return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature)
|
||||
}
|
||||
// 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 ") {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Extract components from the Authorization header
|
||||
parts := strings.Split(authHeader[len("AWS4-HMAC-SHA256 "):], ", ")
|
||||
if len(parts) != 3 {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// Get timestamp from X-Amz-Date header
|
||||
timestamp := c.Get("X-Amz-Date")
|
||||
if timestamp == "" {
|
||||
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" {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
accessKey = credParts[0]
|
||||
date = credParts[1]
|
||||
region = credParts[2]
|
||||
service = credParts[3]
|
||||
} else {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Parse SignedHeaders
|
||||
var signedHeaders string
|
||||
if after, ok := strings.CutPrefix(parts[1], "SignedHeaders="); ok {
|
||||
signedHeaders = after
|
||||
} else {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// Compute expected signature
|
||||
expectedSignature := gw.computeSignature(c, date, timestamp, region, service, signedHeaders)
|
||||
// Parse Signature
|
||||
var providedSignature string
|
||||
if after, ok := strings.CutPrefix(parts[2], "Signature="); ok {
|
||||
providedSignature = after
|
||||
} else {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Lookup access key in IAM KV store
|
||||
if gw.IAMService == nil {
|
||||
slog.Error("SigV4 auth: IAM service not initialized")
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store parsed auth data in context for downstream handlers
|
||||
c.Locals("sigv4.service", service)
|
||||
c.Locals("sigv4.region", region)
|
||||
c.Locals("sigv4.accessKey", accessKey)
|
||||
ak, err := gw.IAMService.LookupAccessKey(accessKey)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
|
||||
slog.Debug("Access key not found", "accessKeyID", accessKey)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
|
||||
return
|
||||
}
|
||||
slog.Error("IAM lookup failed", "accessKeyID", accessKey, "err", err)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
}
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
slog.Debug("Access key inactive", "accessKeyID", accessKey)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("SigV4 authentication successful", "accessKey", accessKey)
|
||||
return c.Next()
|
||||
secret, err := gw.IAMService.DecryptSecret(ak.SecretAccessKey)
|
||||
if err != nil {
|
||||
slog.Error("Failed to decrypt IAM secret", "accessKeyID", accessKey, "err", err)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get timestamp from X-Amz-Date header
|
||||
timestamp := r.Header.Get("X-Amz-Date")
|
||||
if timestamp == "" {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
return
|
||||
}
|
||||
if time.Since(parsedTime).Abs() > maxClockSkew {
|
||||
slog.Debug("Signature expired", "timestamp", timestamp, "skew", time.Since(parsedTime))
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
}
|
||||
|
||||
// Read body once and re-buffer for downstream handlers
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read request body", "err", err)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
// Compute expected signature using decrypted secret
|
||||
expectedSignature := computeSignatureWithSecret(r, body, secret, 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,
|
||||
)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
}
|
||||
|
||||
// Store parsed auth data in context for downstream handlers
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ctxIdentity, ak.UserName)
|
||||
ctx = context.WithValue(ctx, ctxAccountID, ak.AccountID)
|
||||
ctx = context.WithValue(ctx, ctxService, service)
|
||||
ctx = context.WithValue(ctx, ctxRegion, region)
|
||||
ctx = context.WithValue(ctx, ctxAccessKey, accessKey)
|
||||
|
||||
slog.Debug("SigV4 authentication successful", "accessKey", accessKey, "identity", ak.UserName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// computeSignatureWithSecret builds the canonical request and computes the AWS Signature V4 signature
|
||||
// using the provided secret key. The body is passed explicitly since it has already been read from r.Body.
|
||||
func computeSignatureWithSecret(r *http.Request, body []byte, secretKey, date, timestamp, region, service, signedHeaders string) string {
|
||||
// Build canonical URI (URI-encoded path)
|
||||
path := c.Path()
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
@@ -135,7 +189,7 @@ func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region,
|
||||
}
|
||||
|
||||
// Build canonical query string (sorted, encoded)
|
||||
canonicalQueryString := buildCanonicalQueryString(string(c.Request().URI().QueryString()))
|
||||
canonicalQueryString := buildCanonicalQueryString(r.URL.RawQuery)
|
||||
|
||||
// Build canonical headers from SignedHeaders list
|
||||
headersList := strings.Split(signedHeaders, ";")
|
||||
@@ -146,9 +200,9 @@ func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region,
|
||||
header = strings.ToLower(strings.TrimSpace(header))
|
||||
var value string
|
||||
if header == "host" {
|
||||
value = string(c.Request().Host())
|
||||
value = r.Host
|
||||
} else {
|
||||
value = c.Get(canonicalHeaderName(header))
|
||||
value = r.Header.Get(canonicalHeaderName(header))
|
||||
}
|
||||
// Trim leading/trailing whitespace and collapse multiple spaces
|
||||
value = strings.TrimSpace(value)
|
||||
@@ -159,12 +213,12 @@ func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region,
|
||||
}
|
||||
|
||||
// Hash payload body with SHA256
|
||||
payloadHash := auth.HashSHA256(string(c.Body()))
|
||||
payloadHash := auth.HashSHA256(string(body))
|
||||
|
||||
// Build canonical request
|
||||
canonicalRequest := fmt.Sprintf(
|
||||
"%s\n%s\n%s\n%s\n%s\n%s",
|
||||
c.Method(),
|
||||
r.Method,
|
||||
canonicalURI,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders.String(),
|
||||
@@ -184,7 +238,7 @@ func (gw *GatewayConfig) computeSignature(c *fiber.Ctx, date, timestamp, region,
|
||||
)
|
||||
|
||||
// Derive signing key and compute signature
|
||||
signingKey := auth.GetSigningKey(gw.SecretKey, date, region, service)
|
||||
signingKey := auth.GetSigningKey(secretKey, date, region, service)
|
||||
signature := auth.HmacSHA256Hex(signingKey, stringToSign)
|
||||
|
||||
return signature
|
||||
@@ -236,7 +290,7 @@ func buildCanonicalQueryString(queryString string) string {
|
||||
|
||||
// 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
|
||||
// Convert header names like "x-amz-date" to "X-Amz-Date" for http.Header.Get
|
||||
parts := strings.Split(header, "-")
|
||||
for i, part := range parts {
|
||||
if len(part) > 0 {
|
||||
@@ -247,8 +301,11 @@ func canonicalHeaderName(header string) string {
|
||||
}
|
||||
|
||||
// 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())
|
||||
func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode string) {
|
||||
requestID := r.Header.Get("x-amz-request-id")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
|
||||
errorMsg, exists := awserrors.ErrorLookup[errorCode]
|
||||
if !exists {
|
||||
@@ -257,6 +314,7 @@ func (gw *GatewayConfig) writeSigV4Error(c *fiber.Ctx, errorCode string) error {
|
||||
|
||||
xmlError := GenerateEC2ErrorResponse(errorCode, errorMsg.Message, requestID)
|
||||
|
||||
c.Set("Content-Type", "application/xml")
|
||||
return c.Status(errorMsg.HTTPCode).Send(xmlError)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
_, _ = w.Write(xmlError)
|
||||
}
|
||||
|
||||
+1603
-53
File diff suppressed because it is too large
Load Diff
+157
-85
@@ -4,37 +4,42 @@ package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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_igw "github.com/mulgadc/hive/hive/gateway/ec2/igw"
|
||||
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_vpc "github.com/mulgadc/hive/hive/gateway/ec2/vpc"
|
||||
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)
|
||||
// accountID is the caller's AWS account ID extracted from SigV4 auth context.
|
||||
type EC2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]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) {
|
||||
func ec2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) EC2Handler {
|
||||
return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) {
|
||||
input := new(In)
|
||||
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := handler(input, gw)
|
||||
output, err := handler(input, gw, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -48,128 +53,176 @@ func ec2Handler[In any](handler func(*In, *GatewayConfig) (any, error)) EC2Handl
|
||||
}
|
||||
|
||||
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())
|
||||
"DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
|
||||
}),
|
||||
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_instance.RunInstances(input, gw.NATSConn)
|
||||
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.RunInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"StartInstances": ec2Handler(func(input *ec2.StartInstancesInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_instance.StartInstances(input, gw.NATSConn)
|
||||
"StartInstances": ec2Handler(func(input *ec2.StartInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.StartInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"StopInstances": ec2Handler(func(input *ec2.StopInstancesInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_instance.StopInstances(input, gw.NATSConn)
|
||||
"StopInstances": ec2Handler(func(input *ec2.StopInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.StopInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"TerminateInstances": ec2Handler(func(input *ec2.TerminateInstancesInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_instance.TerminateInstances(input, gw.NATSConn)
|
||||
"RebootInstances": ec2Handler(func(input *ec2.RebootInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.RebootInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypes": ec2Handler(func(input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig) (any, error) {
|
||||
"TerminateInstances": ec2Handler(func(input *ec2.TerminateInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.TerminateInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypes": ec2Handler(func(input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig, accountID string) (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)
|
||||
"GetConsoleOutput": ec2Handler(func(input *ec2.GetConsoleOutputInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.GetConsoleOutput(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateKeyPair": ec2Handler(func(input *ec2.CreateKeyPairInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_key.CreateKeyPair(input, gw.NATSConn)
|
||||
"ModifyInstanceAttribute": ec2Handler(func(input *ec2.ModifyInstanceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.ModifyInstanceAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteKeyPair": ec2Handler(func(input *ec2.DeleteKeyPairInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_key.DeleteKeyPair(input, gw.NATSConn)
|
||||
"CreateKeyPair": ec2Handler(func(input *ec2.CreateKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_key.CreateKeyPair(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeKeyPairs": ec2Handler(func(input *ec2.DescribeKeyPairsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_key.DescribeKeyPairs(input, gw.NATSConn)
|
||||
"DeleteKeyPair": ec2Handler(func(input *ec2.DeleteKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_key.DeleteKeyPair(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig) ([]byte, error) {
|
||||
"DescribeKeyPairs": ec2Handler(func(input *ec2.DescribeKeyPairsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_key.DescribeKeyPairs(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]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)
|
||||
return ec2Handler(func(input *ec2.ImportKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_key.ImportKeyPair(input, gw.NATSConn, accountID)
|
||||
})(action, q, gw, accountID)
|
||||
},
|
||||
"DescribeImages": ec2Handler(func(input *ec2.DescribeImagesInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_image.DescribeImages(input, gw.NATSConn)
|
||||
"DescribeImages": ec2Handler(func(input *ec2.DescribeImagesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_image.DescribeImages(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateImage": ec2Handler(func(input *ec2.CreateImageInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_image.CreateImage(input, gw.NATSConn)
|
||||
"CreateImage": ec2Handler(func(input *ec2.CreateImageInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_image.CreateImage(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
|
||||
}),
|
||||
"DescribeRegions": ec2Handler(func(input *ec2.DescribeRegionsInput, gw *GatewayConfig) (any, error) {
|
||||
"DescribeRegions": ec2Handler(func(input *ec2.DescribeRegionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_zone.DescribeRegions(input, gw.Region)
|
||||
}),
|
||||
"DescribeAvailabilityZones": ec2Handler(func(input *ec2.DescribeAvailabilityZonesInput, gw *GatewayConfig) (any, error) {
|
||||
"DescribeAvailabilityZones": ec2Handler(func(input *ec2.DescribeAvailabilityZonesInput, gw *GatewayConfig, accountID string) (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)
|
||||
"DescribeVolumes": ec2Handler(func(input *ec2.DescribeVolumesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.DescribeVolumes(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyVolume": ec2Handler(func(input *ec2.ModifyVolumeInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.ModifyVolume(input, gw.NATSConn)
|
||||
"ModifyVolume": ec2Handler(func(input *ec2.ModifyVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.ModifyVolume(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateVolume": ec2Handler(func(input *ec2.CreateVolumeInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.CreateVolume(input, gw.NATSConn)
|
||||
"CreateVolume": ec2Handler(func(input *ec2.CreateVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.CreateVolume(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteVolume": ec2Handler(func(input *ec2.DeleteVolumeInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.DeleteVolume(input, gw.NATSConn)
|
||||
"DeleteVolume": ec2Handler(func(input *ec2.DeleteVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.DeleteVolume(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AttachVolume": ec2Handler(func(input *ec2.AttachVolumeInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.AttachVolume(input, gw.NATSConn)
|
||||
"AttachVolume": ec2Handler(func(input *ec2.AttachVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.AttachVolume(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVolumeStatus": ec2Handler(func(input *ec2.DescribeVolumeStatusInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.DescribeVolumeStatus(input, gw.NATSConn)
|
||||
"DescribeVolumeStatus": ec2Handler(func(input *ec2.DescribeVolumeStatusInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.DescribeVolumeStatus(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DetachVolume": ec2Handler(func(input *ec2.DetachVolumeInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_volume.DetachVolume(input, gw.NATSConn)
|
||||
"DetachVolume": ec2Handler(func(input *ec2.DetachVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_volume.DetachVolume(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeAccountAttributes": ec2Handler(func(input *ec2.DescribeAccountAttributesInput, gw *GatewayConfig) (any, error) {
|
||||
"DescribeAccountAttributes": ec2Handler(func(input *ec2.DescribeAccountAttributesInput, gw *GatewayConfig, accountID string) (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)
|
||||
"EnableEbsEncryptionByDefault": ec2Handler(func(input *ec2.EnableEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.EnableEbsEncryptionByDefault(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisableEbsEncryptionByDefault": ec2Handler(func(input *ec2.DisableEbsEncryptionByDefaultInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_account.DisableEbsEncryptionByDefault(input, gw.NATSConn)
|
||||
"DisableEbsEncryptionByDefault": ec2Handler(func(input *ec2.DisableEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.DisableEbsEncryptionByDefault(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetEbsEncryptionByDefault": ec2Handler(func(input *ec2.GetEbsEncryptionByDefaultInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_account.GetEbsEncryptionByDefault(input, gw.NATSConn)
|
||||
"GetEbsEncryptionByDefault": ec2Handler(func(input *ec2.GetEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.GetEbsEncryptionByDefault(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetSerialConsoleAccessStatus": ec2Handler(func(input *ec2.GetSerialConsoleAccessStatusInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_account.GetSerialConsoleAccessStatus(input, gw.NATSConn)
|
||||
"GetSerialConsoleAccessStatus": ec2Handler(func(input *ec2.GetSerialConsoleAccessStatusInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.GetSerialConsoleAccessStatus(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"EnableSerialConsoleAccess": ec2Handler(func(input *ec2.EnableSerialConsoleAccessInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_account.EnableSerialConsoleAccess(input, gw.NATSConn)
|
||||
"EnableSerialConsoleAccess": ec2Handler(func(input *ec2.EnableSerialConsoleAccessInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.EnableSerialConsoleAccess(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisableSerialConsoleAccess": ec2Handler(func(input *ec2.DisableSerialConsoleAccessInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_account.DisableSerialConsoleAccess(input, gw.NATSConn)
|
||||
"DisableSerialConsoleAccess": ec2Handler(func(input *ec2.DisableSerialConsoleAccessInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_account.DisableSerialConsoleAccess(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateTags": ec2Handler(func(input *ec2.CreateTagsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_tags.CreateTags(input, gw.NATSConn)
|
||||
"CreateTags": ec2Handler(func(input *ec2.CreateTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_tags.CreateTags(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteTags": ec2Handler(func(input *ec2.DeleteTagsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_tags.DeleteTags(input, gw.NATSConn)
|
||||
"DeleteTags": ec2Handler(func(input *ec2.DeleteTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_tags.DeleteTags(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTags": ec2Handler(func(input *ec2.DescribeTagsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_tags.DescribeTags(input, gw.NATSConn)
|
||||
"DescribeTags": ec2Handler(func(input *ec2.DescribeTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_tags.DescribeTags(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSnapshot": ec2Handler(func(input *ec2.CreateSnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.CreateSnapshot(input, gw.NATSConn)
|
||||
"CreateSnapshot": ec2Handler(func(input *ec2.CreateSnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_snapshot.CreateSnapshot(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteSnapshot": ec2Handler(func(input *ec2.DeleteSnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.DeleteSnapshot(input, gw.NATSConn)
|
||||
"DeleteSnapshot": ec2Handler(func(input *ec2.DeleteSnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_snapshot.DeleteSnapshot(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSnapshots": ec2Handler(func(input *ec2.DescribeSnapshotsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.DescribeSnapshots(input, gw.NATSConn)
|
||||
"DescribeSnapshots": ec2Handler(func(input *ec2.DescribeSnapshotsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_snapshot.DescribeSnapshots(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CopySnapshot": ec2Handler(func(input *ec2.CopySnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.CopySnapshot(input, gw.NATSConn)
|
||||
"CopySnapshot": ec2Handler(func(input *ec2.CopySnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_snapshot.CopySnapshot(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateEgressOnlyInternetGateway": ec2Handler(func(input *ec2.CreateEgressOnlyInternetGatewayInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_eigw.CreateEgressOnlyInternetGateway(input, gw.NATSConn)
|
||||
"CreateInternetGateway": ec2Handler(func(input *ec2.CreateInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_igw.CreateInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteEgressOnlyInternetGateway": ec2Handler(func(input *ec2.DeleteEgressOnlyInternetGatewayInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_eigw.DeleteEgressOnlyInternetGateway(input, gw.NATSConn)
|
||||
"DeleteInternetGateway": ec2Handler(func(input *ec2.DeleteInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_igw.DeleteInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeEgressOnlyInternetGateways": ec2Handler(func(input *ec2.DescribeEgressOnlyInternetGatewaysInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_eigw.DescribeEgressOnlyInternetGateways(input, gw.NATSConn)
|
||||
"DescribeInternetGateways": ec2Handler(func(input *ec2.DescribeInternetGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_igw.DescribeInternetGateways(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AttachInternetGateway": ec2Handler(func(input *ec2.AttachInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_igw.AttachInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DetachInternetGateway": ec2Handler(func(input *ec2.DetachInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_igw.DetachInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateEgressOnlyInternetGateway": ec2Handler(func(input *ec2.CreateEgressOnlyInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_eigw.CreateEgressOnlyInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteEgressOnlyInternetGateway": ec2Handler(func(input *ec2.DeleteEgressOnlyInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_eigw.DeleteEgressOnlyInternetGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeEgressOnlyInternetGateways": ec2Handler(func(input *ec2.DescribeEgressOnlyInternetGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_eigw.DescribeEgressOnlyInternetGateways(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateVpc": ec2Handler(func(input *ec2.CreateVpcInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateVpc(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteVpc": ec2Handler(func(input *ec2.DeleteVpcInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DeleteVpc(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVpcs": ec2Handler(func(input *ec2.DescribeVpcsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeVpcs(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSubnet": ec2Handler(func(input *ec2.CreateSubnetInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateSubnet(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteSubnet": ec2Handler(func(input *ec2.DeleteSubnetInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DeleteSubnet(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSubnets": ec2Handler(func(input *ec2.DescribeSubnetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeSubnets(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateNetworkInterface": ec2Handler(func(input *ec2.CreateNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateNetworkInterface(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteNetworkInterface": ec2Handler(func(input *ec2.DeleteNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DeleteNetworkInterface(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeNetworkInterfaces": ec2Handler(func(input *ec2.DescribeNetworkInterfacesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeNetworkInterfaces(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -180,8 +233,13 @@ var ec2LocalActions = map[string]bool{
|
||||
"DescribeAccountAttributes": true,
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
|
||||
queryArgs := ParseAWSQueryArgs(string(ctx.Body()))
|
||||
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read EC2 request body", "error", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
queryArgs := ParseAWSQueryArgs(string(body))
|
||||
|
||||
action := queryArgs["Action"]
|
||||
handler, ok := ec2Actions[action]
|
||||
@@ -189,14 +247,28 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if err := gw.checkPolicy(r, "ec2", action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil && !ec2LocalActions[action] {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler(action, queryArgs, gw)
|
||||
// Extract account ID from auth context
|
||||
accountID, _ := r.Context().Value(ctxAccountID).(string)
|
||||
if accountID == "" {
|
||||
slog.Error("EC2_Request: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler(action, queryArgs, gw, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput)
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(xmlOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateDisableEbsEncryptionByDefaultInput(input *ec2.DisableEbsEncryptionB
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableEbsEncryptionByDefault(input *ec2.DisableEbsEncryptionByDefaultInput, natsConn *nats.Conn) (ec2.DisableEbsEncryptionByDefaultOutput, error) {
|
||||
func DisableEbsEncryptionByDefault(input *ec2.DisableEbsEncryptionByDefaultInput, natsConn *nats.Conn, accountID string) (ec2.DisableEbsEncryptionByDefaultOutput, error) {
|
||||
var output ec2.DisableEbsEncryptionByDefaultOutput
|
||||
|
||||
if err := ValidateDisableEbsEncryptionByDefaultInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func DisableEbsEncryptionByDefault(input *ec2.DisableEbsEncryptionByDefaultInput
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.DisableEbsEncryptionByDefault(input)
|
||||
result, err := svc.DisableEbsEncryptionByDefault(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateDisableSerialConsoleAccessInput(input *ec2.DisableSerialConsoleAcce
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableSerialConsoleAccess(input *ec2.DisableSerialConsoleAccessInput, natsConn *nats.Conn) (ec2.DisableSerialConsoleAccessOutput, error) {
|
||||
func DisableSerialConsoleAccess(input *ec2.DisableSerialConsoleAccessInput, natsConn *nats.Conn, accountID string) (ec2.DisableSerialConsoleAccessOutput, error) {
|
||||
var output ec2.DisableSerialConsoleAccessOutput
|
||||
|
||||
if err := ValidateDisableSerialConsoleAccessInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func DisableSerialConsoleAccess(input *ec2.DisableSerialConsoleAccessInput, nats
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.DisableSerialConsoleAccess(input)
|
||||
result, err := svc.DisableSerialConsoleAccess(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateEnableEbsEncryptionByDefaultInput(input *ec2.EnableEbsEncryptionByD
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableEbsEncryptionByDefault(input *ec2.EnableEbsEncryptionByDefaultInput, natsConn *nats.Conn) (ec2.EnableEbsEncryptionByDefaultOutput, error) {
|
||||
func EnableEbsEncryptionByDefault(input *ec2.EnableEbsEncryptionByDefaultInput, natsConn *nats.Conn, accountID string) (ec2.EnableEbsEncryptionByDefaultOutput, error) {
|
||||
var output ec2.EnableEbsEncryptionByDefaultOutput
|
||||
|
||||
if err := ValidateEnableEbsEncryptionByDefaultInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func EnableEbsEncryptionByDefault(input *ec2.EnableEbsEncryptionByDefaultInput,
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.EnableEbsEncryptionByDefault(input)
|
||||
result, err := svc.EnableEbsEncryptionByDefault(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateEnableSerialConsoleAccessInput(input *ec2.EnableSerialConsoleAccess
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableSerialConsoleAccess(input *ec2.EnableSerialConsoleAccessInput, natsConn *nats.Conn) (ec2.EnableSerialConsoleAccessOutput, error) {
|
||||
func EnableSerialConsoleAccess(input *ec2.EnableSerialConsoleAccessInput, natsConn *nats.Conn, accountID string) (ec2.EnableSerialConsoleAccessOutput, error) {
|
||||
var output ec2.EnableSerialConsoleAccessOutput
|
||||
|
||||
if err := ValidateEnableSerialConsoleAccessInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func EnableSerialConsoleAccess(input *ec2.EnableSerialConsoleAccessInput, natsCo
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.EnableSerialConsoleAccess(input)
|
||||
result, err := svc.EnableSerialConsoleAccess(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateGetEbsEncryptionByDefaultInput(input *ec2.GetEbsEncryptionByDefault
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetEbsEncryptionByDefault(input *ec2.GetEbsEncryptionByDefaultInput, natsConn *nats.Conn) (ec2.GetEbsEncryptionByDefaultOutput, error) {
|
||||
func GetEbsEncryptionByDefault(input *ec2.GetEbsEncryptionByDefaultInput, natsConn *nats.Conn, accountID string) (ec2.GetEbsEncryptionByDefaultOutput, error) {
|
||||
var output ec2.GetEbsEncryptionByDefaultOutput
|
||||
|
||||
if err := ValidateGetEbsEncryptionByDefaultInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func GetEbsEncryptionByDefault(input *ec2.GetEbsEncryptionByDefaultInput, natsCo
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.GetEbsEncryptionByDefault(input)
|
||||
result, err := svc.GetEbsEncryptionByDefault(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func ValidateGetSerialConsoleAccessStatusInput(input *ec2.GetSerialConsoleAccess
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetSerialConsoleAccessStatus(input *ec2.GetSerialConsoleAccessStatusInput, natsConn *nats.Conn) (ec2.GetSerialConsoleAccessStatusOutput, error) {
|
||||
func GetSerialConsoleAccessStatus(input *ec2.GetSerialConsoleAccessStatusInput, natsConn *nats.Conn, accountID string) (ec2.GetSerialConsoleAccessStatusOutput, error) {
|
||||
var output ec2.GetSerialConsoleAccessStatusOutput
|
||||
|
||||
if err := ValidateGetSerialConsoleAccessStatusInput(input); err != nil {
|
||||
@@ -24,7 +24,7 @@ func GetSerialConsoleAccessStatus(input *ec2.GetSerialConsoleAccessStatusInput,
|
||||
}
|
||||
|
||||
svc := handlers_ec2_account.NewNATSAccountSettingsService(natsConn)
|
||||
result, err := svc.GetSerialConsoleAccessStatus(input)
|
||||
result, err := svc.GetSerialConsoleAccessStatus(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
@@ -278,3 +278,65 @@ func TestValidateGetSerialConsoleAccessStatusInput(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler tests — call handlers with valid input + nil NATS to cover error paths
|
||||
|
||||
func TestEnableEbsEncryptionByDefault_ValidationErrors(t *testing.T) {
|
||||
_, err := EnableEbsEncryptionByDefault(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestEnableEbsEncryptionByDefault_NilNATS(t *testing.T) {
|
||||
_, err := EnableEbsEncryptionByDefault(&ec2.EnableEbsEncryptionByDefaultInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDisableEbsEncryptionByDefault_ValidationErrors(t *testing.T) {
|
||||
_, err := DisableEbsEncryptionByDefault(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDisableEbsEncryptionByDefault_NilNATS(t *testing.T) {
|
||||
_, err := DisableEbsEncryptionByDefault(&ec2.DisableEbsEncryptionByDefaultInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetEbsEncryptionByDefault_ValidationErrors(t *testing.T) {
|
||||
_, err := GetEbsEncryptionByDefault(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestGetEbsEncryptionByDefault_NilNATS(t *testing.T) {
|
||||
_, err := GetEbsEncryptionByDefault(&ec2.GetEbsEncryptionByDefaultInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEnableSerialConsoleAccess_ValidationErrors(t *testing.T) {
|
||||
_, err := EnableSerialConsoleAccess(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestEnableSerialConsoleAccess_NilNATS(t *testing.T) {
|
||||
_, err := EnableSerialConsoleAccess(&ec2.EnableSerialConsoleAccessInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDisableSerialConsoleAccess_ValidationErrors(t *testing.T) {
|
||||
_, err := DisableSerialConsoleAccess(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDisableSerialConsoleAccess_NilNATS(t *testing.T) {
|
||||
_, err := DisableSerialConsoleAccess(&ec2.DisableSerialConsoleAccessInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetSerialConsoleAccessStatus_ValidationErrors(t *testing.T) {
|
||||
_, err := GetSerialConsoleAccessStatus(nil, nil, "")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestGetSerialConsoleAccessStatus_NilNATS(t *testing.T) {
|
||||
_, err := GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{}, nil, "acct-123")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user