Compare commits

..

41 Commits

Author SHA1 Message Date
Ben Duncan 120bfa8953 fix: generate go.work in Dockerfile instead of copying gitignored file 2026-03-16 12:36:56 +00:00
Ben Duncan d8d5fd7d2c New doc links 2026-03-16 12:32:44 +00:00
Ben Duncan b857c7982e Merge remote-tracking branch 'origin/dev' 2026-03-16 12:17:57 +00:00
Ben Duncan 965e65df3b Added test coverage 2026-03-16 12:14:46 +00:00
Ben Duncan 4c4683ba0c installer tweaks for sudo 2026-03-16 12:04:06 +00:00
Ben Duncan a458a28406 new binary mode and systemd process 2026-03-16 11:57:52 +00:00
Ben Duncan 2351d4ff81 New doc templates 2026-03-16 22:47:08 +11:00
Julian Sommer e7a3b5b712 fix: update test for explicit error handling 2026-03-13 16:07:49 +10:00
Julian Sommer 36f820c859 fix: error handling (#76) 2026-03-13 17:01:00 +11:00
Julian Sommer c60a691ac1 fix: start/stop race condition 2026-03-13 05:27:28 +00:00
Julian Sommer 21fd5a7081 fix: IAM replication 2026-03-13 04:33:19 +00:00
Julian Sommer 2b455e365e fix: dropped state 2026-03-13 03:53:38 +00:00
Julian Sommer e6a09d371c fix: multi node e2e retry 2026-03-13 03:37:41 +00:00
Julian Sommer 6f3f348287 chore: bump vite plugin react 2026-03-13 02:52:08 +00:00
Julian Sommer 8e8b17c599 chore: migrate to vite8 2026-03-13 02:39:28 +00:00
Julian Sommer 72aa3e7563 feat: migrate to oxlint (#75) 2026-03-13 13:24:01 +11:00
Julian Sommer 096b3380fc feat: default vpc account scoping (#74) 2026-03-13 13:18:58 +11:00
Julian Sommer 663805cd23 fix: race conditions on node failure 2026-03-13 01:53:00 +00:00
Julian Sommer 380ab2a870 fix: replicate all js buckets 2026-03-13 10:48:00 +10:00
Julian Sommer 98c75cbb83 fix: stop-dev.sh 2026-03-13 09:17:41 +10:00
Julian Sommer 3ffe67271f feat: multi node e2e (#73) 2026-03-13 10:11:26 +11:00
Julian Sommer 4466de04d3 fix: ovn logrotate 2026-03-12 08:24:52 +10:00
Julian Sommer fd91b80851 fix: hardcoded predastore nats url 2026-03-11 13:57:49 +10:00
Julian Sommer 0853eb5068 fix: return error from GenerateNATSToken instead of panicking
Same pattern as H-5 — crypto/rand failure caused a panic. Now returns
(string, error) so callers can handle it gracefully.
2026-03-11 12:08:44 +10:00
Julian Sommer 3d8c9f3812 fix: hive panics (#71) 2026-03-11 13:02:31 +11:00
Julian Sommer 2cac5e2c37 fix: cors 2026-03-11 11:07:44 +10:00
Julian Sommer 0ec13f33eb fix: lint 2026-03-11 10:58:03 +10:00
Julian Sommer 7af16b75b9 feat: 80% real frontend coverage 2026-03-11 10:56:42 +10:00
Julian Sommer 40e09f0886 feat: enforce frontend code coverage 2026-03-11 10:25:04 +10:00
Julian Sommer e26cd7fd82 feat: ui tests 2026-03-11 10:14:31 +10:00
Julian Sommer 8f223d35d3 feat: setup vitest 2026-03-11 09:47:46 +10:00
Julian Sommer b0dbc76b22 chore: frontend deps 2026-03-11 09:32:46 +10:00
Julian Sommer 506bced4fa fix: e2e single 2026-03-11 08:43:05 +10:00
Ben Duncan 438ec3c973 Merge branch 'dev' of github.com:mulgadc/hive into dev 2026-03-10 22:13:11 +10:00
Ben Duncan 11a1f18cb0 removed restrict on hostfwd, vpc issue to ssh in 2026-03-10 22:13:00 +10:00
Julian Sommer 1ff08ab80a fix: seperate system account (#70) 2026-03-10 16:30:34 +11:00
Ben Duncan d17eef2ba9 Merge branch 'dev' of github.com:mulgadc/hive into dev 2026-03-10 10:58:04 +10:00
Ben Duncan b1059899a2 dev networking, remote dhcp, due to double route bug 2026-03-10 10:57:55 +10:00
Julian Sommer 17da8b2eb2 fix: update INSTALL.md 2026-03-10 10:49:29 +10:00
Julian Sommer ee3df227a7 feat: reboot instances (#68) 2026-03-10 11:48:58 +11:00
Julian Sommer 015dbbc272 feat: frontend performance 2026-03-10 10:31:59 +10:00
284 changed files with 16128 additions and 5890 deletions
+163 -47
View File
@@ -1,4 +1,4 @@
name: Hive E2E Test
name: Hive E2E Tests
on:
push:
@@ -11,70 +11,186 @@ on:
- 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
+44
View File
@@ -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
+3
View File
@@ -42,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
+1
View File
@@ -13,6 +13,7 @@ go.work.sum
# Build outputs
bin/
dist/
tmp/
*.test
*.out
+1 -1257
View File
File diff suppressed because it is too large Load Diff
+35 -32
View File
@@ -55,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)...."
@@ -193,39 +197,38 @@ security-check:
$(_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)
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
distro-clean:
rm -rf dist/
.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 modernize check-modernize vet security-check \
test-docker-build test-docker-single test-docker-multi test-docker
distro distro-clean
+18 -17
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
ARG VERSION=dev
FROM golang:1.26-bookworm AS builder
ARG VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
nbdkit nbdkit-plugin-dev pkg-config gcc make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy module files for dependency caching
COPY 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/
# Generate go.work (gitignored, so not in checkout)
RUN cd hive && go work init . ../predastore ../viperblock ../viperblock/nbd ../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/ /
+9
View File
@@ -0,0 +1,9 @@
/var/log/hive/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
+22
View File
@@ -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
+21
View File
@@ -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
+17
View File
@@ -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
+22
View File
@@ -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
+16
View File
@@ -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
+21
View File
@@ -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
+19
View File
@@ -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
+8
View File
@@ -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
+141 -74
View File
@@ -37,11 +37,13 @@ import (
"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"
)
@@ -194,12 +196,8 @@ func init() {
accountCreateCmd.Flags().String("name", "", "Account name (required)")
accountCreateCmd.MarkFlagRequired("name")
homeDir, _ := os.UserHomeDir()
configDir := filepath.Join(homeDir, "hive", "config")
hiveDir := filepath.Join(homeDir, "hive")
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)")
@@ -257,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")
@@ -564,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...")
@@ -592,15 +584,18 @@ 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()
fmt.Println("\n🔑 Generated root credentials (save these — they won't be shown again):")
fmt.Printf(" Access Key: %s\n", accessKey)
fmt.Printf(" Secret Key: %s\n", secretKey)
fmt.Printf(" Account ID: %s\n", accountID)
// 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()
// Generate IAM master key (AES-256, used to encrypt secrets in NATS KV)
masterKey, err := handlers_iam.GenerateMasterKey()
@@ -608,7 +603,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
os.Exit(1)
}
if err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID); err != nil {
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)
}
@@ -616,17 +612,25 @@ func runAdminInit(cmd *cobra.Command, args []string) {
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)
@@ -689,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)
@@ -710,6 +714,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
Region: region,
NatsToken: natsToken,
DataDir: hiveRoot,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -741,9 +746,9 @@ func runAdminInit(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(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")
@@ -751,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:")
@@ -786,7 +801,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
os.Exit(1)
}
if err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID); err != nil {
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)
}
@@ -806,12 +822,14 @@ 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))
@@ -878,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)
@@ -903,6 +921,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
Region: region,
NatsToken: natsToken,
DataDir: hiveRoot,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -935,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")
@@ -1007,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...")
@@ -1113,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 {
@@ -1155,7 +1164,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "❌ Error decoding master key: %v\n", err)
os.Exit(1)
}
if err := writeBootstrapFiles(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID); err != nil {
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)
}
@@ -1195,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)
@@ -1224,6 +1233,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
Region: creds.Region,
NatsToken: creds.NatsToken,
DataDir: dataDir,
ConfigDir: configDir,
Node: node,
Az: az,
@@ -1257,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")
@@ -1305,26 +1315,26 @@ func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string)
// 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, func(), error) {
func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterConfig, *nats.Conn, func(), error) {
cfg, nc, err := loadConfigAndConnect()
if err != nil {
return nil, nil, nil, fmt.Errorf("connect to cluster: %w", err)
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, fmt.Errorf("load master key: %w", err)
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
}
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey)
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey, len(cfg.Nodes))
if err != nil {
nc.Close()
return nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
}
return svc, cfg, func() { nc.Close() }, nil
return svc, cfg, nc, func() { nc.Close() }, nil
}
// adminAccessPolicyDocument is the AdministratorAccess policy document that
@@ -1334,7 +1344,7 @@ const adminAccessPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect
func runAccountCreate(cmd *cobra.Command, args []string) {
name, _ := cmd.Flags().GetString("name")
svc, cfg, cleanup, err := initIAMServiceFromConfig()
svc, cfg, nc, cleanup, err := initIAMServiceFromConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
@@ -1349,6 +1359,16 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
}
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"),
@@ -1394,7 +1414,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
endpointHost := "localhost"
certPath := filepath.Join(cfg.NodeBaseDir(), "config", "ca.pem")
nodeConfig := cfg.Nodes[cfg.Node]
nodeConfig = cfg.Nodes[cfg.Node]
if h, _, err := net.SplitHostPort(nodeConfig.AWSGW.Host); err == nil {
if h != "" && h != "0.0.0.0" {
endpointHost = h
@@ -1411,8 +1431,12 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
}); 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": "us-east-1",
"region": region,
"endpoint_url": endpointURL,
"ca_bundle": certPath,
"output": "json",
@@ -1433,7 +1457,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
}
func runAccountList(cmd *cobra.Command, args []string) {
svc, _, cleanup, err := initIAMServiceFromConfig()
svc, _, _, cleanup, err := initIAMServiceFromConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
@@ -1462,18 +1486,61 @@ func runAccountList(cmd *cobra.Command, args []string) {
}
}
func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) error {
// 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 root secret: %w", err)
return fmt.Errorf("encrypting system secret: %w", err)
}
return handlers_iam.SaveBootstrapData(
filepath.Join(configDir, "bootstrap.json"),
&handlers_iam.BootstrapData{
AccessKeyID: accessKey, EncryptedSecret: encryptedSecret, AccountID: accountID,
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)
}
+1 -2
View File
@@ -66,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)
+44
View File
@@ -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
}
+57
View File
@@ -0,0 +1,57 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigDir_Dev(t *testing.T) {
// Non-root user without /etc/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")
}
}
+6 -2
View File
@@ -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)
}
}
+55 -19
View File
@@ -148,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)
},
@@ -167,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")
@@ -201,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")
@@ -320,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")
@@ -364,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")
},
}
@@ -382,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")
},
@@ -412,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")
@@ -450,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")
},
}
@@ -468,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")
},
@@ -501,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")
@@ -545,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")
},
}
@@ -563,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")
},
@@ -600,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")
},
}
@@ -618,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")
},
}
@@ -677,7 +707,10 @@ var vpcdStartCmd = &cobra.Command{
return
}
svc.Start()
if _, err = svc.Start(); err != nil {
fmt.Println("Error starting vpcd service:", err)
os.Exit(1)
}
fmt.Println("vpcd service started")
},
}
@@ -694,7 +727,10 @@ var vpcdStopCmd = &cobra.Command{
return
}
svc.Stop()
if err = svc.Stop(); err != nil {
fmt.Println("Error stopping vpcd service:", err)
os.Exit(1)
}
fmt.Println("vpcd service stopped")
},
}
@@ -46,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"
+8 -1
View File
@@ -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"
+27
View File
@@ -0,0 +1,27 @@
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
// Version variables set via ldflags at build time.
// Example: go build -ldflags "-X github.com/mulgadc/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)
}
+29
View File
@@ -0,0 +1,29 @@
package cmd
import (
"testing"
)
func TestVersionVarsExist(t *testing.T) {
// Verify the version variables are set (defaults when not injected via ldflags)
if Version == "" {
t.Error("Version should not be empty")
}
if Commit == "" {
t.Error("Commit should not be empty")
}
}
func TestVersionCommand(t *testing.T) {
// Verify the version command is registered
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Use == "version" {
found = true
break
}
}
if !found {
t.Error("version command not registered on rootCmd")
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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/)
+61
View File
@@ -0,0 +1,61 @@
---
title: "Creating Accounts"
description: "Create and manage isolated user accounts with their own resources."
category: "Administration"
tags:
- accounts
- iam
- admin
resources:
- title: "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
```
+119
View File
@@ -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>
```
+97
View File
@@ -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.
+74
View File
@@ -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.
+132
View File
@@ -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
```
+285
View File
@@ -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.
+198
View File
@@ -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
```
+97
View File
@@ -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.
+102
View File
@@ -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
```
+194
View File
@@ -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
```
+97
View File
@@ -0,0 +1,97 @@
---
title: "VPC Networking"
description: "Create and manage Virtual Private Clouds with subnets, DHCP, and Geneve overlay networking."
category: "Environments"
tags:
- vpc
- networking
- ovn
resources:
- title: "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
```
+72 -16
View File
@@ -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,44 +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 returns the global platform account ID (000000000000).
// This is used during bootstrap (hive admin init) for the root/platform account.
// Customer accounts are created via IAMService.CreateAccount() with sequential IDs.
func GenerateAccountID() string {
// 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
@@ -600,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))
}
@@ -610,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 {
+74 -14
View File
@@ -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,32 +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_ReturnsGlobalID(t *testing.T) {
id := GenerateAccountID()
func TestSystemAccountID(t *testing.T) {
id := SystemAccountID()
assert.Equal(t, "000000000000", id)
assert.Len(t, id, 12)
}
func TestGenerateAccountID_Deterministic(t *testing.T) {
assert.Equal(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 '/'
@@ -62,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 ---
@@ -523,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"`)
@@ -535,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")
}
@@ -543,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")
}
@@ -615,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)
}
+83 -34
View File
@@ -15,6 +15,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync"
@@ -22,6 +23,8 @@ import (
"syscall"
"time"
"net/http"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/go-chi/chi/v5"
@@ -46,7 +49,6 @@ import (
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats.go"
"github.com/pelletier/go-toml/v2"
"net/http"
)
type BlockDeviceMapping struct {
@@ -131,8 +133,9 @@ type Daemon struct {
// NetworkPlumber handles tap device lifecycle for VPC networking
networkPlumber NetworkPlumber
// shuttingDown is set to true during coordinated cluster shutdown (GATE phase).
// When true, the daemon rejects new work and setupShutdown skips VM stops.
// shuttingDown is set to true during coordinated cluster shutdown (GATE phase)
// or during SIGTERM-based shutdown. When true, the daemon rejects new work,
// crash handlers bail out, and setupShutdown skips redundant VM stops.
shuttingDown atomic.Bool
// ready is set to true once NATS connection, JetStream, and all services
@@ -403,6 +406,8 @@ func (d *Daemon) subscribeAll() error {
{"hive.nodes.discover", d.handleNodeDiscover, ""},
{"hive.node.status", d.handleNodeStatus, ""},
{"hive.node.vms", d.handleNodeVMs, ""},
// Account creation → create default VPC for new account
{"iam.account.created", d.handleAccountCreated, "hive-workers"},
// Coordinated cluster shutdown phases (fan-out, no queue group)
{"hive.cluster.shutdown.gate", d.handleShutdownGate, ""},
{"hive.cluster.shutdown.drain", d.handleShutdownDrain, ""},
@@ -524,6 +529,7 @@ func (d *Daemon) Start() error {
}
d.waitForClusterReady()
d.upgradeJetStreamReplicas()
d.restoreInstances()
if err := d.subscribeAll(); err != nil {
@@ -624,17 +630,26 @@ func (d *Daemon) initJetStream() error {
retryDelay = min(retryDelay*2, 10*time.Second)
}
// Upgrade replicas if cluster has more than one node
clusterSize := len(d.clusterConfig.Nodes)
if clusterSize > 1 {
if err := d.jsManager.UpdateReplicas(clusterSize); err != nil {
slog.Warn("Failed to upgrade JetStream replicas on startup (other NATS nodes may not be ready)", "targetReplicas", clusterSize, "error", err)
}
}
// Replica upgrade is deferred to after all services have created their
// KV buckets and the cluster is ready (see upgradeJetStreamReplicas).
return nil
}
// upgradeJetStreamReplicas bumps the replication factor on ALL KV_* streams
// to match the cluster size. This runs after all services have created their
// buckets and after waitForClusterReady so that enough NATS peers are online
// to accept the new replica count.
func (d *Daemon) upgradeJetStreamReplicas() {
clusterSize := len(d.clusterConfig.Nodes)
if clusterSize <= 1 || d.jsManager == nil {
return
}
if err := d.jsManager.UpdateReplicas(clusterSize); err != nil {
slog.Warn("Failed to upgrade JetStream replicas", "targetReplicas", clusterSize, "error", err)
}
}
// initServiceWithRetry initializes a service using the provided init function,
// retrying with exponential backoff (500ms→10s) for up to 5 minutes. During
// cluster restarts, JetStream KV may be temporarily unavailable while NATS
@@ -797,9 +812,12 @@ func (d *Daemon) restoreInstances() {
if instance.Status == vm.StateTerminated {
if !d.migrateTerminatedToKV(instance) {
// KV write failed — still remove from local map. The VM is gone;
// keeping it here would make it retry forever on every restart.
delete(d.Instances.VMS, instance.ID)
// KV write failed — keep in local state so the next restart
// retries the migration. Deleting here would create a "void":
// the instance disappears from both local state and the
// terminated KV, making it invisible to DescribeInstances.
slog.Warn("Terminated instance KV migration failed, will retry on next restart",
"instance", instance.ID)
}
continue
}
@@ -894,6 +912,11 @@ func (d *Daemon) restoreInstances() {
go func(inst *vm.VM) {
defer wg.Done()
defer func() { <-sem }() // release
defer func() {
if r := recover(); r != nil {
slog.Error("Panic during instance recovery", "instanceId", inst.ID, "panic", r, "stack", string(debug.Stack()))
}
}()
slog.Info("Launching instance (recovery)", "instance", inst.ID)
if err := d.LaunchInstance(inst); err != nil {
slog.Error("Failed to launch instance", "instanceId", inst.ID, "err", err)
@@ -1071,7 +1094,9 @@ func (d *Daemon) ClusterManager() error {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("Failed to encode health response", "error", err)
}
})
// Join endpoint - accepts new nodes joining the cluster
@@ -1080,10 +1105,12 @@ func (d *Daemon) ClusterManager() error {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
if err := json.NewEncoder(w).Encode(types.NodeJoinResponse{
Success: false,
Message: "invalid request body",
})
}); err != nil {
slog.Error("Failed to encode join error response", "error", err)
}
return
}
@@ -1093,10 +1120,12 @@ func (d *Daemon) ClusterManager() error {
if req.Node == "" || req.Region == "" || req.AZ == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
if err := json.NewEncoder(w).Encode(types.NodeJoinResponse{
Success: false,
Message: "node, region, and az are required",
})
}); err != nil {
slog.Error("Failed to encode join validation response", "error", err)
}
return
}
@@ -1104,10 +1133,12 @@ func (d *Daemon) ClusterManager() error {
if _, exists := d.clusterConfig.Nodes[req.Node]; exists {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(409)
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
if err := json.NewEncoder(w).Encode(types.NodeJoinResponse{
Success: false,
Message: fmt.Sprintf("node %s already exists in cluster", req.Node),
})
}); err != nil {
slog.Error("Failed to encode join conflict response", "error", err)
}
return
}
@@ -1137,10 +1168,12 @@ func (d *Daemon) ClusterManager() error {
slog.Error("Failed to save cluster config", "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
if err := json.NewEncoder(w).Encode(types.NodeJoinResponse{
Success: false,
Message: "failed to save cluster config",
})
}); err != nil {
slog.Error("Failed to encode join save-error response", "error", err)
}
return
}
@@ -1193,7 +1226,7 @@ func (d *Daemon) ClusterManager() error {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
if err := json.NewEncoder(w).Encode(types.NodeJoinResponse{
Success: true,
Message: fmt.Sprintf("node %s successfully joined cluster", req.Node),
SharedData: sharedData,
@@ -1202,7 +1235,9 @@ func (d *Daemon) ClusterManager() error {
CACert: caCert,
CAKey: caKey,
PredastoreConfig: predastoreConfig,
})
}); err != nil {
slog.Error("Failed to encode join success response", "error", err)
}
})
// Get cluster config endpoint
@@ -1210,10 +1245,12 @@ func (d *Daemon) ClusterManager() error {
configHash, _ := d.computeConfigHash()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
if err := json.NewEncoder(w).Encode(map[string]any{
"config": d.clusterConfig,
"config_hash": configHash,
})
}); err != nil {
slog.Error("Failed to encode config response", "error", err)
}
})
// Start HTTP server in goroutine
@@ -1273,6 +1310,12 @@ func (d *Daemon) SendQMPCommand(q *qmp.QMPClient, cmd qmp.QMPCommand, instanceId
q.Mu.Lock()
defer q.Mu.Unlock()
// Set a read deadline so we don't block forever on a hung QEMU process
if err := q.Conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
return nil, fmt.Errorf("set read deadline: %w", err)
}
defer q.Conn.SetReadDeadline(time.Time{}) // clear deadline after command
if err := q.Encoder.Encode(cmd); err != nil {
return nil, fmt.Errorf("encode error: %w", err)
}
@@ -1288,6 +1331,10 @@ func (d *Daemon) SendQMPCommand(q *qmp.QMPClient, cmd qmp.QMPCommand, instanceId
// by the command handlers that initiate the action, avoiding races
// between event-driven and command-driven transitions.
slog.Info("QMP event", "event", msg["event"], "instanceId", instanceId)
// Extend deadline after receiving an event (QEMU is alive, just chatty)
if err := q.Conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
return nil, fmt.Errorf("set read deadline: %w", err)
}
continue
}
if errObj, ok := msg["error"].(map[string]any); ok {
@@ -1478,10 +1525,13 @@ func (d *Daemon) setupShutdown() {
// Cancel context to stop heartbeat and other goroutines
d.cancel()
// If coordinated shutdown already handled VMs (DRAIN phase), skip stopInstance
// If coordinated shutdown already handled VMs (DRAIN phase), skip stopInstance.
// Otherwise, set the flag now so crash handlers and restart schedulers
// know to bail out during SIGTERM-based shutdown.
if d.shuttingDown.Load() {
slog.Info("Coordinated shutdown in progress, skipping VM stop (already handled by DRAIN phase)")
} else {
d.shuttingDown.Store(true)
// Pass instances to terminate
if err := d.stopInstance(d.Instances.VMS, false); err != nil {
slog.Error("Failed to stop instances during shutdown", "err", err)
@@ -1538,6 +1588,10 @@ func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
// Send qmp_capabilities handshake to init
_, err = d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "qmp_capabilities"}, instance.ID)
if err != nil {
slog.Error("Failed QMP capabilities handshake", "err", err)
return err
}
// Simple heartbeat to confirm QMP and the instance is running / healthy
go func() {
@@ -1574,11 +1628,6 @@ func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
}
}()
if err != nil {
slog.Error("Failed to create QMP client", "err", err)
return err
}
return nil
}
@@ -2043,6 +2092,8 @@ func (d *Daemon) ebsTopic(action string) string {
func (d *Daemon) MountVolumes(instance *vm.VM) error {
instance.EBSRequests.Mu.Lock()
defer instance.EBSRequests.Mu.Unlock()
for k, v := range instance.EBSRequests.Requests {
// Send the volume payload as JSON
@@ -2086,8 +2137,6 @@ func (d *Daemon) MountVolumes(instance *vm.VM) error {
}
instance.EBSRequests.Mu.Unlock()
return nil
}
+2
View File
@@ -88,6 +88,8 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
d.handleDetachVolume(msg, command, instance)
case command.Attributes.StartInstance:
d.handleStartInstance(msg, command, instance)
case command.Attributes.RebootInstance:
d.handleRebootInstance(msg, command, instance)
case command.Attributes.StopInstance, command.Attributes.TerminateInstance:
d.handleStopOrTerminateInstance(msg, command, instance)
default:
+61 -10
View File
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"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"
@@ -264,6 +265,12 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
// Launch all instances (volumes and VMs)
var successCount int
for _, instance := range instances {
// Pre-compute dev MAC so cloud-init can generate per-interface netplan
// that suppresses the default route on the dev/hostfwd NIC.
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
instance.DevMAC = generateDevMAC(instance.ID)
}
// Prepare the root volume, cloud-init, EFI drives via NBD (AMI clone to new volume)
volumeInfos, err := d.instanceService.GenerateVolumes(runInstancesInput, instance)
if err != nil {
@@ -305,6 +312,33 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
slog.Info("handleEC2RunInstances completed", "requested", launchCount, "created", len(instances), "launched", successCount)
}
func (d *Daemon) handleRebootInstance(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
slog.Info("Rebooting instance", "id", command.ID)
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StateRunning {
slog.Error("RebootInstance: instance not in running state", "instanceId", command.ID, "status", status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
_, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "system_reset"}, command.ID)
if err != nil {
slog.Error("RebootInstance: QMP system_reset failed", "instanceId", command.ID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
slog.Info("Instance rebooted", "instanceId", command.ID)
if err := msg.Respond([]byte(`{}`)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
func (d *Daemon) handleStartInstance(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
slog.Info("Starting instance", "id", command.ID)
@@ -420,11 +454,12 @@ func (d *Daemon) handleStopOrTerminateInstance(msg *nats.Msg, command types.EC2I
if d.jsManager != nil {
if isTerminate {
// Write to terminated KV bucket (auto-expires after 1 hour via TTL).
// On failure, continue cleanup — VM is already stopped, keeping it
// in the local map just delays data loss to the next restart.
// If this fails, keep the instance in local state so DescribeInstances
// still sees it and restoreInstances can retry the KV migration.
if err := d.jsManager.WriteTerminatedInstance(inst.ID, inst); err != nil {
slog.Error("Failed to write terminated instance to KV, instance will not appear in DescribeInstances",
slog.Error("Failed to write terminated instance to KV, keeping in local state for retry",
"instanceId", inst.ID, "err", err)
return
}
} else {
// Write to shared KV first — if daemon crashes after this but
@@ -436,7 +471,25 @@ func (d *Daemon) handleStopOrTerminateInstance(msg *nats.Msg, command types.EC2I
}
}
// Unsubscribe from per-instance NATS topic
// Guard + delete must be atomic under the same lock hold.
// A concurrent ec2.start handler may have loaded the instance
// from stopped KV, re-added it to VMS with a new pointer, and
// launched it. Deleting here would destroy the running instance's
// state — creating a "ghost instance" visible nowhere.
d.Instances.Mu.Lock()
current, exists := d.Instances.VMS[inst.ID]
if !exists || current != inst {
d.Instances.Mu.Unlock()
slog.Info("Instance was reclaimed by another handler, skipping local cleanup",
"instanceId", inst.ID, "state", string(finalState))
return
}
delete(d.Instances.VMS, inst.ID)
d.Instances.Mu.Unlock()
// Unsubscribe from per-instance NATS topic. Safe to do after
// the delete — LaunchInstance already unsubscribes stale entries
// before creating new ones (daemon.go:1658-1664).
d.mu.Lock()
if sub, ok := d.natsSubscriptions[inst.ID]; ok {
if err := sub.Unsubscribe(); err != nil {
@@ -446,17 +499,15 @@ func (d *Daemon) handleStopOrTerminateInstance(msg *nats.Msg, command types.EC2I
}
d.mu.Unlock()
// Remove from local instance map
d.Instances.Mu.Lock()
delete(d.Instances.VMS, inst.ID)
d.Instances.Mu.Unlock()
// Persist local state without the instance
if err := d.WriteState(); err != nil {
slog.Error("Failed to persist state after releasing instance, re-adding to local map for consistency",
"instanceId", inst.ID, "err", err)
// Only re-add if another handler hasn't claimed the slot
d.Instances.Mu.Lock()
d.Instances.VMS[inst.ID] = inst
if _, occupied := d.Instances.VMS[inst.ID]; !occupied {
d.Instances.VMS[inst.ID] = inst
}
d.Instances.Mu.Unlock()
} else {
slog.Info("Released instance ownership to KV",
+136
View File
@@ -623,6 +623,142 @@ func TestHandleEC2Events_TerminateInstance(t *testing.T) {
assert.Equal(t, vm.StateShuttingDown, status)
}
func TestHandleEC2Events_RebootRunningInstance(t *testing.T) {
natsURL := sharedNATSURL
daemon := createTestDaemon(t, natsURL)
instanceID := "i-test-reboot-001"
daemon.Instances.VMS[instanceID] = &vm.VM{
ID: instanceID,
InstanceType: getTestInstanceType(),
Status: vm.StateRunning,
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
AccountID: testAccountID,
}
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
cmd := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{RebootInstance: true},
}
cmdData, _ := json.Marshal(cmd)
reply, err := natsRequest(daemon.natsConn,
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
require.NotNil(t, reply)
// With nil QMPClient encoder/decoder, SendQMPCommand returns error,
// so we expect an error response (ServerInternal).
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
// Instance should remain in running state (reboot doesn't change state)
daemon.Instances.Mu.Lock()
status := daemon.Instances.VMS[instanceID].Status
daemon.Instances.Mu.Unlock()
assert.Equal(t, vm.StateRunning, status)
}
func TestHandleEC2Events_RebootStoppedInstance(t *testing.T) {
natsURL := sharedNATSURL
daemon := createTestDaemon(t, natsURL)
instanceID := "i-test-reboot-stopped"
daemon.Instances.VMS[instanceID] = &vm.VM{
ID: instanceID,
InstanceType: getTestInstanceType(),
Status: vm.StateStopped,
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
AccountID: testAccountID,
}
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
cmd := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{RebootInstance: true},
}
cmdData, _ := json.Marshal(cmd)
reply, err := natsRequest(daemon.natsConn,
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
require.NotNil(t, reply)
// Should get IncorrectInstanceState error
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
func TestHandleEC2Events_RebootTerminatedInstance(t *testing.T) {
natsURL := sharedNATSURL
daemon := createTestDaemon(t, natsURL)
instanceID := "i-test-reboot-terminated"
daemon.Instances.VMS[instanceID] = &vm.VM{
ID: instanceID,
InstanceType: getTestInstanceType(),
Status: vm.StateTerminated,
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
AccountID: testAccountID,
}
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
cmd := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{RebootInstance: true},
}
cmdData, _ := json.Marshal(cmd)
reply, err := natsRequest(daemon.natsConn,
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
require.NotNil(t, reply)
// Should get IncorrectInstanceState error
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
func TestHandleEC2Events_InstanceNotFound(t *testing.T) {
natsURL := sharedNATSURL
+22
View File
@@ -1,6 +1,9 @@
package daemon
import (
"encoding/json"
"log/slog"
"github.com/nats-io/nats.go"
)
@@ -39,3 +42,22 @@ func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
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)
}
}
+42 -53
View File
@@ -3,6 +3,7 @@ package daemon
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
@@ -301,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
}
@@ -341,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
}
@@ -472,66 +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++
}
// Also update the terminated-instances bucket if it exists
if m.terminatedKV != nil {
terminatedStreamName := "KV_" + TerminatedInstanceBucket
terminatedInfo, err := m.js.StreamInfo(terminatedStreamName)
if err == nil && terminatedInfo.Config.Replicas < newReplicas {
terminatedInfo.Config.Replicas = newReplicas
if _, err := m.js.UpdateStream(&terminatedInfo.Config); err != nil {
slog.Warn("Failed to update terminated-instances bucket replicas", "error", err)
} else {
slog.Info("Updated terminated-instances bucket replicas", "bucket", TerminatedInstanceBucket, "newReplicas", newReplicas)
}
}
if updated > 0 {
slog.Info("KV replication update complete", "bucketsUpdated", updated, "replicas", newReplicas)
}
return nil
+7 -1
View File
@@ -53,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
}
@@ -105,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
}
@@ -162,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
}
@@ -196,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
}
@@ -226,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
}
@@ -290,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)
}
}
+4
View File
@@ -56,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.
+2 -2
View File
@@ -93,8 +93,8 @@ func (m *mockIAMService) GetUserPolicies(_, _ string) ([]handlers_iam.PolicyDocu
func (m *mockIAMService) DecryptSecret(ciphertext string) (string, error) {
return handlers_iam.DecryptSecret(ciphertext, m.masterKey)
}
func (m *mockIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil }
func (m *mockIAMService) IsEmpty() (bool, error) { return true, nil }
func (m *mockIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil }
func (m *mockIAMService) IsEmpty() (bool, error) { return true, nil }
func (m *mockIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) {
return nil, nil
}
+8 -1
View File
@@ -65,6 +65,9 @@ var ec2Actions = map[string]EC2Handler{
"StopInstances": ec2Handler(func(input *ec2.StopInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_instance.StopInstances(input, gw.NATSConn, accountID)
}),
"RebootInstances": ec2Handler(func(input *ec2.RebootInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_instance.RebootInstances(input, gw.NATSConn, accountID)
}),
"TerminateInstances": ec2Handler(func(input *ec2.TerminateInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_instance.TerminateInstances(input, gw.NATSConn, accountID)
}),
@@ -231,7 +234,11 @@ var ec2LocalActions = map[string]bool{
}
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
body, _ := io.ReadAll(r.Body)
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"]
@@ -0,0 +1,79 @@
package gateway_ec2_instance
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/types"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
// RebootInstances sends reboot commands to specified instances via NATS.
// Unlike stop+start, reboot keeps the instance running and sends a QMP system_reset.
// Returns an empty response on success (AWS returns no state-change data).
func RebootInstances(input *ec2.RebootInstancesInput, natsConn *nats.Conn, accountID string) (*ec2.RebootInstancesOutput, error) {
if len(input.InstanceIds) == 0 {
return nil, fmt.Errorf("no instance IDs provided")
}
slog.Info("RebootInstances: Processing request", "instance_count", len(input.InstanceIds))
for _, instanceIDPtr := range input.InstanceIds {
if instanceIDPtr == nil {
continue
}
instanceID := *instanceIDPtr
command := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{
RebootInstance: true,
},
}
jsonData, err := json.Marshal(command)
if err != nil {
slog.Error("RebootInstances: Failed to marshal command", "instance_id", instanceID, "err", err)
continue
}
subject := fmt.Sprintf("ec2.cmd.%s", instanceID)
reqMsg := nats.NewMsg(subject)
reqMsg.Data = jsonData
reqMsg.Header.Set(utils.AccountIDHeader, accountID)
msg, err := natsConn.RequestMsg(reqMsg, 5*time.Second)
if err != nil {
slog.Error("RebootInstances: Failed to send command", "instance_id", instanceID, "err", err)
// NATS timeout means no daemon has a subscription for this instance.
// Check if the instance exists in the stopped-instances KV bucket —
// if so, return IncorrectInstanceState instead of NotFound.
describeInput := &ec2.DescribeInstancesInput{
InstanceIds: []*string{&instanceID},
}
describeData, _ := json.Marshal(describeInput)
if reservations := queryInstanceBucket(natsConn, "ec2.DescribeStoppedInstances", describeData, accountID); len(reservations) > 0 {
return nil, errors.New(awserrors.ErrorIncorrectInstanceState)
}
return nil, errors.New(awserrors.ErrorInvalidInstanceIDNotFound)
}
// Check if the daemon returned an error response (e.g. ownership check failure)
if responseError, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil {
slog.Error("RebootInstances: Daemon returned error", "instance_id", instanceID, "code", *responseError.Code)
return nil, errors.New(*responseError.Code)
}
slog.Info("RebootInstances: Command sent successfully", "instance_id", instanceID)
}
slog.Info("RebootInstances: Completed", "total_instances", len(input.InstanceIds))
return &ec2.RebootInstancesOutput{}, nil
}
@@ -0,0 +1,130 @@
package gateway_ec2_instance
import (
"encoding/json"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/types"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRebootInstances_Success(t *testing.T) {
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) {
var cmd types.EC2InstanceCommand
err := json.Unmarshal(msg.Data, &cmd)
require.NoError(t, err)
assert.Equal(t, instanceID, cmd.ID)
assert.True(t, cmd.Attributes.RebootInstance)
msg.Respond([]byte(`{}`))
})
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(instanceID)},
}
output, err := RebootInstances(input, nc, "123456789012")
require.NoError(t, err)
require.NotNil(t, output)
}
func TestRebootInstances_MultipleInstances(t *testing.T) {
_, nc := startTestNATSServer(t)
ids := []string{"i-001", "i-002"}
for _, id := range ids {
nc.Subscribe("ec2.cmd."+id, func(msg *nats.Msg) {
msg.Respond([]byte(`{}`))
})
}
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(ids[0]), aws.String(ids[1])},
}
output, err := RebootInstances(input, nc, "123456789012")
require.NoError(t, err)
require.NotNil(t, output)
}
func TestRebootInstances_EmptyInstanceIds(t *testing.T) {
_, nc := startTestNATSServer(t)
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{},
}
_, err := RebootInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Contains(t, err.Error(), "no instance IDs provided")
}
func TestRebootInstances_NATSRequestFails(t *testing.T) {
_, nc := startTestNATSServer(t)
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String("i-nosubscriber")},
}
_, err := RebootInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
}
func TestRebootInstances_StoppedInstance(t *testing.T) {
_, nc := startTestNATSServer(t)
instanceID := "i-stopped"
// No ec2.cmd subscription — simulates a stopped instance with no daemon listener.
// Subscribe to the stopped-instances describe topic to return the instance.
nc.Subscribe("ec2.DescribeStoppedInstances", func(msg *nats.Msg) {
resp := ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
{Instances: []*ec2.Instance{{InstanceId: aws.String(instanceID)}}},
},
}
data, _ := json.Marshal(resp)
msg.Respond(data)
})
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(instanceID)},
}
_, err := RebootInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIncorrectInstanceState, err.Error())
}
func TestRebootInstances_DaemonError(t *testing.T) {
_, nc := startTestNATSServer(t)
instanceID := "i-error"
nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) {
msg.Respond(utils.GenerateErrorPayload(awserrors.ErrorIncorrectInstanceState))
})
input := &ec2.RebootInstancesInput{
InstanceIds: []*string{aws.String(instanceID)},
}
_, err := RebootInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIncorrectInstanceState, err.Error())
}
@@ -30,6 +30,10 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.MaxCount == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if *input.MaxCount == 0 {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
@@ -51,12 +51,26 @@ func TerminateInstances(input *ec2.TerminateInstancesInput, natsConn *nats.Conn,
continue
}
// Send NATS request to the specific instance topic with account ID header
// Send NATS request to the specific instance topic with account ID header.
// Retry briefly on ErrNoResponders — after a cluster restart the
// per-instance NATS subscription may not have propagated to all
// servers yet.
subject := fmt.Sprintf("ec2.cmd.%s", instanceID)
reqMsg := nats.NewMsg(subject)
reqMsg.Data = jsonData
reqMsg.Header.Set(utils.AccountIDHeader, accountID)
msg, err := natsConn.RequestMsg(reqMsg, 5*time.Second)
var msg *nats.Msg
for attempt := range 3 {
reqMsg := nats.NewMsg(subject)
reqMsg.Data = jsonData
reqMsg.Header.Set(utils.AccountIDHeader, accountID)
msg, err = natsConn.RequestMsg(reqMsg, 5*time.Second)
if err == nil || !errors.Is(err, nats.ErrNoResponders) {
break
}
if attempt < 2 {
slog.Debug("TerminateInstances: No responder on per-instance topic, retrying",
"instance_id", instanceID, "attempt", attempt+1)
time.Sleep(time.Duration(attempt+1) * time.Second)
}
}
if err != nil {
// If no daemon owns this instance, try the ec2.terminate topic for stopped instances
if errors.Is(err, nats.ErrNoResponders) {
@@ -88,8 +102,7 @@ func TerminateInstances(input *ec2.TerminateInstancesInput, natsConn *nats.Conn,
}
slog.Error("TerminateInstances: Failed to send command", "instance_id", instanceID, "err", err)
stateChanges = append(stateChanges, newStateChange(instanceID, 16, "running", 16, "running"))
continue
return nil, fmt.Errorf("failed to terminate instance %s: %w", instanceID, err)
}
// Check if the daemon returned an error response (e.g. ownership check failure)
@@ -114,18 +114,11 @@ func TestTerminateInstances_NATSRequestFails(t *testing.T) {
InstanceIds: []*string{aws.String(instanceID)},
}
output, err := TerminateInstances(input, nc, "123456789012")
require.NoError(t, err)
require.Len(t, output.TerminatingInstances, 1)
// On NATS failure, state should reflect "still running"
sc := output.TerminatingInstances[0]
assert.Equal(t, instanceID, *sc.InstanceId)
assert.Equal(t, int64(16), *sc.CurrentState.Code)
assert.Equal(t, "running", *sc.CurrentState.Name)
assert.Equal(t, int64(16), *sc.PreviousState.Code)
assert.Equal(t, "running", *sc.PreviousState.Name)
// When no subscriber exists and all fallback paths fail, an error is
// returned so the caller knows the terminate did not succeed.
_, err := TerminateInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Contains(t, err.Error(), instanceID)
}
func TestTerminateInstances_MixedSuccessAndFailure(t *testing.T) {
@@ -142,18 +135,12 @@ func TestTerminateInstances_MixedSuccessAndFailure(t *testing.T) {
InstanceIds: []*string{aws.String(goodID), aws.String(badID)},
}
output, err := TerminateInstances(input, nc, "123456789012")
require.NoError(t, err)
require.Len(t, output.TerminatingInstances, 2)
// First: success → shutting-down
assert.Equal(t, goodID, *output.TerminatingInstances[0].InstanceId)
assert.Equal(t, "shutting-down", *output.TerminatingInstances[0].CurrentState.Name)
// Second: failure → still running
assert.Equal(t, badID, *output.TerminatingInstances[1].InstanceId)
assert.Equal(t, "running", *output.TerminatingInstances[1].CurrentState.Name)
// When a running instance cannot be reached (ErrNoResponders exhausted after
// retries), TerminateInstances returns an error rather than silently
// reporting the instance as "running".
_, err := TerminateInstances(input, nc, "123456789012")
require.Error(t, err)
assert.Contains(t, err.Error(), badID)
}
func TestTerminateInstances_VerifiesQMPAttributes(t *testing.T) {
+70 -3
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -106,10 +107,55 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
return r
}
// corsMiddleware adds CORS headers for browser requests from the dev UI.
// corsAllowedOrigins builds the set of allowed CORS origins from localhost and
// all local non-loopback IPs on the hive-ui port (default 3000). This allows
// the UI to be accessed from any local address, not just localhost.
func corsAllowedOrigins() map[string]struct{} {
origins := map[string]struct{}{
"https://localhost:3000": {},
}
ifaces, err := net.Interfaces()
if err != nil {
return origins
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
continue
}
origins[fmt.Sprintf("https://%s:3000", ip.String())] = struct{}{}
}
}
return origins
}
// corsMiddleware adds CORS headers for browser requests from the hive-ui.
func corsMiddleware(next http.Handler) http.Handler {
allowed := corsAllowedOrigins()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000")
origin := r.Header.Get("Origin")
if origin == "" {
origin = "https://localhost:3000"
}
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else {
w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000")
}
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -169,6 +215,14 @@ func (gw *GatewayConfig) GetService(r *http.Request) (string, error) {
return svc, nil
}
// isNATSTransient reports whether err represents a transient NATS/JetStream
// failure that may resolve after cluster leader election completes.
func isNATSTransient(err error) bool {
return err != nil && (errors.Is(err, nats.ErrNoResponders) ||
errors.Is(err, nats.ErrTimeout) ||
errors.Is(err, nats.ErrNoStreamResponse))
}
// checkPolicy evaluates IAM policies for the current request. Returns nil
// if access is allowed, or an ErrorAccessDenied error if denied.
// Root users bypass evaluation entirely. If the IAM service is unavailable,
@@ -204,7 +258,20 @@ func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) er
// Resolve the IAM action string (e.g. "ec2:RunInstances")
iamAction := policy.IAMAction(service, action)
policies, err := gw.IAMService.GetUserPolicies(accountID, identity)
// Retry on transient NATS errors (e.g. during node failure / leader election).
var policies []handlers_iam.PolicyDocument
var err error
for attempt := range 3 {
policies, err = gw.IAMService.GetUserPolicies(accountID, identity)
if err == nil || !isNATSTransient(err) {
break
}
if attempt < 2 {
slog.Debug("checkPolicy: transient NATS error, retrying",
"user", identity, "attempt", attempt+1, "err", err)
time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)
}
}
if err != nil {
slog.Error("checkPolicy: failed to get user policies", "user", identity, "err", err)
return errors.New(awserrors.ErrorInternalError)
+113 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -825,6 +826,80 @@ func TestCheckPolicy_MissingAccountID(t *testing.T) {
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestCheckPolicy_NATSTransientRetriesAllAttempts(t *testing.T) {
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
calls++
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
},
}
gw := &GatewayConfig{DisableLogging: true, IAMService: mock}
req := httptest.NewRequest("POST", "/", nil)
ctx := context.WithValue(req.Context(), ctxIdentity, "alice")
ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
assert.Equal(t, 3, calls, "should have retried all 3 attempts")
}
func TestCheckPolicy_NATSTransientRetriesThenSucceeds(t *testing.T) {
calls := 0
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
calls++
if calls < 3 {
return nil, fmt.Errorf("get user: %w", nats.ErrNoResponders)
}
return []handlers_iam.PolicyDocument{
{
Version: "2012-10-17",
Statement: []handlers_iam.Statement{
{Effect: "Allow", Action: handlers_iam.StringOrArr{"ec2:*"}, Resource: handlers_iam.StringOrArr{"*"}},
},
},
}, nil
},
}
gw := &GatewayConfig{DisableLogging: true, IAMService: mock}
req := httptest.NewRequest("POST", "/", nil)
ctx := context.WithValue(req.Context(), ctxIdentity, "alice")
ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
assert.NoError(t, err)
assert.Equal(t, 3, calls, "should have retried until success")
}
func TestCheckPolicy_NonTransientErrorStillFails(t *testing.T) {
mock := &policyMockIAMService{
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
return nil, errors.New("database corruption")
},
}
gw := &GatewayConfig{DisableLogging: true, IAMService: mock}
req := httptest.NewRequest("POST", "/", nil)
ctx := context.WithValue(req.Context(), ctxIdentity, "alice")
ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
req = req.WithContext(ctx)
err := gw.checkPolicy(req, "ec2", "DescribeInstances")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
func TestIsNATSTransient(t *testing.T) {
assert.False(t, isNATSTransient(nil))
assert.True(t, isNATSTransient(nats.ErrNoResponders))
assert.True(t, isNATSTransient(nats.ErrTimeout))
assert.True(t, isNATSTransient(fmt.Errorf("get user: %w", nats.ErrNoResponders)))
assert.False(t, isNATSTransient(errors.New("some other error")))
}
func TestCorsMiddleware_GETRequest(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -860,6 +935,43 @@ func TestCorsMiddleware_OPTIONSRequest(t *testing.T) {
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
}
func TestCorsMiddleware_LocalIPOrigin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
handler := corsMiddleware(next)
// Request from a local IP should be reflected
allowed := corsAllowedOrigins()
for origin := range allowed {
if origin == "https://localhost:3000" {
continue
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Origin", origin)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin"))
return // test one IP is enough
}
}
func TestCorsMiddleware_UnknownOrigin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
})
handler := corsMiddleware(next)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Origin", "https://evil.example.com")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Unknown origins fall back to localhost
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
}
func TestSlogRequestLogger_CallsNext(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -891,7 +1003,7 @@ func TestSlogRequestLogger_CapturesStatusCode(t *testing.T) {
func TestEC2ActionMapCompleteness(t *testing.T) {
expectedActions := []string{
"DescribeInstances", "RunInstances", "StartInstances", "StopInstances",
"TerminateInstances", "DescribeInstanceTypes", "GetConsoleOutput",
"TerminateInstances", "RebootInstances", "DescribeInstanceTypes", "GetConsoleOutput",
"ModifyInstanceAttribute",
"CreateKeyPair", "DeleteKeyPair", "DescribeKeyPairs", "ImportKeyPair",
"DescribeImages", "CreateImage",
+5 -1
View File
@@ -94,7 +94,11 @@ var iamActions = map[string]IAMHandler{
}
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
body, _ := io.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("Failed to read IAM request body", "error", err)
return errors.New(awserrors.ErrorInternalError)
}
queryArgs := ParseAWSQueryArgs(string(body))
action := queryArgs["Action"]
+3 -3
View File
@@ -87,9 +87,9 @@ func (s *stubIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, err
return nil, nil
}
func (s *stubIAMService) DecryptSecret(_ string) (string, error) { return "", nil }
func (s *stubIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil }
func (s *stubIAMService) IsEmpty() (bool, error) { return true, nil }
func (s *stubIAMService) DecryptSecret(_ string) (string, error) { return "", nil }
func (s *stubIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil }
func (s *stubIAMService) IsEmpty() (bool, error) { return true, nil }
func (s *stubIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) {
return nil, nil
+3 -3
View File
@@ -124,9 +124,9 @@ func (m *flexMockIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey,
return nil, errors.New("not implemented")
}
func (m *flexMockIAMService) DecryptSecret(_ string) (string, error) { return "", nil }
func (m *flexMockIAMService) SeedRootUser(_ *handlers_iam.BootstrapData) error { return nil }
func (m *flexMockIAMService) IsEmpty() (bool, error) { return true, nil }
func (m *flexMockIAMService) DecryptSecret(_ string) (string, error) { return "", nil }
func (m *flexMockIAMService) SeedBootstrap(_ *handlers_iam.BootstrapData) error { return nil }
func (m *flexMockIAMService) IsEmpty() (bool, error) { return true, nil }
func (m *flexMockIAMService) CreateAccount(_ string) (*handlers_iam.Account, error) {
return nil, nil
+44 -12
View File
@@ -64,11 +64,9 @@ instance-id: {{.InstanceID}}
local-hostname: {{.Hostname}}
`
// cloudInitNetworkConfig enables DHCP on all NICs. Without this, Ubuntu cloud
// images only configure the first NIC and the guest may have no IPv4 connectivity.
// This is essential for DEV_NETWORKING dual-NIC setups (TAP + hostfwd) and
// also ensures OVN DHCP works reliably on the TAP interface.
const cloudInitNetworkConfig = `network:
// cloudInitNetworkConfigWildcard enables DHCP on all NICs via wildcard match.
// Used when there's no dual-NIC setup (non-VPC or VPC without DEV_NETWORKING).
const cloudInitNetworkConfigWildcard = `network:
version: 2
ethernets:
allnics:
@@ -78,6 +76,38 @@ const cloudInitNetworkConfig = `network:
dhcp-identifier: mac
`
// generateNetworkConfig produces the cloud-init network-config for the instance.
//
// When both eniMAC and devMAC are provided (VPC + DEV_NETWORKING), it generates
// per-interface config that suppresses the default route on the dev/hostfwd NIC.
// Without this, both NICs get DHCP default routes at equal metric, causing
// nondeterministic routing — SSH via hostfwd times out and VPC traffic leaks.
//
// The dev NIC still gets an IP via DHCP (needed for hostfwd port forwarding)
// but dhcp4-overrides prevents it from installing routes or DNS.
func generateNetworkConfig(eniMAC, devMAC string) string {
if eniMAC == "" || devMAC == "" {
return cloudInitNetworkConfigWildcard
}
return fmt.Sprintf(`network:
version: 2
ethernets:
vpc0:
match:
macaddress: "%s"
dhcp4: true
dhcp-identifier: mac
dev0:
match:
macaddress: "%s"
dhcp4: true
dhcp-identifier: mac
dhcp4-overrides:
use-routes: false
use-dns: false
`, eniMAC, devMAC)
}
type CloudInitData struct {
Username string
SSHKey string
@@ -527,7 +557,7 @@ func (s *InstanceServiceImpl) prepareCloudInitVolume(input *ec2.RunInstancesInpu
}
// Create the cloud-init ISO
err = s.createCloudInitISO(input, instance.ID, instance.AccountID, cloudInitVb)
err = s.createCloudInitISO(input, instance, cloudInitVb)
if err != nil {
return err
}
@@ -554,7 +584,7 @@ func (s *InstanceServiceImpl) prepareCloudInitVolume(input *ec2.RunInstancesInpu
}
// createCloudInitISO generates the cloud-init ISO image
func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, instanceId string, accountID string, cloudInitVb *viperblock.VB) error {
func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, instance *vm.VM, cloudInitVb *viperblock.VB) error {
// Create ISO writer
writer, err := iso9660.NewWriter()
if err != nil {
@@ -564,7 +594,7 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
defer writer.Cleanup()
// Generate instance metadata
hostname := generateHostname(instanceId)
hostname := generateHostname(instance.ID)
// Retrieve SSH pubkey from S3
keyName := ""
@@ -572,7 +602,7 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
keyName = *input.KeyName
}
keyPath := fmt.Sprintf("keys/%s/%s", accountID, keyName)
keyPath := fmt.Sprintf("keys/%s/%s", instance.AccountID, keyName)
result, err := s.objectStore.GetObject(&awss3.GetObjectInput{
Bucket: aws.String(s.config.Predastore.Bucket),
Key: aws.String(keyPath),
@@ -615,7 +645,7 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
// Add meta-data
metaData := CloudInitMetaData{
InstanceID: instanceId,
InstanceID: instance.ID,
Hostname: hostname,
}
@@ -633,8 +663,10 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
return errors.New(awserrors.ErrorServerInternal)
}
// Add network-config (enables DHCP on all NICs)
err = writer.AddFile(strings.NewReader(cloudInitNetworkConfig), "network-config")
// Add network-config: per-interface when VPC+dev (suppresses dev default route),
// wildcard DHCP otherwise.
networkConfig := generateNetworkConfig(instance.ENIMac, instance.DevMAC)
err = writer.AddFile(strings.NewReader(networkConfig), "network-config")
if err != nil {
slog.Error("failed to add network-config file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
@@ -394,10 +394,48 @@ func TestMockInstanceService(t *testing.T) {
assert.Equal(t, "subnet-abc123", *inst.SubnetId)
}
func TestCloudInitNetworkConfig(t *testing.T) {
assert.Contains(t, cloudInitNetworkConfig, "version: 2")
assert.Contains(t, cloudInitNetworkConfig, "dhcp4: true")
assert.Contains(t, cloudInitNetworkConfig, "dhcp-identifier: mac")
func TestCloudInitNetworkConfigWildcard(t *testing.T) {
// No MACs → wildcard config (non-VPC or VPC without DEV_NETWORKING)
cfg := generateNetworkConfig("", "")
assert.Contains(t, cfg, "version: 2")
assert.Contains(t, cfg, "dhcp4: true")
assert.Contains(t, cfg, "dhcp-identifier: mac")
assert.Contains(t, cfg, `name: "en*"`, "wildcard should match all en* interfaces")
assert.NotContains(t, cfg, "use-routes")
}
func TestCloudInitNetworkConfigDualNIC(t *testing.T) {
eniMAC := "02:00:00:61:ef:c2"
devMAC := "02:de:00:60:83:0d"
cfg := generateNetworkConfig(eniMAC, devMAC)
// Both MACs present in config
assert.Contains(t, cfg, eniMAC)
assert.Contains(t, cfg, devMAC)
// VPC NIC gets normal DHCP (with default route)
assert.Contains(t, cfg, "vpc0:")
assert.Contains(t, cfg, "dev0:")
// Dev NIC has route/DNS suppressed
assert.Contains(t, cfg, "use-routes: false")
assert.Contains(t, cfg, "use-dns: false")
// No wildcard match — per-interface only
assert.NotContains(t, cfg, `name: "en*"`)
}
func TestCloudInitNetworkConfigPartialMAC(t *testing.T) {
// Only ENI MAC (VPC without dev) → wildcard
cfg := generateNetworkConfig("02:00:00:61:ef:c2", "")
assert.Contains(t, cfg, `name: "en*"`)
assert.NotContains(t, cfg, "use-routes")
// Only dev MAC (shouldn't happen, but defensive) → wildcard
cfg = generateNetworkConfig("", "02:de:00:60:83:0d")
assert.Contains(t, cfg, `name: "en*"`)
assert.NotContains(t, cfg, "use-routes")
}
func TestCloudInitVolumeNamePerInstance(t *testing.T) {
-26
View File
@@ -294,32 +294,6 @@ func (s *VPCServiceImpl) DescribeVpcs(input *ec2.DescribeVpcsInput, accountID st
vpcs = append(vpcs, s.vpcRecordToEC2(&record, accountID))
}
// Include global default VPCs (created at startup) for non-root accounts.
// This matches AWS behavior where all accounts have a default VPC.
if accountID != utils.GlobalAccountID {
globalPrefix := utils.GlobalAccountID + "."
for _, key := range keys {
if !strings.HasPrefix(key, globalPrefix) {
continue
}
entry, err := s.vpcKV.Get(key)
if err != nil {
continue
}
var record VPCRecord
if err := json.Unmarshal(entry.Value(), &record); err != nil {
continue
}
if !record.IsDefault {
continue
}
if len(vpcIDs) > 0 && !vpcIDs[record.VpcId] {
continue
}
vpcs = append(vpcs, s.vpcRecordToEC2(&record, accountID))
}
}
// If specific VPC IDs were requested but not found, return error
if len(vpcIDs) > 0 {
found := make(map[string]bool)
@@ -1,6 +1,7 @@
package handlers_ec2_vpc
import (
"encoding/json"
"testing"
"time"
@@ -873,6 +874,96 @@ func TestDetachENI_NotFound(t *testing.T) {
assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound")
}
// --- Per-account isolation tests ---
func TestEnsureDefaultVPC_PerAccountIsolation(t *testing.T) {
svc := setupTestVPCService(t)
accountA := "111111111111"
accountB := "222222222222"
require.NoError(t, svc.EnsureDefaultVPC(accountA))
require.NoError(t, svc.EnsureDefaultVPC(accountB))
// Each account should see only their own default VPC
descA, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{}, accountA)
require.NoError(t, err)
require.Len(t, descA.Vpcs, 1)
assert.Equal(t, accountA, *descA.Vpcs[0].OwnerId)
descB, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{}, accountB)
require.NoError(t, err)
require.Len(t, descB.Vpcs, 1)
assert.Equal(t, accountB, *descB.Vpcs[0].OwnerId)
// VPC IDs should be different
assert.NotEqual(t, *descA.Vpcs[0].VpcId, *descB.Vpcs[0].VpcId)
}
func TestEnsureDefaultVPC_IndependentVNIs(t *testing.T) {
svc, nc := setupTestVPCServiceWithNC(t)
accountA := "111111111111"
accountB := "222222222222"
// Capture VNIs from vpc.create events
vniCh := make(chan int64, 2)
sub, err := nc.Subscribe("vpc.create", func(msg *nats.Msg) {
var evt struct {
VNI int64 `json:"vni"`
}
if err := json.Unmarshal(msg.Data, &evt); err == nil {
vniCh <- evt.VNI
}
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
require.NoError(t, svc.EnsureDefaultVPC(accountA))
require.NoError(t, svc.EnsureDefaultVPC(accountB))
var vnis []int64
for range 2 {
select {
case vni := <-vniCh:
vnis = append(vnis, vni)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for vpc.create event")
}
}
assert.NotEqual(t, vnis[0], vnis[1], "each account should get a unique VNI")
}
func TestDescribeVpcs_NoGlobalSharing(t *testing.T) {
svc := setupTestVPCService(t)
globalAccount := "000000000000"
otherAccount := "111111111111"
// Create default VPC for global account only
require.NoError(t, svc.EnsureDefaultVPC(globalAccount))
// Other account should NOT see the global default VPC
desc, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{}, otherAccount)
require.NoError(t, err)
assert.Empty(t, desc.Vpcs)
}
func TestGetDefaultSubnet_PerAccount(t *testing.T) {
svc := setupTestVPCService(t)
accountA := "111111111111"
accountB := "222222222222"
require.NoError(t, svc.EnsureDefaultVPC(accountA))
require.NoError(t, svc.EnsureDefaultVPC(accountB))
subA, err := svc.GetDefaultSubnet(accountA)
require.NoError(t, err)
subB, err := svc.GetDefaultSubnet(accountB)
require.NoError(t, err)
assert.NotEqual(t, subA.SubnetId, subB.SubnetId)
assert.Equal(t, "172.31.0.0/20", subA.CidrBlock)
assert.Equal(t, "172.31.0.0/20", subB.CidrBlock)
}
// --- EnsureDefaultVPC event test ---
func TestEnsureDefaultVPC_PublishesEvents(t *testing.T) {
+62 -5
View File
@@ -1,11 +1,15 @@
package handlers_iam
import (
"encoding/json"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -218,7 +222,7 @@ func TestAccessKeyReturnsAccountID(t *testing.T) {
}
// ============================================================================
// SeedRootUser Account-Scoped Tests
// SeedBootstrap Account-Scoped Tests
// ============================================================================
// ============================================================================
@@ -340,16 +344,16 @@ func TestCrossAccount_LookupAccessKeyReturnsCorrectAccount(t *testing.T) {
}
// ============================================================================
// SeedRootUser Account-Scoped Tests
// SeedBootstrap Account-Scoped Tests
// ============================================================================
func TestSeedRootUser_AccountScoped(t *testing.T) {
func TestSeedBootstrap_AccountScoped(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("root-secret", svc.masterKey)
require.NoError(t, err)
err = svc.SeedRootUser(&BootstrapData{
err = svc.SeedBootstrap(&BootstrapData{
AccessKeyID: "AKIAROOTEXAMPLE12345",
EncryptedSecret: encryptedSecret,
AccountID: utils.GlobalAccountID,
@@ -374,6 +378,59 @@ func TestSeedRootUser_AccountScoped(t *testing.T) {
account, err := svc.GetAccount(utils.GlobalAccountID)
require.NoError(t, err)
assert.Equal(t, utils.GlobalAccountID, account.AccountID)
assert.Equal(t, "Global", account.AccountName)
assert.Equal(t, "system", account.AccountName)
assert.Equal(t, "ACTIVE", account.Status)
}
// ============================================================================
// Account Creation Event Tests
// ============================================================================
func TestCreateAccount_PublishesEvent(t *testing.T) {
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: t.TempDir(),
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(func() { ns.Shutdown() })
nc, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
masterKey, err := GenerateMasterKey()
require.NoError(t, err)
svc, err := NewIAMServiceImpl(nc, masterKey, 1)
require.NoError(t, err)
eventCh := make(chan *nats.Msg, 1)
sub, err := nc.Subscribe("iam.account.created", func(msg *nats.Msg) {
eventCh <- msg
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
acc, err := svc.CreateAccount("Event Test")
require.NoError(t, err)
select {
case msg := <-eventCh:
var evt struct {
AccountID string `json:"account_id"`
AccountName string `json:"account_name"`
}
require.NoError(t, json.Unmarshal(msg.Data, &evt))
assert.Equal(t, acc.AccountID, evt.AccountID)
assert.Equal(t, "Event Test", evt.AccountName)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for iam.account.created event")
}
}
+78
View File
@@ -270,6 +270,84 @@ func TestSaveBootstrapData_RoundTrip(t *testing.T) {
}
}
func TestBootstrapData_ExtendedRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bootstrap.json")
data := &BootstrapData{
AccessKeyID: "AKIASYSTEM1234567890",
EncryptedSecret: "c3lzdGVtLXNlY3JldA==",
AccountID: "000000000000",
Admin: &AdminBootstrapData{
AccountID: "000000000001",
AccountName: "hive",
UserName: "admin",
AccessKeyID: "AKIAADMIN12345678901",
EncryptedSecret: "YWRtaW4tc2VjcmV0",
},
}
if err := SaveBootstrapData(path, data); err != nil {
t.Fatalf("SaveBootstrapData() error: %v", err)
}
loaded, err := LoadBootstrapData(path)
if err != nil {
t.Fatalf("LoadBootstrapData() error: %v", err)
}
// System fields
if loaded.AccessKeyID != data.AccessKeyID {
t.Fatalf("AccessKeyID: expected %q, got %q", data.AccessKeyID, loaded.AccessKeyID)
}
if loaded.AccountID != data.AccountID {
t.Fatalf("AccountID: expected %q, got %q", data.AccountID, loaded.AccountID)
}
// Admin fields
if loaded.Admin == nil {
t.Fatal("Admin field should not be nil")
}
if loaded.Admin.AccountID != "000000000001" {
t.Fatalf("Admin.AccountID: expected %q, got %q", "000000000001", loaded.Admin.AccountID)
}
if loaded.Admin.AccountName != "hive" {
t.Fatalf("Admin.AccountName: expected %q, got %q", "hive", loaded.Admin.AccountName)
}
if loaded.Admin.UserName != "admin" {
t.Fatalf("Admin.UserName: expected %q, got %q", "admin", loaded.Admin.UserName)
}
if loaded.Admin.AccessKeyID != "AKIAADMIN12345678901" {
t.Fatalf("Admin.AccessKeyID: expected %q, got %q", "AKIAADMIN12345678901", loaded.Admin.AccessKeyID)
}
if loaded.Admin.EncryptedSecret != "YWRtaW4tc2VjcmV0" {
t.Fatalf("Admin.EncryptedSecret mismatch")
}
}
func TestBootstrapData_NoAdmin_BackwardCompat(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bootstrap.json")
// Write bootstrap data without admin field (old format)
oldJSON := `{"access_key_id":"AKIAOLD123","encrypted_secret":"b2xk","account_id":"000000000000"}`
if err := os.WriteFile(path, []byte(oldJSON), 0600); err != nil {
t.Fatal(err)
}
loaded, err := LoadBootstrapData(path)
if err != nil {
t.Fatalf("LoadBootstrapData() error: %v", err)
}
if loaded.AccessKeyID != "AKIAOLD123" {
t.Fatalf("AccessKeyID: expected %q, got %q", "AKIAOLD123", loaded.AccessKeyID)
}
if loaded.Admin != nil {
t.Fatal("Admin field should be nil for old-format bootstrap data")
}
}
func TestSaveBootstrapData_InvalidPath(t *testing.T) {
data := &BootstrapData{
AccessKeyID: "AKIAIOSFODNN7EXAMPLE",
+1 -1
View File
@@ -36,7 +36,7 @@ type IAMService interface {
// Auth (internal — used by SigV4 middleware and bootstrap, not exposed via gateway)
LookupAccessKey(accessKeyID string) (*AccessKey, error)
DecryptSecret(ciphertext string) (string, error)
SeedRootUser(data *BootstrapData) error
SeedBootstrap(data *BootstrapData) error
IsEmpty() (bool, error)
// Account operations
+182 -40
View File
@@ -33,6 +33,7 @@ const (
// IAMServiceImpl implements IAM operations using NATS JetStream KV.
type IAMServiceImpl struct {
js nats.JetStreamContext
natsConn *nats.Conn
usersBucket nats.KeyValue
accessKeysBucket nats.KeyValue
policiesBucket nats.KeyValue
@@ -43,37 +44,42 @@ type IAMServiceImpl struct {
}
// NewIAMServiceImpl creates a new IAM service backed by NATS JetStream KV.
func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte) (*IAMServiceImpl, error) {
// clusterSize sets the JetStream replication factor for KV buckets. For
// multi-node clusters this must match the number of NATS servers so that
// buckets survive node failures. Pass 1 for single-node or test setups.
func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (*IAMServiceImpl, error) {
if len(masterKey) != 32 {
return nil, fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey))
}
replicas := max(clusterSize, 1)
js, err := natsConn.JetStream()
if err != nil {
return nil, fmt.Errorf("get JetStream context: %w", err)
}
usersBucket, err := getOrCreateBucket(js, KVBucketUsers, 10)
usersBucket, err := getOrCreateBucket(js, KVBucketUsers, 10, replicas)
if err != nil {
return nil, fmt.Errorf("init users bucket: %w", err)
}
accessKeysBucket, err := getOrCreateBucket(js, KVBucketAccessKeys, 5)
accessKeysBucket, err := getOrCreateBucket(js, KVBucketAccessKeys, 5, replicas)
if err != nil {
return nil, fmt.Errorf("init access keys bucket: %w", err)
}
policiesBucket, err := getOrCreateBucket(js, KVBucketPolicies, 10)
policiesBucket, err := getOrCreateBucket(js, KVBucketPolicies, 10, replicas)
if err != nil {
return nil, fmt.Errorf("init policies bucket: %w", err)
}
accountsBucket, err := getOrCreateBucket(js, KVBucketAccounts, 5)
accountsBucket, err := getOrCreateBucket(js, KVBucketAccounts, 5, replicas)
if err != nil {
return nil, fmt.Errorf("init accounts bucket: %w", err)
}
accountCounterBucket, err := getOrCreateBucket(js, KVBucketAccountCounter, 5)
accountCounterBucket, err := getOrCreateBucket(js, KVBucketAccountCounter, 5, replicas)
if err != nil {
return nil, fmt.Errorf("init account counter bucket: %w", err)
}
@@ -87,10 +93,12 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte) (*IAMServiceImpl,
"users_bucket", KVBucketUsers,
"access_keys_bucket", KVBucketAccessKeys,
"policies_bucket", KVBucketPolicies,
"accounts_bucket", KVBucketAccounts)
"accounts_bucket", KVBucketAccounts,
"replicas", replicas)
return &IAMServiceImpl{
js: js,
natsConn: natsConn,
usersBucket: usersBucket,
accessKeysBucket: accessKeysBucket,
policiesBucket: policiesBucket,
@@ -101,10 +109,11 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte) (*IAMServiceImpl,
}, nil
}
func getOrCreateBucket(js nats.JetStreamContext, name string, history uint8) (nats.KeyValue, error) {
func getOrCreateBucket(js nats.JetStreamContext, name string, history uint8, replicas int) (nats.KeyValue, error) {
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: name,
History: history,
Bucket: name,
History: history,
Replicas: replicas,
})
if err != nil {
kv, err = js.KeyValue(name)
@@ -133,10 +142,15 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
}
}
userID, err := generateIAMID("AIDA")
if err != nil {
return nil, fmt.Errorf("generate user ID: %w", err)
}
kvKey := accountID + "." + userName
user := User{
UserName: userName,
UserID: generateIAMID("AIDA"),
UserID: userID,
AccountID: accountID,
ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", accountID, path, userName),
Path: path,
@@ -298,8 +312,14 @@ func (s *IAMServiceImpl) CreateAccessKey(accountID string, input *iam.CreateAcce
return nil, errors.New(awserrors.ErrorIAMLimitExceeded)
}
accessKeyID := generateAccessKeyID()
secretAccessKey := admin.GenerateAWSSecretKey()
accessKeyID, err := generateAccessKeyID()
if err != nil {
return nil, fmt.Errorf("generate access key ID: %w", err)
}
secretAccessKey, err := admin.GenerateAWSSecretKey()
if err != nil {
return nil, fmt.Errorf("generate secret key: %w", err)
}
encryptedSecret, err := EncryptSecret(secretAccessKey, s.masterKey)
if err != nil {
@@ -504,24 +524,27 @@ func (s *IAMServiceImpl) DecryptSecret(ciphertext string) (string, error) {
return s.decrypter.Decrypt(ciphertext)
}
// SeedRootUser consumes bootstrap data to create the root IAM user and access
// key in NATS KV. Uses conditional create (put-if-not-exists) for multi-node
// race safety — the first node to call this wins; others skip silently.
// Also creates the global account record (000000000000) if it doesn't exist.
func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error {
// Ensure the global account record exists
globalAccount := Account{
// SeedBootstrap consumes bootstrap data to create the system root IAM user and
// (optionally) the default admin account in NATS KV. Uses conditional create
// (put-if-not-exists) for multi-node race safety — the first node wins; others
// skip silently. Also creates the system account record (000000000000).
// If data.Admin is non-nil, it also seeds the admin account, user, access key,
// AdministratorAccess policy, and sets the account counter so subsequent
// CreateAccount calls start at 000000000002.
func (s *IAMServiceImpl) SeedBootstrap(data *BootstrapData) error {
// --- Seed system account (000000000000) ---
systemAccount := Account{
AccountID: utils.GlobalAccountID,
AccountName: "Global",
AccountName: "system",
Status: AccountStatusActive,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
accountData, err := json.Marshal(globalAccount)
accountData, err := json.Marshal(systemAccount)
if err != nil {
return fmt.Errorf("marshal global account: %w", err)
return fmt.Errorf("marshal system account: %w", err)
}
if _, err := s.accountsBucket.Create(utils.GlobalAccountID, accountData); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("seed global account: %w", err)
return fmt.Errorf("seed system account: %w", err)
}
kvKey := utils.GlobalAccountID + ".root"
@@ -546,9 +569,7 @@ func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error {
_, err = s.usersBucket.Create(kvKey, userData)
if errors.Is(err, nats.ErrKeyExists) {
slog.Info("Root user already seeded by another node, skipping")
return nil
}
if err != nil {
} else if err != nil {
return fmt.Errorf("seed root user: %w", err)
}
@@ -567,16 +588,120 @@ func (s *IAMServiceImpl) SeedRootUser(data *BootstrapData) error {
return fmt.Errorf("marshal root access key: %w", err)
}
_, err = s.accessKeysBucket.Create(data.AccessKeyID, akData)
if errors.Is(err, nats.ErrKeyExists) {
if _, err = s.accessKeysBucket.Create(data.AccessKeyID, akData); errors.Is(err, nats.ErrKeyExists) {
slog.Info("Root access key already seeded by another node, skipping")
return nil
}
if err != nil {
} else if err != nil {
return fmt.Errorf("seed root access key: %w", err)
} else {
slog.Info("System root user seeded", "accountID", utils.GlobalAccountID, "accessKeyID", data.AccessKeyID)
}
slog.Info("Root IAM user seeded", "accountID", utils.GlobalAccountID, "accessKeyID", data.AccessKeyID)
// --- Seed admin account (000000000001) if present ---
if data.Admin != nil {
if err := s.seedAdminAccount(data.Admin); err != nil {
return fmt.Errorf("seed admin account: %w", err)
}
}
return nil
}
// seedAdminAccount creates the default admin account, user, access key,
// AdministratorAccess policy, and sets the account counter to 2.
func (s *IAMServiceImpl) seedAdminAccount(admin *AdminBootstrapData) error {
now := time.Now().UTC().Format(time.RFC3339)
// Create admin account record
account := Account{
AccountID: admin.AccountID,
AccountName: admin.AccountName,
Status: AccountStatusActive,
CreatedAt: now,
}
accountData, err := json.Marshal(account)
if err != nil {
return fmt.Errorf("marshal admin account: %w", err)
}
if _, err := s.accountsBucket.Create(admin.AccountID, accountData); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("store admin account: %w", err)
}
// Set account counter to 2 so next CreateAccount gets 000000000002
if _, err := s.accountCounterBucket.Create("next_id", []byte("2")); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("seed account counter: %w", err)
}
// Create AdministratorAccess policy
policyARN := fmt.Sprintf("arn:aws:iam::%s:policy/AdministratorAccess", admin.AccountID)
policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`
policyID, err := generateIAMID("ANPA")
if err != nil {
return fmt.Errorf("generate policy ID: %w", err)
}
policy := Policy{
PolicyName: "AdministratorAccess",
PolicyID: policyID,
ARN: policyARN,
Path: "/",
Description: "Full administrator access",
PolicyDocument: policyDoc,
CreatedAt: now,
DefaultVersion: "v1",
Tags: []Tag{},
}
policyData, err := json.Marshal(policy)
if err != nil {
return fmt.Errorf("marshal admin policy: %w", err)
}
policyKVKey := admin.AccountID + ".AdministratorAccess"
if _, err := s.policiesBucket.Create(policyKVKey, policyData); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("store admin policy: %w", err)
}
// Create admin user (with policy already attached)
adminUserID, err := generateIAMID("AIDA")
if err != nil {
return fmt.Errorf("generate admin user ID: %w", err)
}
kvKey := admin.AccountID + "." + admin.UserName
adminUser := User{
UserName: admin.UserName,
UserID: adminUserID,
AccountID: admin.AccountID,
ARN: fmt.Sprintf("arn:aws:iam::%s:user/%s", admin.AccountID, admin.UserName),
Path: "/",
CreatedAt: now,
AccessKeys: []string{admin.AccessKeyID},
Tags: []Tag{},
AttachedPolicies: []string{policyARN},
}
userData, err := json.Marshal(adminUser)
if err != nil {
return fmt.Errorf("marshal admin user: %w", err)
}
if _, err := s.usersBucket.Create(kvKey, userData); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("store admin user: %w", err)
}
// Create admin access key
ak := AccessKey{
AccessKeyID: admin.AccessKeyID,
SecretAccessKey: admin.EncryptedSecret,
UserName: admin.UserName,
AccountID: admin.AccountID,
Status: AccessKeyStatusActive,
CreatedAt: now,
}
akData, err := json.Marshal(ak)
if err != nil {
return fmt.Errorf("marshal admin access key: %w", err)
}
if _, err := s.accessKeysBucket.Create(admin.AccessKeyID, akData); err != nil && !errors.Is(err, nats.ErrKeyExists) {
return fmt.Errorf("store admin access key: %w", err)
}
slog.Info("Admin account seeded", "accountID", admin.AccountID, "userName", admin.UserName, "accessKeyID", admin.AccessKeyID)
return nil
}
@@ -656,6 +781,19 @@ func (s *IAMServiceImpl) CreateAccount(name string) (*Account, error) {
}
slog.Info("Account created", "accountID", accountID, "name", name)
if s.natsConn != nil {
evt, err := json.Marshal(struct {
AccountID string `json:"account_id"`
AccountName string `json:"account_name"`
}{AccountID: accountID, AccountName: name})
if err != nil {
slog.Error("Failed to marshal account creation event", "accountID", accountID, "error", err)
} else if err := s.natsConn.Publish("iam.account.created", evt); err != nil {
slog.Error("Failed to publish account creation event", "accountID", accountID, "error", err)
}
}
return &account, nil
}
@@ -730,9 +868,13 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI
return nil, errors.New(awserrors.ErrorIAMInvalidInput)
}
newPolicyID, err := generateIAMID("ANPA")
if err != nil {
return nil, fmt.Errorf("generate policy ID: %w", err)
}
policy := Policy{
PolicyName: policyName,
PolicyID: generateIAMID("ANPA"),
PolicyID: newPolicyID,
ARN: fmt.Sprintf("arn:aws:iam::%s:policy%s%s", accountID, path, policyName),
Path: path,
Description: aws.StringValue(input.Description),
@@ -1167,20 +1309,20 @@ func (s *IAMServiceImpl) getUser(accountID, userName string) (*User, error) {
return &user, nil
}
func generateIAMID(prefix string) string {
func generateIAMID(prefix string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
return "", fmt.Errorf("crypto/rand failure: %w", err)
}
return prefix + strings.ToUpper(hex.EncodeToString(b))[:17]
return prefix + strings.ToUpper(hex.EncodeToString(b))[:17], nil
}
func generateAccessKeyID() string {
func generateAccessKeyID() (string, error) {
b := make([]byte, 10)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
return "", fmt.Errorf("crypto/rand failure: %w", err)
}
return "AKIA" + strings.ToUpper(hex.EncodeToString(b))
return "AKIA" + strings.ToUpper(hex.EncodeToString(b)), nil
}
func parseCreatedAt(raw string) time.Time {
+124 -19
View File
@@ -47,7 +47,7 @@ func setupTestIAMService(t *testing.T) *IAMServiceImpl {
masterKey, err := GenerateMasterKey()
require.NoError(t, err)
svc, err := NewIAMServiceImpl(nc, masterKey)
svc, err := NewIAMServiceImpl(nc, masterKey, 1)
require.NoError(t, err)
return svc
}
@@ -661,16 +661,16 @@ func TestLookupAccessKey_InactiveKey(t *testing.T) {
}
// ============================================================================
// SeedRootUser Tests
// SeedBootstrap Tests
// ============================================================================
func TestSeedRootUser(t *testing.T) {
func TestSeedBootstrap(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("test-secret-key", svc.masterKey)
require.NoError(t, err)
err = svc.SeedRootUser(&BootstrapData{
err = svc.SeedBootstrap(&BootstrapData{
AccessKeyID: "AKIAEXAMPLE123456789",
EncryptedSecret: encryptedSecret,
AccountID: utils.GlobalAccountID,
@@ -702,11 +702,11 @@ func TestSeedRootUser(t *testing.T) {
account, err := svc.GetAccount(utils.GlobalAccountID)
require.NoError(t, err)
assert.Equal(t, utils.GlobalAccountID, account.AccountID)
assert.Equal(t, "Global", account.AccountName)
assert.Equal(t, "system", account.AccountName)
assert.Equal(t, "ACTIVE", account.Status)
}
func TestSeedRootUser_Idempotent(t *testing.T) {
func TestSeedBootstrap_Idempotent(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, _ := EncryptSecret("test-secret", svc.masterKey)
@@ -717,11 +717,11 @@ func TestSeedRootUser_Idempotent(t *testing.T) {
}
// First call seeds
err := svc.SeedRootUser(data)
err := svc.SeedBootstrap(data)
require.NoError(t, err)
// Second call should succeed (no-op, idempotent)
err = svc.SeedRootUser(data)
err = svc.SeedBootstrap(data)
require.NoError(t, err)
// Root user should still exist with original data
@@ -732,6 +732,100 @@ func TestSeedRootUser_Idempotent(t *testing.T) {
assert.Equal(t, "root", *out.User.UserName)
}
func TestSeedBootstrap_WithAdmin(t *testing.T) {
svc := setupTestIAMService(t)
systemSecret, err := EncryptSecret("system-secret", svc.masterKey)
require.NoError(t, err)
adminSecret, err := EncryptSecret("admin-secret", svc.masterKey)
require.NoError(t, err)
err = svc.SeedBootstrap(&BootstrapData{
AccessKeyID: "AKIASYSTEM1234567890",
EncryptedSecret: systemSecret,
AccountID: utils.GlobalAccountID,
Admin: &AdminBootstrapData{
AccountID: "000000000001",
AccountName: "hive",
UserName: "admin",
AccessKeyID: "AKIAADMIN12345678901",
EncryptedSecret: adminSecret,
},
})
require.NoError(t, err)
// Verify system root user exists
out, err := svc.GetUser(utils.GlobalAccountID, &iam.GetUserInput{
UserName: aws.String("root"),
})
require.NoError(t, err)
assert.Equal(t, "root", *out.User.UserName)
// Verify admin account created
account, err := svc.GetAccount("000000000001")
require.NoError(t, err)
assert.Equal(t, "000000000001", account.AccountID)
assert.Equal(t, "hive", account.AccountName)
assert.Equal(t, "ACTIVE", account.Status)
// Verify admin user exists
adminOut, err := svc.GetUser("000000000001", &iam.GetUserInput{
UserName: aws.String("admin"),
})
require.NoError(t, err)
assert.Equal(t, "admin", *adminOut.User.UserName)
assert.Contains(t, *adminOut.User.Arn, "000000000001")
// Verify admin access key
ak, err := svc.LookupAccessKey("AKIAADMIN12345678901")
require.NoError(t, err)
assert.Equal(t, "admin", ak.UserName)
assert.Equal(t, "000000000001", ak.AccountID)
decrypted, err := DecryptSecret(ak.SecretAccessKey, svc.masterKey)
require.NoError(t, err)
assert.Equal(t, "admin-secret", decrypted)
// Verify AdministratorAccess policy was created and attached
policies, err := svc.GetUserPolicies("000000000001", "admin")
require.NoError(t, err)
require.Len(t, policies, 1)
assert.Equal(t, "Allow", string(policies[0].Statement[0].Effect))
assert.Equal(t, StringOrArr{"*"}, policies[0].Statement[0].Action)
assert.Equal(t, StringOrArr{"*"}, policies[0].Statement[0].Resource)
// Verify account counter set to 2 (next CreateAccount gets 000000000002)
nextAccount, err := svc.CreateAccount("test-org")
require.NoError(t, err)
assert.Equal(t, "000000000002", nextAccount.AccountID)
}
func TestSeedBootstrap_AdminNil_BackwardCompat(t *testing.T) {
svc := setupTestIAMService(t)
encryptedSecret, err := EncryptSecret("test-secret", svc.masterKey)
require.NoError(t, err)
// No Admin field — backward compatibility with old bootstrap.json
err = svc.SeedBootstrap(&BootstrapData{
AccessKeyID: "AKIAEXAMPLE123456789",
EncryptedSecret: encryptedSecret,
AccountID: utils.GlobalAccountID,
})
require.NoError(t, err)
// Root user should exist
out, err := svc.GetUser(utils.GlobalAccountID, &iam.GetUserInput{
UserName: aws.String("root"),
})
require.NoError(t, err)
assert.Equal(t, "root", *out.User.UserName)
// Admin account should NOT exist
_, err = svc.GetAccount("000000000001")
assert.Error(t, err)
}
// ============================================================================
// IsEmpty Tests
// ============================================================================
@@ -758,34 +852,41 @@ func TestIsEmpty_False(t *testing.T) {
// ============================================================================
func TestGenerateIAMID(t *testing.T) {
id := generateIAMID("AIDA")
id, err := generateIAMID("AIDA")
assert.NoError(t, err)
assert.Equal(t, "AIDA", id[:4])
assert.True(t, len(id) == 21) // AIDA + 17 hex chars
// Two IDs should differ
id2 := generateIAMID("AIDA")
id2, err := generateIAMID("AIDA")
assert.NoError(t, err)
assert.NotEqual(t, id, id2)
// Policy ID prefix
pid := generateIAMID("ANPA")
pid, err := generateIAMID("ANPA")
assert.NoError(t, err)
assert.Equal(t, "ANPA", pid[:4])
assert.Len(t, pid, 21)
}
func TestGenerateAccessKeyID(t *testing.T) {
id := generateAccessKeyID()
id, err := generateAccessKeyID()
assert.NoError(t, err)
assert.Equal(t, "AKIA", id[:4])
assert.True(t, len(id) == 24) // AKIA + 20 hex chars
id2 := generateAccessKeyID()
id2, err := generateAccessKeyID()
assert.NoError(t, err)
assert.NotEqual(t, id, id2)
}
func TestGenerateSecretAccessKey(t *testing.T) {
secret := admin.GenerateAWSSecretKey()
secret, err := admin.GenerateAWSSecretKey()
assert.NoError(t, err)
assert.Len(t, secret, 40)
secret2 := admin.GenerateAWSSecretKey()
secret2, err := admin.GenerateAWSSecretKey()
assert.NoError(t, err)
assert.NotEqual(t, secret, secret2)
}
@@ -1647,11 +1748,13 @@ func TestValidatePolicyDocument_ResourceARNFormat(t *testing.T) {
// ============================================================================
func TestGeneratePolicyID(t *testing.T) {
id := generateIAMID("ANPA")
id, err := generateIAMID("ANPA")
assert.NoError(t, err)
assert.Equal(t, "ANPA", id[:4])
assert.Len(t, id, 21) // ANPA + 17 hex chars
id2 := generateIAMID("ANPA")
id2, err := generateIAMID("ANPA")
assert.NoError(t, err)
assert.NotEqual(t, id, id2)
}
@@ -1779,7 +1882,8 @@ func TestParseCreatedAt(t *testing.T) {
func TestGenerateIAMID_AllUpperHex(t *testing.T) {
for range 20 {
id := generateIAMID("AIDA")
id, err := generateIAMID("AIDA")
assert.NoError(t, err)
suffix := id[4:]
for _, c := range suffix {
assert.True(t, (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'),
@@ -1790,7 +1894,8 @@ func TestGenerateIAMID_AllUpperHex(t *testing.T) {
func TestGenerateAccessKeyID_AllUpperHex(t *testing.T) {
for range 20 {
id := generateAccessKeyID()
id, err := generateAccessKeyID()
assert.NoError(t, err)
suffix := id[4:]
for _, c := range suffix {
assert.True(t, (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'),
+12 -1
View File
@@ -103,9 +103,20 @@ func (s StringOrArr) MarshalJSON() ([]byte, error) {
// BootstrapData is the on-disk JSON file consumed on first gateway start
// to seed the root IAM user into NATS KV.
type BootstrapData struct {
AccessKeyID string `json:"access_key_id"`
EncryptedSecret string `json:"encrypted_secret"`
AccountID string `json:"account_id"`
Admin *AdminBootstrapData `json:"admin,omitempty"`
}
// AdminBootstrapData holds the default admin account credentials seeded
// alongside the system account during first boot.
type AdminBootstrapData struct {
AccountID string `json:"account_id"`
AccountName string `json:"account_name"`
UserName string `json:"user_name"`
AccessKeyID string `json:"access_key_id"`
EncryptedSecret string `json:"encrypted_secret"`
AccountID string `json:"account_id"`
}
// Account represents a Hive account. Accounts namespace IAM users,
+14 -2
View File
@@ -2,8 +2,10 @@ package qmp
import (
"encoding/json"
"fmt"
"net"
"sync"
"time"
)
type unmarshalTarget any
@@ -117,7 +119,11 @@ func NewQMPClient(path string) (*QMPClient, error) {
return nil, err
}
//conn.SetReadDeadline(time.Now().Add(30 * time.Second))
// Set deadline for greeting — a hung QEMU would block forever without this
if err := conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("set read deadline: %w", err)
}
client := &QMPClient{
Conn: conn,
@@ -129,7 +135,13 @@ func NewQMPClient(path string) (*QMPClient, error) {
var greeting QMPGreeting
if err := client.Decoder.Decode(&greeting); err != nil {
_ = conn.Close()
return nil, err
return nil, fmt.Errorf("waiting for QMP greeting: %w", err)
}
// Clear deadline for normal operations
if err := conn.SetReadDeadline(time.Time{}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("clear read deadline: %w", err)
}
// enable capabilities
+19 -9
View File
@@ -24,8 +24,12 @@ type Service struct {
}
func New(cfg any) (svc *Service, err error) {
c, ok := cfg.(*config.ClusterConfig)
if !ok {
return nil, fmt.Errorf("invalid config type for awsgw service")
}
svc = &Service{
Config: cfg.(*config.ClusterConfig),
Config: c,
}
return svc, nil
}
@@ -85,22 +89,28 @@ func launchService(config *config.ClusterConfig) error {
// Initialize IAM service with NATS KV backend (required for auth).
// On multi-node clusters, JetStream KV requires cluster quorum which may
// not be available yet if nodes start concurrently. Retry with backoff.
iamService, err := initIAMService(natsConn, masterKey)
iamService, err := initIAMService(natsConn, masterKey, len(config.Nodes))
if err != nil {
return fmt.Errorf("initialize IAM service: %w", err)
}
// First boot: consume bootstrap.json → seed root user into NATS KV → delete file
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file
bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json")
if data, err := handlers_iam.LoadBootstrapData(bootstrapPath); err == nil {
slog.Info("Bootstrap file found, seeding root IAM user")
if err := iamService.SeedRootUser(data); err != nil {
return fmt.Errorf("seed root user from bootstrap.json: %w", err)
data, err := handlers_iam.LoadBootstrapData(bootstrapPath)
switch {
case err == nil:
slog.Info("Bootstrap file found, seeding IAM users")
if err := iamService.SeedBootstrap(data); err != nil {
return fmt.Errorf("seed bootstrap from bootstrap.json: %w", err)
}
if err := os.Remove(bootstrapPath); err != nil {
slog.Warn("Failed to delete bootstrap file", "path", bootstrapPath, "err", err)
}
slog.Info("Bootstrap complete, bootstrap.json deleted")
case os.IsNotExist(err):
// No bootstrap file — normal after first boot
default:
return fmt.Errorf("load bootstrap from %s: %w", bootstrapPath, err)
}
// Create gateway with NATS connection
@@ -172,7 +182,7 @@ func connectNATS(host, token string) (*nats.Conn, error) {
// initIAMService initializes the IAM service with retry/backoff. On multi-node
// clusters, JetStream requires NATS cluster quorum before KV buckets can be
// created. This retries for up to 5 minutes to allow late-joining nodes.
func initIAMService(natsConn *nats.Conn, masterKey []byte) (*handlers_iam.IAMServiceImpl, error) {
func initIAMService(natsConn *nats.Conn, masterKey []byte, clusterSize int) (*handlers_iam.IAMServiceImpl, error) {
const maxWait = 5 * time.Minute
retryDelay := 500 * time.Millisecond
start := time.Now()
@@ -180,7 +190,7 @@ func initIAMService(natsConn *nats.Conn, masterKey []byte) (*handlers_iam.IAMSer
for {
attempt++
svc, err := handlers_iam.NewIAMServiceImpl(natsConn, masterKey)
svc, err := handlers_iam.NewIAMServiceImpl(natsConn, masterKey, clusterSize)
if err == nil {
if attempt > 1 {
slog.Info("IAM service initialized after retry", "attempts", attempt, "elapsed", time.Since(start).Round(time.Second))
+6 -1
View File
@@ -1,6 +1,7 @@
package hive
import (
"fmt"
"log/slog"
"os"
@@ -17,8 +18,12 @@ type Service struct {
}
func New(cfg any) (svc *Service, err error) {
c, ok := cfg.(*config.ClusterConfig)
if !ok {
return nil, fmt.Errorf("invalid config type for hive service")
}
svc = &Service{
Config: cfg.(*config.ClusterConfig),
Config: c,
}
return svc, nil
}
+1
View File
@@ -2,3 +2,4 @@ node_modules
.tanstack
*.tsbuildinfo
.pnpm-store
coverage
+2
View File
@@ -0,0 +1,2 @@
minimum-release-time=3d
exclude-packages-from-minimum-release-time=vite,@vitejs/*
+1 -1
View File
@@ -1 +1 @@
24.12.0
24.14.0
@@ -0,0 +1,16 @@
// Ultracite oxfmt Configuration
// https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"semi": false,
"sortPackageJson": false,
"sortImports": {
"newlinesBetween": true,
},
"sortTailwindcss": {
"stylesheet": "./src/styles.css",
"functions": ["clsx", "cn", "cva"],
},
"ignorePatterns": ["dist/**", "src/routeTree.gen.ts"],
}
@@ -0,0 +1,132 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"typescript",
"unicorn",
"oxc",
"node",
"promise",
"react",
"react-perf",
"jsx-a11y"
],
"jsPlugins": [
"@tanstack/eslint-plugin-query",
"@tanstack/eslint-plugin-router"
],
"env": { "browser": true },
"categories": {
"correctness": "error",
"perf": "error",
"restriction": "error",
"suspicious": "error",
"pedantic": "error",
"style": "error"
},
"rules": {
"no-await-in-loop": "off",
"max-lines-per-function": "off",
"no-implicit-coercion": "off",
"no-magic-numbers": "off",
"no-console": "off",
"no-ternary": "off",
"no-undefined": "off",
"max-lines": "off",
"id-length": "off",
"func-style": "off",
"arrow-body-style": ["error", "as-needed"],
"max-depth": "off",
"max-params": "off",
"capitalized-comments": "off",
"new-cap": "off",
"no-continue": "off",
"init-declarations": "off",
"sort-imports": "off",
"no-duplicate-imports": ["error", { "allowSeparateTypeImports": true }],
"no-use-before-define": "off",
"sort-keys": "off",
"sort-vars": "off",
"prefer-destructuring": "off",
"max-statements": "off",
"complexity": "off",
"no-shadow": "off",
"unicorn/explicit-length-check": "off",
"unicorn/no-array-callback-reference": "off",
"unicorn/no-process-exit": "off",
"unicorn/prefer-global-this": "off",
"unicorn/no-null": "off",
"unicorn/prefer-top-level-await": "off",
"unicorn/prefer-string-raw": "off",
"unicorn/no-useless-undefined": "off",
"unicorn/filename-case": [
"error",
{ "cases": { "kebabCase": true, "camelCase": true } }
],
"typescript/no-import-type-side-effects": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-require-imports": "off",
"typescript/explicit-function-return-type": "off",
"typescript/no-var-requires": "off",
"typescript/require-await": "off",
"node/no-process-env": "off",
"oxc/no-map-spread": "off",
"oxc/no-async-await": "off",
"oxc/no-rest-spread-properties": "off",
"oxc/no-optional-chaining": "off",
"promise/catch-or-return": "off",
"promise/always-return": "off",
"promise/prefer-await-to-callbacks": "off",
"react/only-export-components": "off",
"react/jsx-boolean-value": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-filename-extension": "off",
"react/no-unknown-property": "off",
"react/jsx-props-no-spreading": "off",
"react/jsx-max-depth": "off",
"react/no-multi-comp": "off",
"react/no-unescaped-entities": "off",
"react-perf/jsx-no-jsx-as-prop": "off",
"react-perf/jsx-no-new-object-as-prop": "off",
"react-perf/jsx-no-new-array-as-prop": "off",
"react-perf/jsx-no-new-function-as-prop": "off",
"react/jsx-handler-names": "off",
"jsx-a11y/no-autofocus": "off",
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/stable-query-client": "error",
"@tanstack/query/no-rest-destructuring": "warn",
"@tanstack/query/no-unstable-deps": "error",
"@tanstack/query/infinite-query-property-order": "error",
"@tanstack/query/no-void-query-fn": "error",
"@tanstack/query/mutation-property-order": "error",
"@tanstack/router/create-route-property-order": "warn",
"@tanstack/router/route-param-names": "error"
},
"overrides": [
{
"files": ["**/routeTree.gen.ts"],
"rules": {
"unicorn/filename-case": "off",
"unicorn/no-abusive-eslint-disable": "off"
}
},
{
"files": [
"**/*.{test,spec}.{ts,tsx,js,jsx}",
"**/__tests__/**/*.{ts,tsx,js,jsx}"
],
"rules": {
"typescript/consistent-type-imports": "off"
}
}
],
"ignorePatterns": ["dist", "src/routeTree.gen.ts"]
}
-59
View File
@@ -1,59 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/core", "ultracite/remix", "ultracite/react"],
"assist": {
"actions": {
"source": {
"useSortedProperties": "off",
"useSortedInterfaceMembers": "off",
"organizeImports": {
"level": "on",
"options": {
"groups": [
":URL:",
":BLANK_LINE:",
[":BUN:", ":NODE:"],
":BLANK_LINE:",
[":PACKAGE:", ":PACKAGE_WITH_PROTOCOL:"],
":BLANK_LINE:",
[":ALIAS:", ":PATH:"]
]
}
}
}
}
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.ts"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"filenameCases": ["camelCase"]
}
}
}
}
}
},
{
"includes": ["**/components/ui/*.tsx"],
"linter": {
"rules": {
"performance": {
"noNamespaceImport": "off"
}
}
}
}
]
}
@@ -0,0 +1 @@
import{Dt as e,Et as t,jt as n,nt as r}from"./awsClient.js";var i=class extends n.classBuilder().ep(t).m(function(t,n,r,i){return[e(r,t.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DescribeInstances`,{}).n(`EC2Client`,`DescribeInstancesCommand`).sc(r).build(){};export{i as t};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{c as B,F as $,G as F,y as C,j as s,x as K,H as b,I as k,L}from"./index.js";import{u as E}from"./useSuspenseQuery.js";import{B as O}from"./back-link.js";import{E as S}from"./error-banner.js";import{a as U}from"./s3.js";import{U as A,F as I,O as R}from"./upload-button.js";import"./delete-confirmation-dialog.js";import"./alert-dialog.js";import"./download.js";import"./trash-2.js";function V(){const e=B.c(24),{bucket:n,_splat:v}=$.useParams(),r=F(v||""),{data:m}=E(C(n,r)),a=U();let d;e[0]!==m.Contents?(d=m.Contents||[],e[0]=m.Contents,e[1]=d):d=e[1];const o=d;let p;e[2]!==m.CommonPrefixes?(p=m.CommonPrefixes||[],e[2]=m.CommonPrefixes,e[3]=p):p=e[3];const i=p,u=r.split("/").filter(Boolean),y=[{name:n,path:`/s3/ls/${n}`},...u.map((t,l)=>{const c=`${u.slice(0,l+1).join("/")}/`;return{name:t,path:`/s3/ls/${n}/${c}`}})],P=u.length>1?`/s3/ls/${n}/${u.slice(0,-1).join("/")}/`:`/s3/ls/${n}`;let x;e[4]!==P?(x=s.jsx(O,{to:P,children:"Back"}),e[4]=P,e[5]=x):x=e[5];let f;e[6]!==n||e[7]!==r||e[8]!==a.isPending||e[9]!==a.mutateAsync?(f=s.jsx(A,{bucket:n,isPending:a.isPending,onUpload:a.mutateAsync,prefix:r}),e[6]=n,e[7]=r,e[8]=a.isPending,e[9]=a.mutateAsync,e[10]=f):f=e[10];let h;e[11]!==a.error?(h=a.error&&s.jsx(S,{error:a.error,msg:"Failed to upload object"}),e[11]=a.error,e[12]=h):h=e[12];let g;e[13]!==n||e[14]!==i||e[15]!==r?(g=i.length>0&&s.jsxs("div",{className:"mb-6",children:[s.jsx("h2",{className:"mb-3 font-semibold text-lg",children:"Folders"}),s.jsx("div",{className:"space-y-2",children:i.map(t=>{if(!t.Prefix)return null;const l=K(b(t.Prefix,r)),c=k(t.Prefix,r);return s.jsx(I,{bucketName:n,folderName:l,fullPrefix:c},t.Prefix)})})]}),e[13]=n,e[14]=i,e[15]=r,e[16]=g):g=e[16];let j;e[17]!==n||e[18]!==o||e[19]!==r?(j=o.length>0&&s.jsxs("div",{className:"space-y-2",children:[s.jsx("h2",{className:"mb-3 font-semibold text-lg",children:"Objects"}),o.map(t=>{if(!t.Key)return null;const l=b(t.Key,r),c=k(t.Key,r);return s.jsx(R,{bucketName:n,displayName:l,fullKey:c,object:t},t.Key)})]}),e[17]=n,e[18]=o,e[19]=r,e[20]=j):j=e[20];let N;return e[21]!==i.length||e[22]!==o.length?(N=o.length===0&&i.length===0&&s.jsx("p",{className:"text-muted-foreground",children:"No objects found in this folder."}),e[21]=i.length,e[22]=o.length,e[23]=N):N=e[23],s.jsxs(s.Fragment,{children:[x,s.jsx("div",{className:"mb-6",children:s.jsxs("div",{className:"mb-2 flex items-center justify-between",children:[s.jsx("div",{className:"flex items-center gap-2 text-muted-foreground text-sm",children:y.map((t,l)=>s.jsxs("div",{className:"flex items-center gap-2",children:[l>0&&s.jsx("span",{children:"/"}),l===y.length-1?s.jsx("span",{className:"text-foreground",children:t.name}):s.jsx(L,{className:"hover:text-foreground",to:t.path,children:t.name})]},t.path))}),f]})}),h,g,j,N]})}export{V as component};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./error-banner.js";import"./useRouterState.js";import{t as i}from"./link.js";import{c as a,i as o,r as s,t as c}from"./utils.js";import"./button.js";import{t as l}from"./back-link.js";import{n as u,r as d,t as f}from"./upload-button.js";import"./awsClient.js";import{r as p}from"./s3.js";import{t as m}from"./s32.js";import{d as h}from"./index.js";import"./alert-dialog.js";import"./delete-confirmation-dialog.js";var g=t(),_=e();function v(){let e=(0,g.c)(24),{bucket:t,_splat:v}=h.useParams(),y=s(v||``),{data:b}=n(m(t,y)),x=p(),S;e[0]===b.Contents?S=e[1]:(S=b.Contents||[],e[0]=b.Contents,e[1]=S);let C=S,w;e[2]===b.CommonPrefixes?w=e[3]:(w=b.CommonPrefixes||[],e[2]=b.CommonPrefixes,e[3]=w);let T=w,E=y.split(`/`).filter(Boolean),D=[{name:t,path:`/s3/ls/${t}`},...E.map((e,n)=>({name:e,path:`/s3/ls/${t}/${`${E.slice(0,n+1).join(`/`)}/`}`}))],O=E.length>1?`/s3/ls/${t}/${E.slice(0,-1).join(`/`)}/`:`/s3/ls/${t}`,k;e[4]===O?k=e[5]:(k=(0,_.jsx)(l,{to:O,children:`Back`}),e[4]=O,e[5]=k);let A;e[6]!==t||e[7]!==y||e[8]!==x.isPending||e[9]!==x.mutateAsync?(A=(0,_.jsx)(f,{bucket:t,isPending:x.isPending,onUpload:x.mutateAsync,prefix:y}),e[6]=t,e[7]=y,e[8]=x.isPending,e[9]=x.mutateAsync,e[10]=A):A=e[10];let j;e[11]===x.error?j=e[12]:(j=x.error&&(0,_.jsx)(r,{error:x.error,msg:`Failed to upload object`}),e[11]=x.error,e[12]=j);let M;e[13]!==t||e[14]!==T||e[15]!==y?(M=T.length>0&&(0,_.jsxs)(`div`,{className:`mb-6`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,_.jsx)(`div`,{className:`space-y-2`,children:T.map(e=>e.Prefix?(0,_.jsx)(d,{bucketName:t,folderName:a(o(e.Prefix,y)),fullPrefix:c(e.Prefix,y)},e.Prefix):null)})]}),e[13]=t,e[14]=T,e[15]=y,e[16]=M):M=e[16];let N;e[17]!==t||e[18]!==C||e[19]!==y?(N=C.length>0&&(0,_.jsxs)(`div`,{className:`space-y-2`,children:[(0,_.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),C.map(e=>e.Key?(0,_.jsx)(u,{bucketName:t,displayName:o(e.Key,y),fullKey:c(e.Key,y),object:e},e.Key):null)]}),e[17]=t,e[18]=C,e[19]=y,e[20]=N):N=e[20];let P;return e[21]!==T.length||e[22]!==C.length?(P=C.length===0&&T.length===0&&(0,_.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this folder.`}),e[21]=T.length,e[22]=C.length,e[23]=P):P=e[23],(0,_.jsxs)(_.Fragment,{children:[k,(0,_.jsx)(`div`,{className:`mb-6`,children:(0,_.jsxs)(`div`,{className:`mb-2 flex items-center justify-between`,children:[(0,_.jsx)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:D.map((e,t)=>(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[t>0&&(0,_.jsx)(`span`,{children:`/`}),t===D.length-1?(0,_.jsx)(`span`,{className:`text-foreground`,children:e.name}):(0,_.jsx)(i,{className:`hover:text-foreground`,to:e.path,children:e.name})]},e.path))}),A]})}),j,M,N,P]})}export{v as component};
+1
View File
@@ -0,0 +1 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import"./awsClient.js";import{l as r}from"./ec22.js";import{t as i}from"./page-heading.js";var a=t(),o=e();function s(){let e=(0,a.c)(2),{data:t}=n(r),s=`${t?.Regions?.[0]?.RegionName} Dashboard`,c;return e[0]===s?c=e[1]:(c=(0,o.jsx)(i,{title:s}),e[0]=s,e[1]=c),c}export{s as component};
+1
View File
@@ -0,0 +1 @@
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import{t as r}from"./error-banner.js";import"./useRouterState.js";import{c as i}from"./utils.js";import"./button.js";import{t as a}from"./back-link.js";import{n as o,r as s,t as c}from"./upload-button.js";import"./awsClient.js";import{r as l}from"./s3.js";import{t as u}from"./s32.js";import{f as d}from"./index.js";import{t as f}from"./page-heading.js";import"./alert-dialog.js";import"./delete-confirmation-dialog.js";var p=t(),m=e();function h(){let e=(0,p.c)(31),{bucket:t}=d.useParams(),h;e[0]===t?h=e[1]:(h=u(t),e[0]=t,e[1]=h);let{data:g}=n(h),_=l(),v;e[2]===g.Contents?v=e[3]:(v=g.Contents||[],e[2]=g.Contents,e[3]=v);let y=v,b;e[4]===g.CommonPrefixes?b=e[5]:(b=g.CommonPrefixes||[],e[4]=g.CommonPrefixes,e[5]=b);let x=b,S;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,m.jsx)(a,{to:`/s3/ls`,children:`Back to buckets`}),e[6]=S):S=e[6];let C;e[7]!==t||e[8]!==_.isPending||e[9]!==_.mutateAsync?(C=(0,m.jsx)(c,{bucket:t,isPending:_.isPending,onUpload:_.mutateAsync}),e[7]=t,e[8]=_.isPending,e[9]=_.mutateAsync,e[10]=C):C=e[10];let w;e[11]!==t||e[12]!==C?(w=(0,m.jsx)(f,{actions:C,title:t}),e[11]=t,e[12]=C,e[13]=w):w=e[13];let T;e[14]===_.error?T=e[15]:(T=_.error&&(0,m.jsx)(r,{error:_.error,msg:`Failed to upload object`}),e[14]=_.error,e[15]=T);let E;e[16]!==t||e[17]!==x?(E=x.length>0&&(0,m.jsxs)(`div`,{className:`mb-6`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Folders`}),(0,m.jsx)(`div`,{className:`space-y-2`,children:x.map(e=>e.Prefix?(0,m.jsx)(s,{bucketName:t,folderName:i(e.Prefix),fullPrefix:e.Prefix},e.Prefix):null)})]}),e[16]=t,e[17]=x,e[18]=E):E=e[18];let D;e[19]!==t||e[20]!==y?(D=y.length>0&&(0,m.jsxs)(`div`,{className:`space-y-2`,children:[(0,m.jsx)(`h2`,{className:`mb-3 text-lg font-semibold`,children:`Objects`}),y.map(e=>e.Key?(0,m.jsx)(o,{bucketName:t,displayName:e.Key,fullKey:e.Key,object:e},e.Key):null)]}),e[19]=t,e[20]=y,e[21]=D):D=e[21];let O;e[22]!==x.length||e[23]!==y.length?(O=y.length===0&&x.length===0&&(0,m.jsx)(`p`,{className:`text-muted-foreground`,children:`No objects found in this bucket.`}),e[22]=x.length,e[23]=y.length,e[24]=O):O=e[24];let k;return e[25]!==w||e[26]!==T||e[27]!==E||e[28]!==D||e[29]!==O?(k=(0,m.jsxs)(m.Fragment,{children:[S,w,T,E,D,O]}),e[25]=w,e[26]=T,e[27]=E,e[28]=D,e[29]=O,e[30]=k):k=e[30],k}export{h as component};
+1 -1
View File
@@ -1 +1 @@
import{aJ as D,u as f,aK as v,v as b,r as I,j as e,B as C,L as g}from"./index.js";import{u as d}from"./useSuspenseQuery.js";import{B as u}from"./back-link.js";import{D as V}from"./delete-confirmation-dialog.js";import{D as n,a}from"./detail-row.js";import{E as S}from"./error-banner.js";import{P as y}from"./page-heading.js";import{S as P}from"./state-badge.js";import{g as B}from"./ec2.js";import{T as k}from"./trash-2.js";import"./alert-dialog.js";function Q(){const{id:l}=D.useParams(),m=f(),{data:p}=d(v(l)),{data:x}=d(b),s=p.Vpcs?.[0],r=B(),[j,i]=I.useState(!1),o=x.Subnets?.filter(t=>t.VpcId===l)??[],h=async()=>{try{await r.mutateAsync(l),m({to:"/ec2/describe-vpcs"})}finally{i(!1)}};if(!s?.VpcId)return e.jsxs(e.Fragment,{children:[e.jsx(u,{to:"/ec2/describe-vpcs",children:"Back to VPCs"}),e.jsx("p",{className:"text-muted-foreground",children:"VPC not found."})]});const c=s.Tags?.find(t=>t.Key==="Name")?.Value;return e.jsxs(e.Fragment,{children:[e.jsx(u,{to:"/ec2/describe-vpcs",children:"Back to VPCs"}),r.error&&e.jsx(S,{error:r.error,msg:"Failed to delete VPC"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(y,{actions:e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs(C,{onClick:()=>i(!0),size:"sm",variant:"destructive",children:[e.jsx(k,{className:"size-4"}),"Delete"]}),e.jsx(P,{state:s.State})]}),subtitle:"VPC Details",title:c?`${s.VpcId} (${c})`:s.VpcId}),e.jsxs(n,{children:[e.jsx(n.Header,{children:"VPC Information"}),e.jsxs(n.Content,{children:[e.jsx(a,{label:"VPC ID",value:s.VpcId}),e.jsx(a,{label:"State",value:s.State}),e.jsx(a,{label:"CIDR Block",value:s.CidrBlock}),e.jsx(a,{label:"Is Default",value:s.IsDefault?"Yes":"No"}),e.jsx(a,{label:"DHCP Options ID",value:s.DhcpOptionsId}),e.jsx(a,{label:"Instance Tenancy",value:s.InstanceTenancy}),e.jsx(a,{label:"Owner ID",value:s.OwnerId})]})]}),o.length>0&&e.jsxs(n,{children:[e.jsxs(n.Header,{children:["Subnets (",o.length,")"]}),o.map(t=>t.SubnetId?e.jsxs(n.Content,{children:[e.jsx(a,{label:"Subnet ID",value:e.jsx(g,{className:"text-primary hover:underline",params:{id:t.SubnetId},to:"/ec2/describe-subnets/$id",children:t.SubnetId})}),e.jsx(a,{label:"CIDR Block",value:t.CidrBlock}),e.jsx(a,{label:"Availability Zone",value:t.AvailabilityZone}),e.jsx(a,{label:"State",value:t.State})]},t.SubnetId):null)]})]}),e.jsx(V,{description:`Are you sure you want to delete the VPC "${s.VpcId}"? This action cannot be undone. The VPC must have no associated subnets or internet gateways.`,isPending:r.isPending,onConfirm:h,onOpenChange:i,open:j,title:"Delete VPC"})]})}export{Q as component};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./useSuspenseQuery.js";import"./useRouterState.js";import"./utils.js";import{t as r}from"./back-link.js";import"./awsClient.js";import{n as i}from"./ec22.js";import{t as a}from"./index.js";import{t as o}from"./page-heading.js";import{t as s}from"./state-badge.js";import"./detail-row.js";import{t as c}from"./ami-details.js";var l=t(),u=e();function d(){let e=(0,l.c)(14),{id:t}=a.useParams(),d;e[0]===t?d=e[1]:(d=i(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f?.Images?.[0];if(!p?.ImageId){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),(0,u.jsx)(`p`,{className:`text-muted-foreground`,children:`Image not found.`})]}),e[2]=t):t=e[2],t}let m;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsx)(r,{to:`/ec2/describe-images`,children:`Back to images`}),e[3]=m):m=e[3];let h;e[4]===p.State?h=e[5]:(h=(0,u.jsx)(s,{state:p.State}),e[4]=p.State,e[5]=h);let g;e[6]!==p.ImageId||e[7]!==h?(g=(0,u.jsx)(o,{actions:h,subtitle:`EC2 Image Details`,title:p.ImageId}),e[6]=p.ImageId,e[7]=h,e[8]=g):g=e[8];let _;e[9]===p?_=e[10]:(_=(0,u.jsx)(c,{image:p,showExtendedDetails:!0}),e[9]=p,e[10]=_);let v;return e[11]!==g||e[12]!==_?(v=(0,u.jsxs)(u.Fragment,{children:[m,(0,u.jsxs)(`div`,{className:`space-y-6`,children:[g,_]})]}),e[11]=g,e[12]=_,e[13]=v):v=e[13],v}export{d as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{cX as p,u as x,cY as j,r as f,j as e,B as S,L as h}from"./index.js";import{u as D}from"./useSuspenseQuery.js";import{B as l}from"./back-link.js";import{D as v}from"./delete-confirmation-dialog.js";import{D as o,a as s}from"./detail-row.js";import{E as g}from"./error-banner.js";import{P as I}from"./page-heading.js";import{S as y}from"./state-badge.js";import{l as C}from"./ec2.js";import{T as B}from"./trash-2.js";import"./alert-dialog.js";function $(){const{id:r}=p.useParams(),u=x(),{data:c}=D(j(r)),t=c.Subnets?.[0],a=C(),[d,n]=f.useState(!1),m=async()=>{try{await a.mutateAsync(r),u({to:"/ec2/describe-subnets"})}finally{n(!1)}};if(!t?.SubnetId)return e.jsxs(e.Fragment,{children:[e.jsx(l,{to:"/ec2/describe-subnets",children:"Back to Subnets"}),e.jsx("p",{className:"text-muted-foreground",children:"Subnet not found."})]});const i=t.Tags?.find(b=>b.Key==="Name")?.Value;return e.jsxs(e.Fragment,{children:[e.jsx(l,{to:"/ec2/describe-subnets",children:"Back to Subnets"}),a.error&&e.jsx(g,{error:a.error,msg:"Failed to delete subnet"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(I,{actions:e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs(S,{onClick:()=>n(!0),size:"sm",variant:"destructive",children:[e.jsx(B,{className:"size-4"}),"Delete"]}),e.jsx(y,{state:t.State})]}),subtitle:"Subnet Details",title:i?`${t.SubnetId} (${i})`:t.SubnetId}),e.jsxs(o,{children:[e.jsx(o.Header,{children:"Subnet Information"}),e.jsxs(o.Content,{children:[e.jsx(s,{label:"Subnet ID",value:t.SubnetId}),e.jsx(s,{label:"VPC ID",value:t.VpcId?e.jsx(h,{className:"text-primary hover:underline",params:{id:t.VpcId},to:"/ec2/describe-vpcs/$id",children:t.VpcId}):"—"}),e.jsx(s,{label:"State",value:t.State}),e.jsx(s,{label:"CIDR Block",value:t.CidrBlock}),e.jsx(s,{label:"Availability Zone",value:t.AvailabilityZone}),e.jsx(s,{label:"Available IP Count",value:t.AvailableIpAddressCount?.toString()}),e.jsx(s,{label:"Map Public IP on Launch",value:t.MapPublicIpOnLaunch?"Yes":"No"}),e.jsx(s,{label:"Default for AZ",value:t.DefaultForAz?"Yes":"No"})]})]})]}),e.jsx(v,{description:`Are you sure you want to delete the subnet "${t.SubnetId}"? This action cannot be undone.`,isPending:a.isPending,onConfirm:m,onOpenChange:n,open:d,title:"Delete Subnet"})]})}export{$ as component};
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./error-banner.js";import"./useRouterState.js";import{t as a}from"./link.js";import{a as o}from"./utils.js";import{t as s}from"./useNavigate.js";import{t as c}from"./button.js";import{t as l}from"./createLucideIcon.js";import{t as u}from"./back-link.js";import{t as d}from"./trash-2.js";import"./separator.js";import"./awsClient.js";import{d as f,n as p}from"./ec2.js";import{l as m,u as h}from"./ec22.js";import{O as g,i as _}from"./index.js";import{t as v}from"./page-heading.js";import{t as y}from"./state-badge.js";import{n as b,t as x}from"./detail-row.js";import{a as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./alert-dialog.js";import{i as A,t as j}from"./field.js";import{t as M}from"./delete-confirmation-dialog.js";var N=l(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),P=e(n()),F=t();function I(){let{id:e}=_.useParams(),t=s(),{data:n}=r(h(e)),{data:l}=r(m),I=n.Snapshots?.[0],L=f(),R=p(),[z,B]=(0,P.useState)(!1),[V,H]=(0,P.useState)(!1),[U,W]=(0,P.useState)(``),G=l.Regions?.[0]?.RegionName??`ap-southeast-2`,K=I?.State===`completed`,q=async()=>{try{await L.mutateAsync(e),t({to:`/ec2/describe-snapshots`})}finally{B(!1)}},J=async()=>{try{await R.mutateAsync({sourceSnapshotId:e,sourceRegion:G,description:U||void 0}),H(!1),W(``)}catch{}};if(!I?.SnapshotId)return(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(u,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),(0,F.jsx)(`p`,{className:`text-muted-foreground`,children:`Snapshot not found.`})]});let Y=o(I.StartTime);return(0,F.jsxs)(F.Fragment,{children:[(0,F.jsx)(u,{to:`/ec2/describe-snapshots`,children:`Back to snapshots`}),L.error&&(0,F.jsx)(i,{error:L.error,msg:`Failed to delete snapshot`}),R.error&&(0,F.jsx)(i,{error:R.error,msg:`Failed to copy snapshot`}),(0,F.jsxs)(`div`,{className:`space-y-6`,children:[(0,F.jsx)(v,{actions:(0,F.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,F.jsxs)(c,{disabled:!K,onClick:()=>B(!0),size:`sm`,variant:`destructive`,children:[(0,F.jsx)(d,{className:`size-4`}),`Delete`]}),(0,F.jsxs)(c,{disabled:!K,onClick:()=>H(!0),size:`sm`,variant:`outline`,children:[(0,F.jsx)(N,{className:`size-4`}),`Copy Snapshot`]}),(0,F.jsx)(y,{state:I.State})]}),subtitle:`EC2 Snapshot Details`,title:I.SnapshotId}),(0,F.jsxs)(b,{children:[(0,F.jsx)(b.Header,{children:`Snapshot Information`}),(0,F.jsxs)(b.Content,{children:[(0,F.jsx)(x,{label:`Snapshot ID`,value:I.SnapshotId}),(0,F.jsx)(x,{label:`Volume ID`,value:I.VolumeId?(0,F.jsx)(a,{className:`text-primary hover:underline`,params:{id:I.VolumeId},to:`/ec2/describe-volumes/$id`,children:I.VolumeId}):void 0}),(0,F.jsx)(x,{label:`Volume Size`,value:`${I.VolumeSize} GiB`}),(0,F.jsx)(x,{label:`State`,value:I.State}),(0,F.jsx)(x,{label:`Description`,value:I.Description}),(0,F.jsx)(x,{label:`Progress`,value:I.Progress}),(0,F.jsx)(x,{label:`Start Time`,value:Y}),(0,F.jsx)(x,{label:`Owner ID`,value:I.OwnerId}),(0,F.jsx)(x,{label:`Encrypted`,value:I.Encrypted?`Yes`:`No`})]})]})]}),(0,F.jsx)(M,{description:`Are you sure you want to delete the snapshot "${I.SnapshotId}"? This action cannot be undone.`,isPending:L.isPending,onConfirm:q,onOpenChange:B,open:z,title:`Delete Snapshot`}),(0,F.jsx)(k,{onOpenChange:H,open:V,children:(0,F.jsxs)(C,{children:[(0,F.jsxs)(O,{children:[(0,F.jsx)(w,{children:`Copy Snapshot`}),(0,F.jsx)(S,{children:`Create a copy of this snapshot in the current region.`})]}),(0,F.jsxs)(j,{children:[(0,F.jsx)(A,{children:(0,F.jsx)(`label`,{htmlFor:`copyDescription`,children:`Description (optional)`})}),(0,F.jsx)(g,{id:`copyDescription`,onChange:e=>W(e.target.value),placeholder:`Description for the snapshot copy`,value:U})]}),(0,F.jsxs)(E,{children:[(0,F.jsx)(D,{children:`Cancel`}),(0,F.jsx)(T,{disabled:R.isPending,onClick:J,children:R.isPending?`Copying…`:`Copy Snapshot`})]})]})})]})}export{I as component};
+1 -1
View File
@@ -1 +1 @@
import{J as w,cZ as k,u as N,c_ as B,f as P,r as l,j as e,i as T,B as m,L as F}from"./index.js";import{u}from"./useSuspenseQuery.js";import{B as x}from"./back-link.js";import{D as R}from"./delete-confirmation-dialog.js";import{D as c,a as t}from"./detail-row.js";import{E as g}from"./error-banner.js";import{P as z}from"./page-heading.js";import{S as E}from"./state-badge.js";import{g as V,f as O,b as L,a as H,c as M,d as Q,e as _,h as $}from"./alert-dialog.js";import{b as G,a as J}from"./field.js";import{I as Y}from"./input.js";import{m as Z,n as q}from"./ec2.js";import{T as K}from"./trash-2.js";const U=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],W=w("copy",U);function he(){const{id:n}=k.useParams(),j=N(),{data:D}=u(B(n)),{data:y}=u(P),s=D.Snapshots?.[0],a=Z(),o=q(),[S,i]=l.useState(!1),[f,r]=l.useState(!1),[p,d]=l.useState(""),C=y.Regions?.[0]?.RegionName??"ap-southeast-2",h=s?.State==="completed",v=async()=>{try{await a.mutateAsync(n),j({to:"/ec2/describe-snapshots"})}finally{i(!1)}},b=async()=>{try{await o.mutateAsync({sourceSnapshotId:n,sourceRegion:C,description:p||void 0}),r(!1),d("")}catch{}};if(!s?.SnapshotId)return e.jsxs(e.Fragment,{children:[e.jsx(x,{to:"/ec2/describe-snapshots",children:"Back to snapshots"}),e.jsx("p",{className:"text-muted-foreground",children:"Snapshot not found."})]});const I=T(s.StartTime);return e.jsxs(e.Fragment,{children:[e.jsx(x,{to:"/ec2/describe-snapshots",children:"Back to snapshots"}),a.error&&e.jsx(g,{error:a.error,msg:"Failed to delete snapshot"}),o.error&&e.jsx(g,{error:o.error,msg:"Failed to copy snapshot"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(z,{actions:e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs(m,{disabled:!h,onClick:()=>i(!0),size:"sm",variant:"destructive",children:[e.jsx(K,{className:"size-4"}),"Delete"]}),e.jsxs(m,{disabled:!h,onClick:()=>r(!0),size:"sm",variant:"outline",children:[e.jsx(W,{className:"size-4"}),"Copy Snapshot"]}),e.jsx(E,{state:s.State})]}),subtitle:"EC2 Snapshot Details",title:s.SnapshotId}),e.jsxs(c,{children:[e.jsx(c.Header,{children:"Snapshot Information"}),e.jsxs(c.Content,{children:[e.jsx(t,{label:"Snapshot ID",value:s.SnapshotId}),e.jsx(t,{label:"Volume ID",value:s.VolumeId?e.jsx(F,{className:"text-primary hover:underline",params:{id:s.VolumeId},to:"/ec2/describe-volumes/$id",children:s.VolumeId}):void 0}),e.jsx(t,{label:"Volume Size",value:`${s.VolumeSize} GiB`}),e.jsx(t,{label:"State",value:s.State}),e.jsx(t,{label:"Description",value:s.Description}),e.jsx(t,{label:"Progress",value:s.Progress}),e.jsx(t,{label:"Start Time",value:I}),e.jsx(t,{label:"Owner ID",value:s.OwnerId}),e.jsx(t,{label:"Encrypted",value:s.Encrypted?"Yes":"No"})]})]})]}),e.jsx(R,{description:`Are you sure you want to delete the snapshot "${s.SnapshotId}"? This action cannot be undone.`,isPending:a.isPending,onConfirm:v,onOpenChange:i,open:S,title:"Delete Snapshot"}),e.jsx(V,{onOpenChange:r,open:f,children:e.jsxs(O,{children:[e.jsxs(L,{children:[e.jsx(H,{children:"Copy Snapshot"}),e.jsx(M,{children:"Create a copy of this snapshot in the current region."})]}),e.jsxs(G,{children:[e.jsx(J,{children:e.jsx("label",{htmlFor:"copyDescription",children:"Description (optional)"})}),e.jsx(Y,{id:"copyDescription",onChange:A=>d(A.target.value),placeholder:"Description for the snapshot copy",value:p})]}),e.jsxs(Q,{children:[e.jsx(_,{children:"Cancel"}),e.jsx($,{disabled:o.isPending,onClick:b,children:o.isPending?"Copying…":"Copy Snapshot"})]})]})})]})}export{he as component};
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./error-banner.js";import"./useRouterState.js";import{t as a}from"./link.js";import{s as o}from"./utils.js";import{t as s}from"./useNavigate.js";import{t as c}from"./button.js";import{t as l}from"./back-link.js";import{t as u}from"./trash-2.js";import"./awsClient.js";import{f as d}from"./ec2.js";import{f}from"./ec22.js";import{a as p}from"./index.js";import{t as m}from"./page-heading.js";import{t as h}from"./state-badge.js";import{n as g,t as _}from"./detail-row.js";import"./alert-dialog.js";import{t as v}from"./delete-confirmation-dialog.js";var y=e(n()),b=t();function x(){let{id:e}=p.useParams(),t=s(),{data:n}=r(f(e)),x=n.Subnets?.[0],S=d(),[C,w]=(0,y.useState)(!1),T=async()=>{try{await S.mutateAsync(e),t({to:`/ec2/describe-subnets`})}finally{w(!1)}};if(!x?.SubnetId)return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(l,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),(0,b.jsx)(`p`,{className:`text-muted-foreground`,children:`Subnet not found.`})]});let E=o(x.Tags);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsx)(l,{to:`/ec2/describe-subnets`,children:`Back to Subnets`}),S.error&&(0,b.jsx)(i,{error:S.error,msg:`Failed to delete subnet`}),(0,b.jsxs)(`div`,{className:`space-y-6`,children:[(0,b.jsx)(m,{actions:(0,b.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,b.jsxs)(c,{onClick:()=>w(!0),size:`sm`,variant:`destructive`,children:[(0,b.jsx)(u,{className:`size-4`}),`Delete`]}),(0,b.jsx)(h,{state:x.State})]}),subtitle:`Subnet Details`,title:E?`${x.SubnetId} (${E})`:x.SubnetId}),(0,b.jsxs)(g,{children:[(0,b.jsx)(g.Header,{children:`Subnet Information`}),(0,b.jsxs)(g.Content,{children:[(0,b.jsx)(_,{label:`Subnet ID`,value:x.SubnetId}),(0,b.jsx)(_,{label:`VPC ID`,value:x.VpcId?(0,b.jsx)(a,{className:`text-primary hover:underline`,params:{id:x.VpcId},to:`/ec2/describe-vpcs/$id`,children:x.VpcId}):`—`}),(0,b.jsx)(_,{label:`State`,value:x.State}),(0,b.jsx)(_,{label:`CIDR Block`,value:x.CidrBlock}),(0,b.jsx)(_,{label:`Availability Zone`,value:x.AvailabilityZone}),(0,b.jsx)(_,{label:`Available IP Count`,value:x.AvailableIpAddressCount?.toString()}),(0,b.jsx)(_,{label:`Map Public IP on Launch`,value:x.MapPublicIpOnLaunch?`Yes`:`No`}),(0,b.jsx)(_,{label:`Default for AZ`,value:x.DefaultForAz?`Yes`:`No`})]})]})]}),(0,b.jsx)(v,{description:`Are you sure you want to delete the subnet "${x.SubnetId}"? This action cannot be undone.`,isPending:S.isPending,onConfirm:T,onOpenChange:w,open:C,title:`Delete Subnet`})]})}export{x as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{c as f,d4 as u,j as t,d3 as p}from"./index.js";import{u as x}from"./useSuspenseQuery.js";import{B as d}from"./back-link.js";import{P as I}from"./page-heading.js";import{S as j}from"./state-badge.js";import{A as h}from"./ami-details.js";import"./detail-row.js";function _(){const e=f.c(14),{id:n}=u.useParams();let o;e[0]!==n?(o=p(n),e[0]=n,e[1]=o):o=e[1];const{data:g}=x(o),s=g?.Images?.[0];if(!s?.ImageId){let l;return e[2]===Symbol.for("react.memo_cache_sentinel")?(l=t.jsxs(t.Fragment,{children:[t.jsx(d,{to:"/ec2/describe-images",children:"Back to images"}),t.jsx("p",{className:"text-muted-foreground",children:"Image not found."})]}),e[2]=l):l=e[2],l}let r;e[3]===Symbol.for("react.memo_cache_sentinel")?(r=t.jsx(d,{to:"/ec2/describe-images",children:"Back to images"}),e[3]=r):r=e[3];let a;e[4]!==s.State?(a=t.jsx(j,{state:s.State}),e[4]=s.State,e[5]=a):a=e[5];let i;e[6]!==s.ImageId||e[7]!==a?(i=t.jsx(I,{actions:a,subtitle:"EC2 Image Details",title:s.ImageId}),e[6]=s.ImageId,e[7]=a,e[8]=i):i=e[8];let m;e[9]!==s?(m=t.jsx(h,{image:s,showExtendedDetails:!0}),e[9]=s,e[10]=m):m=e[10];let c;return e[11]!==i||e[12]!==m?(c=t.jsxs(t.Fragment,{children:[r,t.jsxs("div",{className:"space-y-6",children:[i,m]})]}),e[11]=i,e[12]=m,e[13]=c):c=e[13],c}export{_ as component};
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{t as r}from"./useSuspenseQuery.js";import{t as i}from"./error-banner.js";import"./useRouterState.js";import{t as a}from"./link.js";import{s as o}from"./utils.js";import{t as s}from"./useNavigate.js";import{t as c}from"./button.js";import{t as l}from"./back-link.js";import{t as u}from"./trash-2.js";import"./awsClient.js";import{m as d}from"./ec2.js";import{g as f,p}from"./ec22.js";import{c as m}from"./index.js";import{t as h}from"./page-heading.js";import{t as g}from"./state-badge.js";import{n as _,t as v}from"./detail-row.js";import"./alert-dialog.js";import{t as y}from"./delete-confirmation-dialog.js";var b=e(n()),x=t();function S(){let{id:e}=m.useParams(),t=s(),{data:n}=r(f(e)),{data:S}=r(p),C=n.Vpcs?.[0],w=d(),[T,E]=(0,b.useState)(!1),D=S.Subnets?.filter(t=>t.VpcId===e)??[],O=async()=>{try{await w.mutateAsync(e),t({to:`/ec2/describe-vpcs`})}finally{E(!1)}};if(!C?.VpcId)return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(l,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),(0,x.jsx)(`p`,{className:`text-muted-foreground`,children:`VPC not found.`})]});let k=o(C.Tags);return(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(l,{to:`/ec2/describe-vpcs`,children:`Back to VPCs`}),w.error&&(0,x.jsx)(i,{error:w.error,msg:`Failed to delete VPC`}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsx)(h,{actions:(0,x.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,x.jsxs)(c,{onClick:()=>E(!0),size:`sm`,variant:`destructive`,children:[(0,x.jsx)(u,{className:`size-4`}),`Delete`]}),(0,x.jsx)(g,{state:C.State})]}),subtitle:`VPC Details`,title:k?`${C.VpcId} (${k})`:C.VpcId}),(0,x.jsxs)(_,{children:[(0,x.jsx)(_.Header,{children:`VPC Information`}),(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`VPC ID`,value:C.VpcId}),(0,x.jsx)(v,{label:`State`,value:C.State}),(0,x.jsx)(v,{label:`CIDR Block`,value:C.CidrBlock}),(0,x.jsx)(v,{label:`Is Default`,value:C.IsDefault?`Yes`:`No`}),(0,x.jsx)(v,{label:`DHCP Options ID`,value:C.DhcpOptionsId}),(0,x.jsx)(v,{label:`Instance Tenancy`,value:C.InstanceTenancy}),(0,x.jsx)(v,{label:`Owner ID`,value:C.OwnerId})]})]}),D.length>0&&(0,x.jsxs)(_,{children:[(0,x.jsxs)(_.Header,{children:[`Subnets (`,D.length,`)`]}),D.map(e=>e.SubnetId?(0,x.jsxs)(_.Content,{children:[(0,x.jsx)(v,{label:`Subnet ID`,value:(0,x.jsx)(a,{className:`text-primary hover:underline`,params:{id:e.SubnetId},to:`/ec2/describe-subnets/$id`,children:e.SubnetId})}),(0,x.jsx)(v,{label:`CIDR Block`,value:e.CidrBlock}),(0,x.jsx)(v,{label:`Availability Zone`,value:e.AvailabilityZone}),(0,x.jsx)(v,{label:`State`,value:e.State})]},e.SubnetId):null)]})]}),(0,x.jsx)(y,{description:`Are you sure you want to delete the VPC "${C.VpcId}"? This action cannot be undone. The VPC must have no associated subnets or internet gateways.`,isPending:w.isPending,onConfirm:O,onOpenChange:E,open:T,title:`Delete VPC`})]})}export{S as component};
+1 -1
View File
@@ -1 +1 @@
import{c as f,j as e,av as j,u as P,aw as h,r as v,ax as g,B as N,i as b}from"./index.js";import{u}from"./useSuspenseQuery.js";import{B as p}from"./back-link.js";import{D as C}from"./delete-confirmation-dialog.js";import{D as l,a as n}from"./detail-row.js";import{E as w}from"./error-banner.js";import{P as A}from"./page-heading.js";import{h as I}from"./iam.js";import{T as B}from"./trash-2.js";import"./alert-dialog.js";function R(r){const t=f.c(4),{document:a}=r;let s;try{let i;t[0]!==a?(i=JSON.stringify(JSON.parse(a),null,2),t[0]=a,t[1]=i):i=t[1],s=i}catch{s=a}let o;return t[2]!==s?(o=e.jsx("pre",{className:"overflow-auto rounded-md border bg-muted p-4 font-mono text-sm",children:s}),t[2]=s,t[3]=o):o=t[3],o}function J(){const{policyArn:r}=j.useParams(),t=decodeURIComponent(r),a=P(),{data:s}=u(h(t)),o=s.Policy,i=I(),[y,c]=v.useState(!1),x=o?.DefaultVersionId??"v1",{data:m}=u(g(t,x)),d=m?.PolicyVersion?.Document?decodeURIComponent(m.PolicyVersion.Document):null,D=async()=>{try{await i.mutateAsync(t),a({to:"/iam/list-policies"})}finally{c(!1)}};return o?e.jsxs(e.Fragment,{children:[e.jsx(p,{to:"/iam/list-policies",children:"Back to policies"}),i.error&&e.jsx(w,{error:i.error,msg:"Failed to delete policy"}),e.jsxs("div",{className:"space-y-6",children:[e.jsx(A,{actions:e.jsxs(N,{onClick:()=>c(!0),size:"sm",variant:"destructive",children:[e.jsx(B,{className:"size-4"}),"Delete"]}),subtitle:"IAM Policy Details",title:o.PolicyName??""}),e.jsxs(l,{children:[e.jsx(l.Header,{children:"Policy Information"}),e.jsxs(l.Content,{children:[e.jsx(n,{label:"Policy Name",value:o.PolicyName}),e.jsx(n,{label:"Policy ID",value:o.PolicyId}),e.jsx(n,{label:"ARN",value:o.Arn}),e.jsx(n,{label:"Path",value:o.Path}),e.jsx(n,{label:"Description",value:o.Description||"-"}),e.jsx(n,{label:"Created",value:b(o.CreateDate)})]})]}),d&&e.jsxs(l,{children:[e.jsx(l.Header,{children:"Policy Document"}),e.jsx("div",{className:"p-4",children:e.jsx(R,{document:d})})]})]}),e.jsx(C,{description:`Are you sure you want to delete the policy "${o.PolicyName}"? This action cannot be undone.`,isPending:i.isPending,onConfirm:D,onOpenChange:c,open:y,title:"Delete Policy"})]}):e.jsxs(e.Fragment,{children:[e.jsx(p,{to:"/iam/list-policies",children:"Back to policies"}),e.jsx("p",{className:"text-muted-foreground",children:"Policy not found."})]})}export{J as component};
import{a as e,n as t,r as n,t as r}from"./compiler-runtime.js";import{t as i}from"./useSuspenseQuery.js";import{t as a}from"./error-banner.js";import"./useRouterState.js";import{a as o}from"./utils.js";import{t as s}from"./useNavigate.js";import{t as c}from"./button.js";import{t as l}from"./back-link.js";import{t as u}from"./trash-2.js";import"./awsClient.js";import{o as d}from"./iam.js";import{a as f,i as p}from"./iam2.js";import{l as m}from"./index.js";import{t as h}from"./page-heading.js";import{n as g,t as _}from"./detail-row.js";import"./alert-dialog.js";import{t as v}from"./delete-confirmation-dialog.js";var y=e(n()),b=r(),x=t();function S(e){let t=(0,b.c)(4),{document:n}=e,r;try{let e;t[0]===n?e=t[1]:(e=JSON.stringify(JSON.parse(n),null,2),t[0]=n,t[1]=e),r=e}catch{r=n}let i;return t[2]===r?i=t[3]:(i=(0,x.jsx)(`pre`,{className:`overflow-auto rounded-md border bg-muted p-4 font-mono text-sm`,children:r}),t[2]=r,t[3]=i),i}function C(){let{policyArn:e}=m.useParams(),t=decodeURIComponent(e),n=s(),{data:r}=i(p(t)),b=r.Policy,C=d(),[w,T]=(0,y.useState)(!1),{data:E}=i(f(t,b?.DefaultVersionId??`v1`)),D=E?.PolicyVersion?.Document?decodeURIComponent(E.PolicyVersion.Document):null;return b?(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(l,{to:`/iam/list-policies`,children:`Back to policies`}),C.error&&(0,x.jsx)(a,{error:C.error,msg:`Failed to delete policy`}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsx)(h,{actions:(0,x.jsxs)(c,{onClick:()=>T(!0),size:`sm`,variant:`destructive`,children:[(0,x.jsx)(u,{className:`size-4`}),`Delete`]}),subtitle:`IAM Policy Details`,title:b.PolicyName??``}),(0,x.jsxs)(g,{children:[(0,x.jsx)(g.Header,{children:`Policy Information`}),(0,x.jsxs)(g.Content,{children:[(0,x.jsx)(_,{label:`Policy Name`,value:b.PolicyName}),(0,x.jsx)(_,{label:`Policy ID`,value:b.PolicyId}),(0,x.jsx)(_,{label:`ARN`,value:b.Arn}),(0,x.jsx)(_,{label:`Path`,value:b.Path}),(0,x.jsx)(_,{label:`Description`,value:b.Description||`-`}),(0,x.jsx)(_,{label:`Created`,value:o(b.CreateDate)})]})]}),D&&(0,x.jsxs)(g,{children:[(0,x.jsx)(g.Header,{children:`Policy Document`}),(0,x.jsx)(`div`,{className:`p-4`,children:(0,x.jsx)(S,{document:D})})]})]}),(0,x.jsx)(v,{description:`Are you sure you want to delete the policy "${b.PolicyName}"? This action cannot be undone.`,isPending:C.isPending,onConfirm:async()=>{try{await C.mutateAsync(t),n({to:`/iam/list-policies`})}finally{T(!1)}},onOpenChange:T,open:w,title:`Delete Policy`})]}):(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(l,{to:`/iam/list-policies`,children:`Back to policies`}),(0,x.jsx)(`p`,{className:`text-muted-foreground`,children:`Policy not found.`})]})}export{C as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{c as g,i as k,j as t,L as M}from"./index.js";import{D as T,a}from"./detail-row.js";function C(s){const e=g.c(58),{image:l,showExtendedDetails:P}=s,i=P===void 0?!1:P;let E;e[0]!==l.CreationDate?(E=k(l.CreationDate),e[0]=l.CreationDate,e[1]=E):E=e[1];const R=E,S=i?"AMI Information":"AMI Details";let o;e[2]!==S?(o=t.jsx(T.Header,{children:S}),e[2]=S,e[3]=o):o=e[3];let r;e[4]!==l.ImageId||e[5]!==i?(r=l.ImageId&&!i?t.jsx(M,{className:"text-primary hover:underline",params:{id:l.ImageId},to:"/ec2/describe-images/$id",children:l.ImageId}):l.ImageId,e[4]=l.ImageId,e[5]=i,e[6]=r):r=e[6];let c;e[7]!==r?(c=t.jsx(a,{label:"AMI ID",value:r}),e[7]=r,e[8]=c):c=e[8];let n;e[9]!==l.Name?(n=t.jsx(a,{label:"AMI Name",value:l.Name}),e[9]=l.Name,e[10]=n):n=e[10];let u;e[11]!==l.Description?(u=t.jsx(a,{label:"Description",value:l.Description}),e[11]=l.Description,e[12]=u):u=e[12];let m;e[13]!==l.Architecture?(m=t.jsx(a,{label:"Architecture",value:l.Architecture}),e[13]=l.Architecture,e[14]=m):m=e[14];let v;e[15]!==l.VirtualizationType?(v=t.jsx(a,{label:"Virtualization Type",value:l.VirtualizationType}),e[15]=l.VirtualizationType,e[16]=v):v=e[16];const A=l.Platform||"Linux";let D;e[17]!==A?(D=t.jsx(a,{label:"Platform",value:A}),e[17]=A,e[18]=D):D=e[18];let d;e[19]!==l.PlatformDetails?(d=t.jsx(a,{label:"Platform Details",value:l.PlatformDetails}),e[19]=l.PlatformDetails,e[20]=d):d=e[20];let x;e[21]!==l.ImageType?(x=t.jsx(a,{label:"Image Type",value:l.ImageType}),e[21]=l.ImageType,e[22]=x):x=e[22];let b;e[23]!==l.RootDeviceType?(b=t.jsx(a,{label:"Root Device Type",value:l.RootDeviceType}),e[23]=l.RootDeviceType,e[24]=b):b=e[24];let j;e[25]!==l.RootDeviceName?(j=t.jsx(a,{label:"Root Device Name",value:l.RootDeviceName}),e[25]=l.RootDeviceName,e[26]=j):j=e[26];let f;e[27]!==l.OwnerId?(f=t.jsx(a,{label:"Owner",value:l.OwnerId}),e[27]=l.OwnerId,e[28]=f):f=e[28];let I;e[29]!==l.State?(I=t.jsx(a,{label:"State",value:l.State}),e[29]=l.State,e[30]=I):I=e[30];let h;e[31]!==R||e[32]!==l.Public||e[33]!==i?(h=i&&t.jsxs(t.Fragment,{children:[t.jsx(a,{label:"Creation Date",value:R}),t.jsx(a,{label:"Public",value:l.Public?"Yes":"No"})]}),e[31]=R,e[32]=l.Public,e[33]=i,e[34]=h):h=e[34];let p;e[35]!==v||e[36]!==D||e[37]!==d||e[38]!==x||e[39]!==b||e[40]!==j||e[41]!==f||e[42]!==I||e[43]!==h||e[44]!==c||e[45]!==n||e[46]!==u||e[47]!==m?(p=t.jsxs(T.Content,{children:[c,n,u,m,v,D,d,x,b,j,f,I,h]}),e[35]=v,e[36]=D,e[37]=d,e[38]=x,e[39]=b,e[40]=j,e[41]=f,e[42]=I,e[43]=h,e[44]=c,e[45]=n,e[46]=u,e[47]=m,e[48]=p):p=e[48];let N;e[49]!==p||e[50]!==o?(N=t.jsxs(T,{children:[o,p]}),e[49]=p,e[50]=o,e[51]=N):N=e[51];let y;e[52]!==l.BlockDeviceMappings||e[53]!==i?(y=i&&l.BlockDeviceMappings&&l.BlockDeviceMappings.length>0&&l.BlockDeviceMappings.map(w),e[52]=l.BlockDeviceMappings,e[53]=i,e[54]=y):y=e[54];let V;return e[55]!==N||e[56]!==y?(V=t.jsxs(t.Fragment,{children:[N,y]}),e[55]=N,e[56]=y,e[57]=V):V=e[57],V}function w(s){return t.jsxs(T,{children:[t.jsxs(T.Header,{children:["Block Device: ",s.DeviceName]}),t.jsxs(T.Content,{children:[t.jsx(a,{label:"Device Name",value:s.DeviceName}),s.Ebs&&t.jsxs(t.Fragment,{children:[t.jsx(a,{label:"Volume Size",value:s.Ebs.VolumeSize?`${s.Ebs.VolumeSize} GB`:void 0}),t.jsx(a,{label:"Volume Type",value:s.Ebs.VolumeType}),t.jsx(a,{label:"Delete On Termination",value:s.Ebs.DeleteOnTermination?"Yes":"No"}),t.jsx(a,{label:"Encrypted",value:s.Ebs.Encrypted?"Yes":"No"}),s.Ebs.SnapshotId&&t.jsx(a,{label:"Snapshot ID",value:t.jsx(M,{className:"text-primary hover:underline",params:{id:s.Ebs.SnapshotId},to:"/ec2/describe-snapshots/$id",children:s.Ebs.SnapshotId})})]}),s.VirtualName&&t.jsx(a,{label:"Virtual Name",value:s.VirtualName})]})]},s.DeviceName)}export{C as A};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./link.js";import{a as r}from"./utils.js";import{n as i,t as a}from"./detail-row.js";var o=t(),s=e();function c(e){let t=(0,o.c)(58),{image:c,showExtendedDetails:u}=e,d=u===void 0?!1:u,f;t[0]===c.CreationDate?f=t[1]:(f=r(c.CreationDate),t[0]=c.CreationDate,t[1]=f);let p=f,m=d?`AMI Information`:`AMI Details`,h;t[2]===m?h=t[3]:(h=(0,s.jsx)(i.Header,{children:m}),t[2]=m,t[3]=h);let g;t[4]!==c.ImageId||t[5]!==d?(g=c.ImageId&&!d?(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:c.ImageId},to:`/ec2/describe-images/$id`,children:c.ImageId}):c.ImageId,t[4]=c.ImageId,t[5]=d,t[6]=g):g=t[6];let _;t[7]===g?_=t[8]:(_=(0,s.jsx)(a,{label:`AMI ID`,value:g}),t[7]=g,t[8]=_);let v;t[9]===c.Name?v=t[10]:(v=(0,s.jsx)(a,{label:`AMI Name`,value:c.Name}),t[9]=c.Name,t[10]=v);let y;t[11]===c.Description?y=t[12]:(y=(0,s.jsx)(a,{label:`Description`,value:c.Description}),t[11]=c.Description,t[12]=y);let b;t[13]===c.Architecture?b=t[14]:(b=(0,s.jsx)(a,{label:`Architecture`,value:c.Architecture}),t[13]=c.Architecture,t[14]=b);let x;t[15]===c.VirtualizationType?x=t[16]:(x=(0,s.jsx)(a,{label:`Virtualization Type`,value:c.VirtualizationType}),t[15]=c.VirtualizationType,t[16]=x);let S=c.Platform||`Linux`,C;t[17]===S?C=t[18]:(C=(0,s.jsx)(a,{label:`Platform`,value:S}),t[17]=S,t[18]=C);let w;t[19]===c.PlatformDetails?w=t[20]:(w=(0,s.jsx)(a,{label:`Platform Details`,value:c.PlatformDetails}),t[19]=c.PlatformDetails,t[20]=w);let T;t[21]===c.ImageType?T=t[22]:(T=(0,s.jsx)(a,{label:`Image Type`,value:c.ImageType}),t[21]=c.ImageType,t[22]=T);let E;t[23]===c.RootDeviceType?E=t[24]:(E=(0,s.jsx)(a,{label:`Root Device Type`,value:c.RootDeviceType}),t[23]=c.RootDeviceType,t[24]=E);let D;t[25]===c.RootDeviceName?D=t[26]:(D=(0,s.jsx)(a,{label:`Root Device Name`,value:c.RootDeviceName}),t[25]=c.RootDeviceName,t[26]=D);let O;t[27]===c.OwnerId?O=t[28]:(O=(0,s.jsx)(a,{label:`Owner`,value:c.OwnerId}),t[27]=c.OwnerId,t[28]=O);let k;t[29]===c.State?k=t[30]:(k=(0,s.jsx)(a,{label:`State`,value:c.State}),t[29]=c.State,t[30]=k);let A;t[31]!==p||t[32]!==c.Public||t[33]!==d?(A=d&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Creation Date`,value:p}),(0,s.jsx)(a,{label:`Public`,value:c.Public?`Yes`:`No`})]}),t[31]=p,t[32]=c.Public,t[33]=d,t[34]=A):A=t[34];let j;t[35]!==x||t[36]!==C||t[37]!==w||t[38]!==T||t[39]!==E||t[40]!==D||t[41]!==O||t[42]!==k||t[43]!==A||t[44]!==_||t[45]!==v||t[46]!==y||t[47]!==b?(j=(0,s.jsxs)(i.Content,{children:[_,v,y,b,x,C,w,T,E,D,O,k,A]}),t[35]=x,t[36]=C,t[37]=w,t[38]=T,t[39]=E,t[40]=D,t[41]=O,t[42]=k,t[43]=A,t[44]=_,t[45]=v,t[46]=y,t[47]=b,t[48]=j):j=t[48];let M;t[49]!==j||t[50]!==h?(M=(0,s.jsxs)(i,{children:[h,j]}),t[49]=j,t[50]=h,t[51]=M):M=t[51];let N;t[52]!==c.BlockDeviceMappings||t[53]!==d?(N=d&&c.BlockDeviceMappings&&c.BlockDeviceMappings.length>0&&c.BlockDeviceMappings.map(l),t[52]=c.BlockDeviceMappings,t[53]=d,t[54]=N):N=t[54];let P;return t[55]!==M||t[56]!==N?(P=(0,s.jsxs)(s.Fragment,{children:[M,N]}),t[55]=M,t[56]=N,t[57]=P):P=t[57],P}function l(e){return(0,s.jsxs)(i,{children:[(0,s.jsxs)(i.Header,{children:[`Block Device: `,e.DeviceName]}),(0,s.jsxs)(i.Content,{children:[(0,s.jsx)(a,{label:`Device Name`,value:e.DeviceName}),e.Ebs&&(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(a,{label:`Volume Size`,value:e.Ebs.VolumeSize?`${e.Ebs.VolumeSize} GB`:void 0}),(0,s.jsx)(a,{label:`Volume Type`,value:e.Ebs.VolumeType}),(0,s.jsx)(a,{label:`Delete On Termination`,value:e.Ebs.DeleteOnTermination?`Yes`:`No`}),(0,s.jsx)(a,{label:`Encrypted`,value:e.Ebs.Encrypted?`Yes`:`No`}),e.Ebs.SnapshotId&&(0,s.jsx)(a,{label:`Snapshot ID`,value:(0,s.jsx)(n,{className:`text-primary hover:underline`,params:{id:e.Ebs.SnapshotId},to:`/ec2/describe-snapshots/$id`,children:e.Ebs.SnapshotId})})]}),e.VirtualName&&(0,s.jsx)(a,{label:`Virtual Name`,value:e.VirtualName})]})]},e.DeviceName)}export{c as t};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
import{J as o,c as r,j as l,L as a}from"./index.js";const m=[["path",{d:"m12 19-7-7 7-7",key:"1l729n"}],["path",{d:"M19 12H5",key:"x3x0zl"}]],x=o("arrow-left",m);function p(i){const e=r.c(7);let s,t;e[0]!==i?({children:s,...t}=i,e[0]=i,e[1]=s,e[2]=t):(s=e[1],t=e[2]);let n;e[3]===Symbol.for("react.memo_cache_sentinel")?(n=l.jsx(x,{className:"size-4"}),e[3]=n):n=e[3];let c;return e[4]!==s||e[5]!==t?(c=l.jsxs(a,{className:"mb-6 inline-flex items-center gap-2 text-sm",...t,children:[n,s]}),e[4]=s,e[5]=t,e[6]=c):c=e[6],c}export{p as B};
import{n as e,t}from"./compiler-runtime.js";import{t as n}from"./link.js";import{t as r}from"./createLucideIcon.js";var i=r(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),a=t(),o=e();function s(e){let t=(0,a.c)(7),r,s;t[0]===e?(r=t[1],s=t[2]):({children:r,...s}=e,t[0]=e,t[1]=r,t[2]=s);let c;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,o.jsx)(i,{className:`size-4`}),t[3]=c):c=t[3];let l;return t[4]!==r||t[5]!==s?(l=(0,o.jsxs)(n,{className:`mb-6 inline-flex items-center gap-2 text-sm`,...s,children:[c,r]}),t[4]=r,t[5]=s,t[6]=l):l=t[6],l}export{s as t};

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