mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-24 13:14:23 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8700df99 | |||
| 857cbd75ac | |||
| 0fa38f7986 | |||
| 1a4d590bf3 | |||
| b7678ab7c5 | |||
| 11730be685 | |||
| a4bc8bee74 | |||
| fafc104c30 | |||
| efa6257850 | |||
| 7e46f0012b | |||
| 58e6817efe |
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Clean workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
- name: Clean workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Clean workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
|
||||
+167
-10
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
distro:
|
||||
distro-amd64:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
@@ -29,19 +29,176 @@ jobs:
|
||||
path: viperblock
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up QEMU (for arm64 cross-build)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Build amd64 Tarball
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
|
||||
|
||||
- name: Build distribution tarballs
|
||||
run: cd spinifex && make distro VERSION=${{ github.ref_name }}
|
||||
- name: Upload amd64 Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: |
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz.sha256
|
||||
spinifex/scripts/setup.sh
|
||||
|
||||
- name: Create GitHub Release
|
||||
distro-arm64:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
path: viperblock
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build arm64 Tarball
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
|
||||
|
||||
- name: Upload arm64 Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: |
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
|
||||
|
||||
release:
|
||||
needs: [distro-amd64, distro-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download amd64 Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: artifacts
|
||||
|
||||
- name: Download arm64 Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: artifacts
|
||||
|
||||
- name: Create Draft GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
spinifex/dist/*.tar.gz
|
||||
spinifex/dist/*.sha256
|
||||
spinifex/scripts/setup.sh
|
||||
artifacts/**/*.tar.gz
|
||||
artifacts/**/*.sha256
|
||||
artifacts/**/setup.sh
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Upload Tarball for Verify Job
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/
|
||||
|
||||
verify:
|
||||
needs: release
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Download Release Assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
|
||||
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
|
||||
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
|
||||
|
||||
- name: Run Smoke Test
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
||||
-i "$SSH_KEY" \
|
||||
smoke-test-install.sh \
|
||||
"tf-user@${WAN_IP}:/tmp/smoke-test.sh"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
||||
-i "$SSH_KEY" "tf-user@${WAN_IP}" \
|
||||
'bash /tmp/smoke-test.sh'
|
||||
|
||||
- name: Promote Release
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false
|
||||
echo "Release ${TAG} promoted to latest"
|
||||
|
||||
- name: Mark Release Failed
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
|
||||
NOTES_FILE=$(mktemp)
|
||||
printf 'Verification failed — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file "$NOTES_FILE"
|
||||
rm -f "$NOTES_FILE"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
sparse-checkout: spinifex/services/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
package_json_file: spinifex/services/spinifexui/frontend/package.json
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ endif
|
||||
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
|
||||
ifdef QUIET
|
||||
_Q = @
|
||||
_COVQ = 2>&1 | grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true
|
||||
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
|
||||
_RACEQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' || true; }; exit $${PIPESTATUS[0]}
|
||||
_SECQ = >
|
||||
else
|
||||
_Q =
|
||||
_COVQ = || true
|
||||
_COVQ =
|
||||
_RACEQ =
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
@@ -79,8 +79,6 @@ test:
|
||||
LOG_IGNORE=1 go test -timeout 120s ./spinifex/...
|
||||
|
||||
# Run unit tests with coverage profile
|
||||
# Note: go test may exit non-zero due to Go version mismatch in coverage instrumentation
|
||||
# for packages without test files. We check actual test results + coverage threshold instead.
|
||||
COVERPROFILE ?= coverage.out
|
||||
test-cover:
|
||||
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
|
||||
@@ -199,11 +197,17 @@ 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"
|
||||
|
||||
# Build release tarballs for both architectures via Docker
|
||||
distro:
|
||||
@echo "Building spinifex $(VERSION) distribution tarballs..."
|
||||
# Build release tarballs — use distro-ARCH for single arch, distro for both
|
||||
distro: distro-amd64 distro-arm64
|
||||
@echo ""
|
||||
@echo "Distribution tarballs:"
|
||||
@ls -lh dist/*.tar.gz
|
||||
@echo ""
|
||||
@cat dist/*.sha256
|
||||
|
||||
distro-amd64:
|
||||
@echo "Building spinifex $(VERSION) linux/amd64..."
|
||||
@mkdir -p dist/
|
||||
@echo "Building linux/amd64..."
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
@@ -212,7 +216,10 @@ distro:
|
||||
../
|
||||
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
|
||||
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
|
||||
@echo "Building linux/arm64..."
|
||||
|
||||
distro-arm64:
|
||||
@echo "Building spinifex $(VERSION) linux/arm64..."
|
||||
@mkdir -p dist/
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--build-arg VERSION=$(VERSION) \
|
||||
@@ -221,11 +228,6 @@ distro:
|
||||
../
|
||||
tar -czf dist/spinifex-$(VERSION)-linux-arm64.tar.gz -C dist/arm64 .
|
||||
sha256sum dist/spinifex-$(VERSION)-linux-arm64.tar.gz > dist/spinifex-$(VERSION)-linux-arm64.tar.gz.sha256
|
||||
@echo ""
|
||||
@echo "Distribution tarballs:"
|
||||
@ls -lh dist/*.tar.gz
|
||||
@echo ""
|
||||
@cat dist/*.sha256
|
||||
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
@@ -233,4 +235,4 @@ distro-clean:
|
||||
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
format check-format modernize check-modernize vet security-check \
|
||||
distro distro-clean
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
@@ -295,10 +295,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
//imageStat, err = os.Stat(imageFile)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "File could not be found %s", err)
|
||||
if _, err := os.Stat(imageFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "File could not be found: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -726,6 +724,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
PredastoreNodeID: predastoreNodeID,
|
||||
Services: services,
|
||||
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
|
||||
// Generate config files
|
||||
@@ -934,6 +935,10 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
PredastoreNodeID: predastoreNodeID,
|
||||
Services: services,
|
||||
RemoteNodes: buildRemoteNodes(allNodes, node),
|
||||
|
||||
// Init node runs ovn-central locally
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
|
||||
// Generate config files
|
||||
@@ -1007,6 +1012,13 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Extract leader IP for OVN NB/SB DB address (strip port from host:port)
|
||||
leaderIP, _, err := net.SplitHostPort(leaderHost)
|
||||
if err != nil {
|
||||
// leaderHost might be an IP without port
|
||||
leaderIP = leaderHost
|
||||
}
|
||||
|
||||
// Validate IP address format
|
||||
if net.ParseIP(bindIP) == nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
|
||||
@@ -1246,6 +1258,10 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
PredastoreNodeID: predastoreNodeID,
|
||||
Services: services,
|
||||
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
|
||||
|
||||
// Joining nodes connect to the init node's OVN NB/SB DB
|
||||
OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP),
|
||||
OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP),
|
||||
}
|
||||
|
||||
// Generate config files
|
||||
|
||||
@@ -39,8 +39,8 @@ token = "{{.NatsToken}}"
|
||||
shardwal = false
|
||||
|
||||
[nodes.{{.Node}}.vpcd]
|
||||
ovn_nb_addr = "tcp:127.0.0.1:6641"
|
||||
ovn_sb_addr = "tcp:127.0.0.1:6642"
|
||||
ovn_nb_addr = "{{.OVNNBAddr}}"
|
||||
ovn_sb_addr = "{{.OVNSBAddr}}"
|
||||
|
||||
[nodes.{{.Node}}.predastore]
|
||||
host = "{{.BindIP}}:8443"
|
||||
|
||||
@@ -127,7 +127,7 @@ require (
|
||||
golang.org/x/vuln v1.1.4 // indirect
|
||||
google.golang.org/genai v1.49.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
|
||||
@@ -549,8 +549,8 @@ google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
|
||||
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Spinifex Platform Development Environment Setup
|
||||
# This script sets up a complete development environment for the Spinifex platform
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -40,11 +40,13 @@ echo "🔍 Checking dependencies..."
|
||||
|
||||
# Check for required commands
|
||||
required_commands=("go" "make")
|
||||
missing_required=false
|
||||
for cmd in "${required_commands[@]}"; do
|
||||
if command_exists "$cmd"; then
|
||||
echo "✅ $cmd found"
|
||||
else
|
||||
echo "❌ $cmd not found. Please install $cmd"
|
||||
missing_required=true
|
||||
case "$cmd" in
|
||||
"go")
|
||||
echo " Install Go: https://golang.org/dl/"
|
||||
@@ -52,6 +54,10 @@ for cmd in "${required_commands[@]}"; do
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if [ "$missing_required" = true ]; then
|
||||
echo "❌ Missing required dependencies. Cannot continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check optional commands
|
||||
optional_commands=("air" "nbdkit")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
|
||||
# Reset dev environment (will purge all data)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "Shutting down services"
|
||||
|
||||
./scripts/stop-dev.sh
|
||||
if ! ./scripts/stop-dev.sh; then
|
||||
echo "❌ Failed to stop services. Aborting reset to prevent data loss."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Removing data"
|
||||
|
||||
|
||||
+11
-5
@@ -9,7 +9,7 @@
|
||||
# Environment variables:
|
||||
# UI=false Skip starting Spinifex UI (e.g., UI=false ./scripts/start-dev.sh)
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -114,8 +114,14 @@ start_service() {
|
||||
|
||||
echo " PID: $pid, Log: $logfile"
|
||||
|
||||
# Brief pause to let service start
|
||||
# Brief pause to let service start, then verify it's still alive
|
||||
sleep 1
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo " ❌ $name failed to start (exited immediately)"
|
||||
echo " Check log: $logfile"
|
||||
tail -5 "$logfile" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to start service in foreground (for final daemon)
|
||||
@@ -302,7 +308,7 @@ if has_service "predastore"; then
|
||||
export SPINIFEX_PREDASTORE_BACKEND=distributed
|
||||
|
||||
# Auto-detect Predastore NODE_ID from spinifex.toml if not already set
|
||||
if [ -z "$SPINIFEX_PREDASTORE_NODE_ID" ]; then
|
||||
if [ -z "${SPINIFEX_PREDASTORE_NODE_ID:-}" ]; then
|
||||
if [ -f "$CONFIG_DIR/spinifex.toml" ]; then
|
||||
DETECTED_NODE_ID=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2); print $2; exit}' "$CONFIG_DIR/spinifex.toml")
|
||||
if [ -n "$DETECTED_NODE_ID" ] && [ "$DETECTED_NODE_ID" != "0" ]; then
|
||||
@@ -407,7 +413,7 @@ else
|
||||
fi
|
||||
|
||||
# 7️⃣ Start Spinifex UI (skip with UI=false or if not a local service)
|
||||
if [ "${UI}" != "false" ] && has_service "ui"; then
|
||||
if [ "${UI:-}" != "false" ] && has_service "ui"; then
|
||||
echo ""
|
||||
echo "7️⃣. Starting Spinifex UI..."
|
||||
|
||||
@@ -422,7 +428,7 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "🔗 Service endpoints will be:"
|
||||
if [ "${UI}" != "false" ]; then
|
||||
if [ "${UI:-}" != "false" ]; then
|
||||
echo " - Spinifex UI: https://localhost:3000"
|
||||
fi
|
||||
echo " - NATS: nats://localhost:4222"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Start node1
|
||||
./scripts/start-dev.sh ~/node1/
|
||||
|
||||
|
||||
# Start node2
|
||||
./scripts/start-dev.sh ~/node2/
|
||||
|
||||
|
||||
# Start node3
|
||||
./scripts/start-dev.sh ~/node3/
|
||||
|
||||
+25
-19
@@ -5,6 +5,8 @@
|
||||
# Usage: ./scripts/stop-dev.sh
|
||||
# Note: Services are stopped using PID files, so data-dir is not required
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Accept optional data directory argument
|
||||
DATA_DIR="${1:-$HOME/spinifex}"
|
||||
|
||||
@@ -65,37 +67,41 @@ is_multinode() {
|
||||
# For multi-node clusters, delegate to coordinated shutdown via NATS.
|
||||
# Only when called without arguments (default path). When a data-dir is
|
||||
# explicitly provided (e.g., E2E per-node cleanup), use per-service stop.
|
||||
if is_multinode && [ -z "$SPINIFEX_FORCE_LOCAL_STOP" ] && [ -z "$1" ]; then
|
||||
if is_multinode && [ -z "${SPINIFEX_FORCE_LOCAL_STOP:-}" ] && [ -z "${1:-}" ]; then
|
||||
echo "Multi-node cluster detected. Using coordinated shutdown..."
|
||||
echo " (Set SPINIFEX_FORCE_LOCAL_STOP=1 to force per-service stop)"
|
||||
exec $PROJECT_ROOT/bin/spx admin cluster shutdown
|
||||
fi
|
||||
|
||||
# Function to stop service by PID file
|
||||
# Function to stop a service. Sends SIGTERM and waits for the process to exit
|
||||
# so resources (ports, DB locks) are fully released before returning.
|
||||
stop_service() {
|
||||
local name="$1"
|
||||
local pidpath="$2"
|
||||
|
||||
echo "🛑 Stopping $name $pidpath..."
|
||||
echo "🛑 Stopping $name..."
|
||||
|
||||
local rc=0
|
||||
# Workaround for local development for multi-node config on single instance
|
||||
if [ -n "$pidpath" ]; then
|
||||
kill -SIGTERM $(cat "$pidpath/$name.pid" 2>/dev/null) 2>/dev/null || rc=$?
|
||||
local pid
|
||||
pid=$(cat "$pidpath/$name.pid" 2>/dev/null) || { echo "⚠️ No PID file for $name"; return 1; }
|
||||
|
||||
kill -SIGTERM "$pid" 2>/dev/null || { rm -f "$pidpath/$name.pid"; echo "✅ $name already stopped"; echo ""; return 0; }
|
||||
|
||||
# Wait for process to fully exit (up to 15s) so ports/locks are released
|
||||
local checks=0
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
else
|
||||
# Correct graceful shutdown via spx binary, waits for clean exit
|
||||
$PROJECT_ROOT/bin/spx service $name stop || rc=$?
|
||||
fi
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
echo "⚠️ Failed to stop $name (exit code $rc)"
|
||||
return 1
|
||||
else
|
||||
echo "✅ $name stopped"
|
||||
echo ""
|
||||
fi
|
||||
checks=$((checks + 1))
|
||||
if [ "$checks" -ge 15 ]; then
|
||||
echo " Force killing $name..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$pidpath/$name.pid"
|
||||
echo "✅ $name stopped"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Stop services in reverse order
|
||||
|
||||
@@ -59,6 +59,11 @@ type ConfigSettings struct {
|
||||
// Node capabilities
|
||||
Services []string
|
||||
|
||||
// OVN Northbound DB address — "tcp:127.0.0.1:6641" on the primary node,
|
||||
// "tcp:<primary-mgmt-ip>:6641" on joining nodes.
|
||||
OVNNBAddr string
|
||||
OVNSBAddr string
|
||||
|
||||
// Other nodes in the cluster (for config source of truth)
|
||||
RemoteNodes []RemoteNode
|
||||
}
|
||||
@@ -586,12 +591,22 @@ func GenerateSelfSignedCert(certPath, keyPath string) error {
|
||||
|
||||
// SetupAWSCredentials updates ~/.aws/credentials and ~/.aws/config.
|
||||
// bindIP is the IP the AWS gateway listens on. If empty or "0.0.0.0", defaults to "localhost".
|
||||
// When running under sudo, writes to SUDO_USER's home instead of root's.
|
||||
func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// When running under sudo, write to the invoking user's home directory
|
||||
// so the operator can use AWS_PROFILE=spinifex without sudo.
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if os.Getuid() == 0 && sudoUser != "" {
|
||||
if u, err := user.Lookup(sudoUser); err == nil {
|
||||
homeDir = u.HomeDir
|
||||
}
|
||||
}
|
||||
|
||||
awsDir := filepath.Join(homeDir, ".aws")
|
||||
if err := os.MkdirAll(awsDir, 0700); err != nil {
|
||||
return err
|
||||
@@ -642,6 +657,11 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
|
||||
return err
|
||||
}
|
||||
|
||||
// Fix ownership so the sudo invoking user can read the files
|
||||
if os.Getuid() == 0 && sudoUser != "" {
|
||||
ChownRecursive(awsDir, sudoUser)
|
||||
}
|
||||
|
||||
fmt.Printf(" Profile: %s\n", profileName)
|
||||
if profileName != "default" {
|
||||
fmt.Printf(" Use: export AWS_PROFILE=%s\n", profileName)
|
||||
|
||||
+174
-41
@@ -188,16 +188,17 @@ func getSystemMemory() (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// NewResourceManager creates a new resource manager with system capabilities
|
||||
func NewResourceManager() *ResourceManager {
|
||||
// NewResourceManager creates a new resource manager with system capabilities.
|
||||
// Returns an error if system memory cannot be detected, since an incorrect
|
||||
// default would either under-provision (large servers) or over-commit (small devices).
|
||||
func NewResourceManager() (*ResourceManager, error) {
|
||||
// Get system CPU cores
|
||||
numCPU := runtime.NumCPU()
|
||||
|
||||
// Get system memory (in GB)
|
||||
totalMemGB, err := getSystemMemory()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get system memory, using default of 8GB", "err", err)
|
||||
totalMemGB = 8.0 // Default to 8GB if we can't get the actual memory
|
||||
return nil, fmt.Errorf("detect system memory: %w", err)
|
||||
}
|
||||
|
||||
// Determine architecture
|
||||
@@ -217,7 +218,7 @@ func NewResourceManager() *ResourceManager {
|
||||
availableVCPU: numCPU,
|
||||
availableMem: totalMemGB,
|
||||
instanceTypes: instanceTypes,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// instanceTypeVCPUs returns the default vCPU count for an instance type, or 0 if unavailable.
|
||||
@@ -316,7 +317,7 @@ func (d *Daemon) SetConfigPath(path string) {
|
||||
}
|
||||
|
||||
// NewDaemon creates a new daemon instance
|
||||
func NewDaemon(cfg *config.ClusterConfig) *Daemon {
|
||||
func NewDaemon(cfg *config.ClusterConfig) (*Daemon, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// If WalDir is not set, use BaseDir
|
||||
@@ -327,18 +328,24 @@ func NewDaemon(cfg *config.ClusterConfig) *Daemon {
|
||||
cfg.Nodes[cfg.Node] = config
|
||||
}
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("initialize resource manager: %w", err)
|
||||
}
|
||||
|
||||
return &Daemon{
|
||||
node: cfg.Node,
|
||||
clusterConfig: cfg,
|
||||
config: &config,
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
natsSubscriptions: make(map[string]*nats.Subscription),
|
||||
startTime: time.Now(),
|
||||
detachDelay: 1 * time.Second,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// natsSub defines a single NATS subscription entry for the table-driven setup.
|
||||
@@ -845,17 +852,57 @@ func (d *Daemon) restoreInstances() {
|
||||
if ok {
|
||||
slog.Info("Re-allocating resources for instance", "instanceId", instance.ID, "type", instance.InstanceType)
|
||||
if err := d.resourceMgr.allocate(instanceType); err != nil {
|
||||
slog.Error("Failed to re-allocate resources for instance on startup", "instanceId", instance.ID, "err", err)
|
||||
slog.Error("Failed to re-allocate resources for instance on startup, moving to stopped",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
instance.Status = vm.StateStopped
|
||||
if instance.Instance != nil {
|
||||
instance.Instance.StateReason = &ec2.StateReason{}
|
||||
instance.Instance.StateReason.SetCode("Server.InsufficientInstanceCapacity")
|
||||
instance.Instance.StateReason.SetMessage(
|
||||
fmt.Sprintf("insufficient resources to restore instance: %v", err))
|
||||
}
|
||||
d.migrateStoppedToSharedKV(instance)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if QEMU process is still alive from before the restart
|
||||
if d.isInstanceProcessRunning(instance) {
|
||||
slog.Info("Instance QEMU process still alive, reconnecting", "instance", instance.ID)
|
||||
if err := d.reconnectInstance(instance); err != nil {
|
||||
slog.Error("Failed to reconnect to running instance", "instanceId", instance.ID, "err", err)
|
||||
// Verify NBD sockets are still valid. After a viperblock restart,
|
||||
// old sockets are gone and QEMU's block devices are broken. Kill
|
||||
// the orphaned QEMU and relaunch from scratch instead of
|
||||
// reconnecting to an instance with dead storage.
|
||||
if !d.areVolumeSocketsValid(instance) {
|
||||
slog.Warn("QEMU alive but NBD sockets are stale, killing orphaned process for relaunch",
|
||||
"instance", instance.ID)
|
||||
pid, pidErr := utils.ReadPidFile(instance.ID)
|
||||
if pidErr != nil || pid <= 0 {
|
||||
slog.Error("Cannot read PID for orphaned QEMU, skipping relaunch",
|
||||
"instanceId", instance.ID, "err", pidErr)
|
||||
continue
|
||||
}
|
||||
// SIGKILL directly — orphaned QEMU with dead storage has no
|
||||
// state worth a graceful shutdown, and KillProcess's 120s
|
||||
// SIGTERM timeout would block daemon startup.
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
}
|
||||
// Wait for the process to actually die, then remove the PID
|
||||
// file ourselves. SIGKILL cannot be caught, so QEMU never
|
||||
// runs its cleanup handler and the PID file stays on disk.
|
||||
if err := utils.WaitForProcessExit(pid, 10*time.Second); err != nil {
|
||||
slog.Error("Orphaned QEMU did not exit after SIGKILL, skipping relaunch",
|
||||
"instanceId", instance.ID, "pid", pid, "err", err)
|
||||
continue
|
||||
}
|
||||
_ = utils.RemovePidFile(instance.ID)
|
||||
} else {
|
||||
slog.Info("Instance QEMU process still alive, reconnecting", "instance", instance.ID)
|
||||
if err := d.reconnectInstance(instance); err != nil {
|
||||
slog.Error("Failed to reconnect to running instance", "instanceId", instance.ID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// QEMU is not running -- resolve transitional states from interrupted operations
|
||||
@@ -887,21 +934,36 @@ func (d *Daemon) restoreInstances() {
|
||||
case vm.StateRunning:
|
||||
// Was running but QEMU died - reset to pending so LaunchInstance can transition to running
|
||||
instance.Status = vm.StatePending
|
||||
// Reset LaunchTime so the pending watchdog gives a fresh timeout window.
|
||||
// Without this, the stale LaunchTime from the original launch causes the
|
||||
// watchdog to immediately mark the instance as failed.
|
||||
now := time.Now()
|
||||
if instance.Instance != nil {
|
||||
instance.Instance.LaunchTime = &now
|
||||
}
|
||||
slog.Info("Instance was running but QEMU exited, relaunching", "instance", instance.ID)
|
||||
}
|
||||
|
||||
// Reset LaunchTime so the pending watchdog gives a fresh timeout window.
|
||||
// Without this, the stale LaunchTime from the original launch causes the
|
||||
// watchdog to immediately mark the instance as failed after a prolonged outage.
|
||||
now := time.Now()
|
||||
if instance.Instance != nil {
|
||||
instance.Instance.LaunchTime = &now
|
||||
}
|
||||
toLaunch = append(toLaunch, instance)
|
||||
}
|
||||
|
||||
// Phase 2: Relaunch crashed VMs with semaphore-based throttling
|
||||
if len(toLaunch) > 0 {
|
||||
// Subscribe to per-instance NATS topics before launching so that
|
||||
// terminate/stop commands can reach this daemon while instances are
|
||||
// still being relaunched. Without this, pending instances are
|
||||
// unreachable via ec2.cmd.<id> and TerminateInstances fails.
|
||||
d.mu.Lock()
|
||||
for _, instance := range toLaunch {
|
||||
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
||||
if subErr != nil {
|
||||
slog.Error("Failed to early-subscribe during recovery", "instanceId", instance.ID, "err", subErr)
|
||||
} else {
|
||||
d.natsSubscriptions[instance.ID] = sub
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
slog.Info("Launching instances (recovery)", "count", len(toLaunch), "maxConcurrent", maxConcurrentRecovery)
|
||||
sem := make(chan struct{}, maxConcurrentRecovery)
|
||||
var wg sync.WaitGroup
|
||||
@@ -917,9 +979,19 @@ func (d *Daemon) restoreInstances() {
|
||||
slog.Error("Panic during instance recovery", "instanceId", inst.ID, "panic", r, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
// Skip if instance was terminated while waiting for semaphore
|
||||
d.Instances.Mu.Lock()
|
||||
status := inst.Status
|
||||
d.Instances.Mu.Unlock()
|
||||
if status != vm.StatePending && status != vm.StateProvisioning {
|
||||
slog.Info("Instance state changed during recovery, skipping launch",
|
||||
"instanceId", inst.ID, "status", string(status))
|
||||
return
|
||||
}
|
||||
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)
|
||||
slog.Error("Failed to launch instance during recovery", "instanceId", inst.ID, "err", err)
|
||||
d.markInstanceFailed(inst, "recovery_launch_failed")
|
||||
}
|
||||
}(instance)
|
||||
}
|
||||
@@ -945,6 +1017,32 @@ func (d *Daemon) isInstanceProcessRunning(instance *vm.VM) bool {
|
||||
return process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
// areVolumeSocketsValid checks whether the NBD Unix sockets backing an
|
||||
// instance's volumes are reachable. A dial probe (not just os.Stat) is used
|
||||
// because viperblock may restart with sockets at the same paths — the file
|
||||
// exists but no process is listening on the old fd that QEMU holds.
|
||||
func (d *Daemon) areVolumeSocketsValid(instance *vm.VM) bool {
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
defer instance.EBSRequests.Mu.Unlock()
|
||||
|
||||
for _, req := range instance.EBSRequests.Requests {
|
||||
if req.NBDURI == "" {
|
||||
continue
|
||||
}
|
||||
serverType, sockPath, _, _, err := utils.ParseNBDURI(req.NBDURI)
|
||||
if err != nil || serverType != "unix" {
|
||||
continue // TCP or unparseable — can't validate locally
|
||||
}
|
||||
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
||||
if err != nil {
|
||||
slog.Debug("NBD socket unreachable", "volume", req.Name, "socket", sockPath, "err", err)
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reconnectInstance re-establishes QMP and NATS connections to a running QEMU instance
|
||||
// after a daemon restart. This bypasses the state machine since recovery is not a
|
||||
// normal state transition.
|
||||
@@ -1253,16 +1351,23 @@ func (d *Daemon) ClusterManager() error {
|
||||
}
|
||||
})
|
||||
|
||||
// Start HTTP server in goroutine
|
||||
// Start HTTP server in goroutine — use a channel to propagate bind errors
|
||||
// back to the caller so Start() fails visibly instead of silently.
|
||||
d.clusterServer = &http.Server{
|
||||
Addr: daemonHost,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", daemonHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cluster manager listen on %s: %w", daemonHost, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting cluster manager", "host", daemonHost)
|
||||
if err := d.clusterServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("Cluster manager failed to start", "error", err)
|
||||
if err := d.clusterServer.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("Cluster manager failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -1634,6 +1739,15 @@ func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
|
||||
|
||||
func (d *Daemon) LaunchInstance(instance *vm.VM) (err error) {
|
||||
|
||||
// Abort if instance is no longer in a launchable state (e.g., terminated
|
||||
// by a concurrent request while waiting in the launch queue).
|
||||
d.Instances.Mu.Lock()
|
||||
status := instance.Status
|
||||
d.Instances.Mu.Unlock()
|
||||
if status != vm.StatePending && status != vm.StateStopped && status != vm.StateProvisioning {
|
||||
return fmt.Errorf("instance %s in %s state, not launchable", instance.ID, status)
|
||||
}
|
||||
|
||||
// First, confirm if the instance is already running
|
||||
pid, err := utils.ReadPidFile(instance.ID)
|
||||
|
||||
@@ -1728,8 +1842,19 @@ func (d *Daemon) LaunchInstance(instance *vm.VM) (err error) {
|
||||
// then completes the termination lifecycle in the background so the instance
|
||||
// reaches terminated state and doesn't get stuck in shutting-down.
|
||||
func (d *Daemon) markInstanceFailed(instance *vm.VM, reason string) {
|
||||
// Set state reason before transition (requires lock)
|
||||
// If the instance is already being cleaned up (e.g., a concurrent terminate
|
||||
// request transitioned it to shutting-down while LaunchInstance was running),
|
||||
// don't spawn a second finalizeTermination goroutine — the existing cleanup
|
||||
// handler owns the lifecycle from here.
|
||||
d.Instances.Mu.Lock()
|
||||
if instance.Status == vm.StateShuttingDown || instance.Status == vm.StateTerminated {
|
||||
d.Instances.Mu.Unlock()
|
||||
slog.Info("markInstanceFailed: instance already in cleanup state, skipping",
|
||||
"instanceId", instance.ID, "status", string(instance.Status), "reason", reason)
|
||||
return
|
||||
}
|
||||
|
||||
// Set state reason before transition
|
||||
if instance.Instance != nil {
|
||||
instance.Instance.StateReason = &ec2.StateReason{}
|
||||
instance.Instance.StateReason.SetCode("Server.InternalError")
|
||||
@@ -1964,34 +2089,42 @@ func (d *Daemon) StartInstance(instance *vm.VM) error {
|
||||
|
||||
// DEV_NETWORKING: add a second NIC with hostfwd for SSH dev access
|
||||
if d.config.Daemon.DevNetworking {
|
||||
sshDebugPort, err := viperblock.FindFreePort()
|
||||
sshDebugAddr, err := viperblock.FindFreePort()
|
||||
if err != nil {
|
||||
slog.Warn("DEV_NETWORKING: failed to find free port for dev NIC", "err", err)
|
||||
} else {
|
||||
_, sshDebugPort, _ = net.SplitHostPort(sshDebugPort)
|
||||
bindIP := d.config.Host
|
||||
if bindIP == "" || bindIP == "0.0.0.0" {
|
||||
bindIP = "127.0.0.1"
|
||||
_, sshDebugPort, err := net.SplitHostPort(sshDebugAddr)
|
||||
if err != nil {
|
||||
slog.Warn("DEV_NETWORKING: failed to parse port from address", "addr", sshDebugAddr, "err", err)
|
||||
} else {
|
||||
bindIP := d.config.Host
|
||||
if bindIP == "" || bindIP == "0.0.0.0" {
|
||||
bindIP = "127.0.0.1"
|
||||
}
|
||||
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
||||
Value: fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
|
||||
})
|
||||
devMac := generateDevMAC(instance.ID)
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: fmt.Sprintf("virtio-net-pci,netdev=dev0,mac=%s", devMac),
|
||||
})
|
||||
slog.Info("DEV_NETWORKING: added dev NIC with SSH hostfwd",
|
||||
"bindIP", bindIP, "port", sshDebugPort, "mac", devMac, "instanceId", instance.ID)
|
||||
}
|
||||
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
||||
Value: fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
|
||||
})
|
||||
devMac := generateDevMAC(instance.ID)
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: fmt.Sprintf("virtio-net-pci,netdev=dev0,mac=%s", devMac),
|
||||
})
|
||||
slog.Info("DEV_NETWORKING: added dev NIC with SSH hostfwd",
|
||||
"bindIP", bindIP, "port", sshDebugPort, "mac", devMac, "instanceId", instance.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-VPC fallback: user-mode networking with SSH port forwarding
|
||||
sshDebugPort, err := viperblock.FindFreePort()
|
||||
sshDebugAddr, err := viperblock.FindFreePort()
|
||||
if err != nil {
|
||||
slog.Error("Failed to find free port", "err", err)
|
||||
return err
|
||||
}
|
||||
_, sshDebugPort, _ = net.SplitHostPort(sshDebugPort)
|
||||
_, sshDebugPort, err := net.SplitHostPort(sshDebugAddr)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse port from address", "addr", sshDebugAddr, "err", err)
|
||||
return fmt.Errorf("parse port from %s: %w", sshDebugAddr, err)
|
||||
}
|
||||
|
||||
bindIP := d.config.Host
|
||||
if bindIP == "" || bindIP == "0.0.0.0" {
|
||||
|
||||
@@ -262,9 +262,33 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
|
||||
slog.Info("Instances added to state with pending status", "count", len(instances))
|
||||
|
||||
// Subscribe to per-instance NATS topics early so terminate/stop commands
|
||||
// can reach this daemon while volumes are being prepared. LaunchInstance
|
||||
// will replace these subscriptions when it completes.
|
||||
d.mu.Lock()
|
||||
for _, instance := range instances {
|
||||
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
||||
if subErr != nil {
|
||||
slog.Error("Failed to early-subscribe to per-instance topic", "instanceId", instance.ID, "err", subErr)
|
||||
} else {
|
||||
d.natsSubscriptions[instance.ID] = sub
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Launch all instances (volumes and VMs)
|
||||
var successCount int
|
||||
for _, instance := range instances {
|
||||
// Skip if instance was terminated by a concurrent request
|
||||
d.Instances.Mu.Lock()
|
||||
status := instance.Status
|
||||
d.Instances.Mu.Unlock()
|
||||
if status != vm.StatePending && status != vm.StateProvisioning {
|
||||
slog.Info("Instance state changed during provisioning, skipping launch",
|
||||
"instanceId", instance.ID, "status", string(status))
|
||||
continue
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
|
||||
@@ -386,7 +386,7 @@ func TestHandleEC2RunInstances_InvalidAMI(t *testing.T) {
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-nonexistent"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
@@ -412,7 +412,7 @@ func TestHandleEC2RunInstances_InvalidKeyPair(t *testing.T) {
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test123"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
KeyName: aws.String("nonexistent-keypair"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
@@ -456,7 +456,7 @@ func TestHandleEC2RunInstances_ValidKeyPairPassesValidation(t *testing.T) {
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test456"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
KeyName: aws.String("my-key"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
@@ -482,7 +482,7 @@ func TestHandleEC2RunInstances_EmptyKeyNameSkipsValidation(t *testing.T) {
|
||||
// No KeyName at all — should skip validation
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test789"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
@@ -517,7 +517,7 @@ func TestHandleEC2RunInstances_ServiceErrorPropagated(t *testing.T) {
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-propatest"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
@@ -541,7 +541,7 @@ func TestHandleEC2Events_StopInstance(t *testing.T) {
|
||||
instanceID := "i-test-stop-001"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -587,7 +587,7 @@ func TestHandleEC2Events_TerminateInstance(t *testing.T) {
|
||||
instanceID := "i-test-term-001"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -631,7 +631,7 @@ func TestHandleEC2Events_RebootRunningInstance(t *testing.T) {
|
||||
instanceID := "i-test-reboot-001"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -681,7 +681,7 @@ func TestHandleEC2Events_RebootStoppedInstance(t *testing.T) {
|
||||
instanceID := "i-test-reboot-stopped"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateStopped,
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -724,7 +724,7 @@ func TestHandleEC2Events_RebootTerminatedInstance(t *testing.T) {
|
||||
instanceID := "i-test-reboot-terminated"
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateTerminated,
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -1150,7 +1150,8 @@ func TestSetConfigPath(t *testing.T) {
|
||||
Node: "node-1",
|
||||
Nodes: map[string]config.Config{"node-1": {BaseDir: "/tmp"}},
|
||||
}
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, daemon.configPath)
|
||||
daemon.SetConfigPath("/etc/spinifex/config.toml")
|
||||
@@ -1208,7 +1209,7 @@ func TestHandleEC2StartStoppedInstance_NotStoppedState(t *testing.T) {
|
||||
runningVM := &vm.VM{
|
||||
ID: "i-running-shared",
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
err := daemon.jsManager.WriteStoppedInstance(runningVM.ID, runningVM)
|
||||
@@ -1242,7 +1243,7 @@ func TestHandleEC2DescribeStoppedInstances_ReturnsStoppedInstances(t *testing.T)
|
||||
stoppedVM := &vm.VM{
|
||||
ID: "i-describe-stopped-001",
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
LastNode: "node-1",
|
||||
AccountID: testAccountID,
|
||||
Reservation: &ec2.Reservation{
|
||||
@@ -1251,7 +1252,7 @@ func TestHandleEC2DescribeStoppedInstances_ReturnsStoppedInstances(t *testing.T)
|
||||
},
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String("i-describe-stopped-001"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
},
|
||||
}
|
||||
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
|
||||
@@ -1396,7 +1397,7 @@ func TestHandleEC2TerminateStoppedInstance_NotStoppedState(t *testing.T) {
|
||||
runningVM := &vm.VM{
|
||||
ID: "i-term-running",
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
err := daemon.jsManager.WriteStoppedInstance(runningVM.ID, runningVM)
|
||||
@@ -1428,7 +1429,7 @@ func TestHandleEC2TerminateStoppedInstance_Success(t *testing.T) {
|
||||
stoppedVM := &vm.VM{
|
||||
ID: "i-term-stopped-001",
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
LastNode: "node-1",
|
||||
AccountID: testAccountID,
|
||||
Reservation: &ec2.Reservation{
|
||||
@@ -1437,7 +1438,7 @@ func TestHandleEC2TerminateStoppedInstance_Success(t *testing.T) {
|
||||
},
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String("i-term-stopped-001"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
},
|
||||
}
|
||||
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
|
||||
@@ -1586,7 +1587,7 @@ func TestAttachVolume_ZoneMismatch(t *testing.T) {
|
||||
// Create a running instance
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{},
|
||||
@@ -2119,7 +2120,7 @@ func TestHandleNodeVMs(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
daemon.config.Daemon.Host = "10.0.0.5:4432"
|
||||
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
launchTime := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
daemon.Instances.Mu.Lock()
|
||||
@@ -2674,7 +2675,7 @@ func TestHandleEC2TerminateStoppedInstance_WithVolumes(t *testing.T) {
|
||||
ID: "i-term-vol-001",
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
LastNode: "node-1",
|
||||
Reservation: &ec2.Reservation{
|
||||
ReservationId: aws.String("r-term-vol-001"),
|
||||
@@ -2682,7 +2683,7 @@ func TestHandleEC2TerminateStoppedInstance_WithVolumes(t *testing.T) {
|
||||
},
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String("i-term-vol-001"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
},
|
||||
}
|
||||
// Add EFI, CloudInit, and a user volume with DeleteOnTermination
|
||||
@@ -2828,7 +2829,7 @@ func TestHandleEC2CreateImage_RunningInstanceReachesService(t *testing.T) {
|
||||
daemon.Instances.VMS[instanceID] = &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
@@ -2879,7 +2880,7 @@ func TestAttachVolume_MissingVolumeData(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -2919,7 +2920,7 @@ func TestAttachVolume_InstanceNotRunning(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -2960,7 +2961,7 @@ func TestAttachVolume_VolumeNotFound(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3003,7 +3004,7 @@ func TestAttachVolume_VolumeInUse(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3065,7 +3066,7 @@ func TestDetachVolume_MissingVolumeData(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3104,7 +3105,7 @@ func TestDetachVolume_InstanceNotRunning(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3145,7 +3146,7 @@ func TestDetachVolume_VolumeNotAttached(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3186,7 +3187,7 @@ func TestDetachVolume_BootVolumeRejected(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3230,7 +3231,7 @@ func TestDetachVolume_DeviceMismatch(t *testing.T) {
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
}
|
||||
@@ -3279,7 +3280,7 @@ func TestHandleEC2RunInstances_InsufficientCapacity(t *testing.T) {
|
||||
// Request a very large instance count that can't be satisfied
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
MinCount: aws.Int64(9999),
|
||||
MaxCount: aws.Int64(9999),
|
||||
}
|
||||
@@ -3359,7 +3360,7 @@ func TestHandleEC2DescribeTerminatedInstances_ReturnsTerminatedInstances(t *test
|
||||
terminatedVM := &vm.VM{
|
||||
ID: "i-describe-term-001",
|
||||
Status: vm.StateTerminated,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
LastNode: "node-1",
|
||||
AccountID: testAccountID,
|
||||
Reservation: &ec2.Reservation{
|
||||
@@ -3368,7 +3369,7 @@ func TestHandleEC2DescribeTerminatedInstances_ReturnsTerminatedInstances(t *test
|
||||
},
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String("i-describe-term-001"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
},
|
||||
}
|
||||
err := daemon.jsManager.WriteTerminatedInstance(terminatedVM.ID, terminatedVM)
|
||||
@@ -3417,7 +3418,7 @@ func TestHandleEC2DescribeTerminatedInstances_WithFilter(t *testing.T) {
|
||||
},
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(id),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
},
|
||||
}
|
||||
require.NoError(t, daemon.jsManager.WriteTerminatedInstance(v.ID, v))
|
||||
@@ -3454,7 +3455,7 @@ func TestHandleEC2TerminateStoppedInstance_WritesToTerminatedKV(t *testing.T) {
|
||||
stoppedVM := &vm.VM{
|
||||
ID: "i-stop-to-term-001",
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
require.NoError(t, daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM))
|
||||
|
||||
@@ -65,7 +65,8 @@ func createTestDaemon(t *testing.T, natsURL string) *Daemon {
|
||||
|
||||
clusterCfg.Nodes["node-1"] = *cfg
|
||||
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connect to NATS
|
||||
nc, err := nats.Connect(natsURL)
|
||||
@@ -88,8 +89,10 @@ func createTestDaemon(t *testing.T, natsURL string) *Daemon {
|
||||
}
|
||||
|
||||
// getTestInstanceType returns a valid instance type for testing based on the system's CPU
|
||||
func getTestInstanceType() string {
|
||||
rm := NewResourceManager()
|
||||
func getTestInstanceType(t *testing.T) string {
|
||||
t.Helper()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
// Find any .micro instance type
|
||||
for key := range rm.instanceTypes {
|
||||
if strings.HasSuffix(key, ".micro") {
|
||||
@@ -133,7 +136,7 @@ func TestHandleEC2RunInstances_MessageParsing(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Valid RunInstancesInput",
|
||||
input: createValidRunInstancesInput(),
|
||||
input: createValidRunInstancesInput(t),
|
||||
expectError: false,
|
||||
errorInPayload: false,
|
||||
validate: func(t *testing.T, reply *nats.Msg) {
|
||||
@@ -362,7 +365,8 @@ func TestDaemon_Initialization(t *testing.T) {
|
||||
|
||||
clusterCfg.Nodes["node-1"] = *cfg
|
||||
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, daemon)
|
||||
assert.NotNil(t, daemon.resourceMgr)
|
||||
@@ -372,7 +376,8 @@ func TestDaemon_Initialization(t *testing.T) {
|
||||
|
||||
// TestResourceManager tests resource manager functionality
|
||||
func TestResourceManager(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, rm)
|
||||
assert.Greater(t, rm.availableVCPU, 0)
|
||||
@@ -398,7 +403,7 @@ func TestResourceManager(t *testing.T) {
|
||||
assert.Equal(t, 1, canAlloc)
|
||||
|
||||
// Allocate
|
||||
err := rm.allocate(instanceType)
|
||||
err = rm.allocate(instanceType)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check resources were allocated
|
||||
@@ -421,7 +426,8 @@ func TestResourceManager(t *testing.T) {
|
||||
// Test canAllocate with count parameter
|
||||
t.Run("canAllocate_with_count", func(t *testing.T) {
|
||||
// Fresh resource manager for predictable testing
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find a .micro instance type
|
||||
var microType *ec2.InstanceTypeInfo
|
||||
@@ -457,7 +463,8 @@ func TestResourceManager(t *testing.T) {
|
||||
|
||||
// TestGetInstanceTypeInfos tests the GetInstanceTypeInfos method
|
||||
func TestGetInstanceTypeInfos(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
infos := rm.GetInstanceTypeInfos()
|
||||
|
||||
@@ -484,7 +491,8 @@ func TestGetInstanceTypeInfos(t *testing.T) {
|
||||
|
||||
// TestGetAvailableInstanceTypeInfos_ResourceFiltering tests that instance types are filtered by available resources
|
||||
func TestGetAvailableInstanceTypeInfos_ResourceFiltering(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get initial count of all available types
|
||||
allTypes := rm.GetInstanceTypeInfos()
|
||||
@@ -524,7 +532,7 @@ func TestGetAvailableInstanceTypeInfos_ResourceFiltering(t *testing.T) {
|
||||
}
|
||||
require.True(t, exists, "Should have at least one .nano instance type")
|
||||
|
||||
err := rm.allocate(nanoType)
|
||||
err = rm.allocate(nanoType)
|
||||
require.NoError(t, err, "Should be able to allocate %s", nanoKey)
|
||||
|
||||
t.Logf("After allocating %s: allocated %d vCPUs, %.2f GB RAM",
|
||||
@@ -859,21 +867,21 @@ func TestDaemon_BootAllocation(t *testing.T) {
|
||||
vms := map[string]*vm.VM{
|
||||
"i-running": {
|
||||
ID: "i-running",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Attributes: types.EC2CommandAttributes{StopInstance: false},
|
||||
},
|
||||
"i-stopped": {
|
||||
ID: "i-stopped",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
Attributes: types.EC2CommandAttributes{StopInstance: true},
|
||||
},
|
||||
"i-terminated": {
|
||||
ID: "i-terminated",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateTerminated,
|
||||
Attributes: types.EC2CommandAttributes{StopInstance: false},
|
||||
},
|
||||
@@ -884,7 +892,8 @@ func TestDaemon_BootAllocation(t *testing.T) {
|
||||
Node: "node-1",
|
||||
Nodes: map[string]config.Config{"node-1": {BaseDir: tmpDir}},
|
||||
}
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
daemon.config = &config.Config{BaseDir: tmpDir}
|
||||
|
||||
// Connect to NATS and initialize JetStream
|
||||
@@ -933,11 +942,12 @@ func TestStopInstance_Deallocation(t *testing.T) {
|
||||
Node: "node-1",
|
||||
Nodes: map[string]config.Config{"node-1": {BaseDir: "/tmp"}},
|
||||
}
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup a running instance with allocated resources
|
||||
instanceId := "i-test-stop"
|
||||
instanceTypeStr := getTestInstanceType()
|
||||
instanceTypeStr := getTestInstanceType(t)
|
||||
instanceType := daemon.resourceMgr.instanceTypes[instanceTypeStr]
|
||||
daemon.Instances.VMS[instanceId] = &vm.VM{
|
||||
ID: instanceId,
|
||||
@@ -946,7 +956,7 @@ func TestStopInstance_Deallocation(t *testing.T) {
|
||||
AccountID: testAccountID,
|
||||
}
|
||||
|
||||
err := daemon.resourceMgr.allocate(instanceType)
|
||||
err = daemon.resourceMgr.allocate(instanceType)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, daemon.resourceMgr.allocatedVCPU, 0)
|
||||
|
||||
@@ -961,10 +971,11 @@ func TestStopInstance_Deallocation(t *testing.T) {
|
||||
}
|
||||
|
||||
// createValidRunInstancesInput creates a valid RunInstancesInput for testing
|
||||
func createValidRunInstancesInput() *ec2.RunInstancesInput {
|
||||
func createValidRunInstancesInput(t *testing.T) *ec2.RunInstancesInput {
|
||||
t.Helper()
|
||||
return &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-0abcdef1234567890"),
|
||||
InstanceType: aws.String(getTestInstanceType()),
|
||||
InstanceType: aws.String(getTestInstanceType(t)),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
KeyName: aws.String("test-key"),
|
||||
@@ -976,7 +987,8 @@ func createValidRunInstancesInput() *ec2.RunInstancesInput {
|
||||
// TestCanAllocate_CountEdgeCases tests edge cases for canAllocate with count parameter
|
||||
func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
t.Run("MinCount_equals_MaxCount", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
var microType *ec2.InstanceTypeInfo
|
||||
for key, it := range rm.instanceTypes {
|
||||
@@ -994,7 +1006,8 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Request_exceeds_capacity", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the largest instance type to exhaust resources faster
|
||||
var largeType *ec2.InstanceTypeInfo
|
||||
@@ -1016,7 +1029,8 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Capacity_decreases_after_allocation", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
var microType *ec2.InstanceTypeInfo
|
||||
for key, it := range rm.instanceTypes {
|
||||
@@ -1031,7 +1045,7 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
t.Logf("Initial capacity: %d micro instances", initial)
|
||||
|
||||
// Allocate one
|
||||
err := rm.allocate(microType)
|
||||
err = rm.allocate(microType)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterOne := rm.canAllocate(microType, 100)
|
||||
@@ -1053,7 +1067,8 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Mixed_instance_types", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
var microType, mediumType *ec2.InstanceTypeInfo
|
||||
for key, it := range rm.instanceTypes {
|
||||
@@ -1071,7 +1086,7 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
initialMedium := rm.canAllocate(mediumType, 100)
|
||||
|
||||
// Allocate a medium (uses more resources)
|
||||
err := rm.allocate(mediumType)
|
||||
err = rm.allocate(mediumType)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both capacities should decrease
|
||||
@@ -1085,7 +1100,8 @@ func TestCanAllocate_CountEdgeCases(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Zero_and_negative_counts", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
var microType *ec2.InstanceTypeInfo
|
||||
for key, it := range rm.instanceTypes {
|
||||
@@ -1300,7 +1316,7 @@ func TestDescribeInstances_ReservationGrouping(t *testing.T) {
|
||||
// TestRunInstances_CountValidation tests MinCount/MaxCount validation scenarios
|
||||
func TestRunInstances_CountValidation(t *testing.T) {
|
||||
natsURL := sharedNATSURL
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
topic := fmt.Sprintf("ec2.RunInstances.%s", instanceType)
|
||||
|
||||
daemon, memStore := createFullTestDaemonWithStore(t, natsURL)
|
||||
@@ -1380,7 +1396,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
|
||||
t.Run("InitialSubscriptions", func(t *testing.T) {
|
||||
// A fresh ResourceManager should subscribe to all instance types that fit
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1409,7 +1426,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("UnsubscribesWhenFull", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1433,7 +1451,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ResubscribesWhenFreed", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1463,7 +1482,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("PartialCapacity", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1491,7 +1511,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("AllocateTriggersSubs", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1534,7 +1555,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("NoRespondersWhenFull", func(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
@@ -1551,7 +1573,7 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
assert.Equal(t, 0, len(rm.instanceSubs))
|
||||
|
||||
// Publishing to an instance type topic should get no responders
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
topic := fmt.Sprintf("ec2.RunInstances.%s", instanceType)
|
||||
|
||||
_, err = nc.Request(topic, []byte("{}"), 500*time.Millisecond)
|
||||
@@ -1562,7 +1584,8 @@ func TestInstanceTypeSubscriptions(t *testing.T) {
|
||||
|
||||
// TestResourceManager_ConcurrentAccess tests thread safety of resource manager
|
||||
func TestResourceManager_ConcurrentAccess(t *testing.T) {
|
||||
rm := NewResourceManager()
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
var microType *ec2.InstanceTypeInfo
|
||||
for key, it := range rm.instanceTypes {
|
||||
@@ -1757,7 +1780,7 @@ func TestStopInstance_DeleteOnTermination_VolumeDeletion(t *testing.T) {
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-dot",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
QMPClient: &qmp.QMPClient{}, // nil encoder/decoder => QMP will fail, which is fine
|
||||
@@ -1855,7 +1878,7 @@ func TestStopInstance_DeleteOnTermination_False_SkipsVolumeDeletion(t *testing.T
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-no-delete",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -1942,7 +1965,7 @@ func TestStopInstance_NoDelete_OnStop(t *testing.T) {
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-stop-only",
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
QMPClient: &qmp.QMPClient{},
|
||||
@@ -1989,7 +2012,7 @@ func TestHandleEC2Events_AttachVolume(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-attach"
|
||||
volumeID := "vol-test-attach"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
// Create a running instance (no actual QMP client - will fail at QMP step)
|
||||
instance := &vm.VM{
|
||||
@@ -2118,7 +2141,7 @@ func TestHandleEC2Events_DetachVolume(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-detach"
|
||||
volumeID := "vol-test-detach"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
// Create a running instance with an attached volume
|
||||
instance := &vm.VM{
|
||||
@@ -2444,7 +2467,7 @@ func TestDetachVolume_SuccessPath(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-detach-success"
|
||||
volumeID := "vol-detach-success"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
// Track QMP commands issued
|
||||
var mu sync.Mutex
|
||||
@@ -2585,7 +2608,7 @@ func TestDetachVolume_ForceFlag(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-detach-force"
|
||||
volumeID := "vol-detach-force"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
var qmpCommands []string
|
||||
@@ -2701,7 +2724,7 @@ func TestDetachVolume_BlockdevDelFailure(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-blockdev-fail"
|
||||
volumeID := "vol-blockdev-fail"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
callCount := 0
|
||||
var mu sync.Mutex
|
||||
@@ -2813,7 +2836,7 @@ func TestDetachVolume_SuccessWithDeviceMatch(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-device-match"
|
||||
volumeID := "vol-device-match"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
qmpClient, cancelQMP := newMockQMPClient(t, nil)
|
||||
defer cancelQMP()
|
||||
@@ -2897,7 +2920,7 @@ func TestAttachVolume_ReplacesStaleEBSRequest(t *testing.T) {
|
||||
|
||||
instanceID := "i-test-stale-replace"
|
||||
volumeID := "vol-stale-replace"
|
||||
instanceType := getTestInstanceType()
|
||||
instanceType := getTestInstanceType(t)
|
||||
|
||||
qmpClient, cancelQMP := newMockQMPClient(t, nil)
|
||||
defer cancelQMP()
|
||||
@@ -3219,7 +3242,8 @@ func TestNewDaemon_WalDirDefaultsToBaseDir(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
d := NewDaemon(cfg)
|
||||
d, err := NewDaemon(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/data/spinifex", d.config.WalDir)
|
||||
}
|
||||
|
||||
@@ -3234,7 +3258,8 @@ func TestNewDaemon_WalDirPreservedIfSet(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
d := NewDaemon(cfg)
|
||||
d, err := NewDaemon(cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/fast-ssd/wal", d.config.WalDir)
|
||||
}
|
||||
|
||||
@@ -3251,7 +3276,7 @@ func TestMarkInstanceFailed(t *testing.T) {
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StatePending,
|
||||
AccountID: testAccountID,
|
||||
Instance: ec2Instance,
|
||||
@@ -3283,7 +3308,7 @@ func TestMarkInstanceFailed_NilInstance(t *testing.T) {
|
||||
instanceID := "i-test-mark-failed-nil"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StatePending,
|
||||
AccountID: testAccountID,
|
||||
Instance: nil, // no ec2.Instance
|
||||
@@ -3436,7 +3461,7 @@ func TestStopTerminate_IncorrectInstanceState(t *testing.T) {
|
||||
instanceID := "i-test-state-check"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
InstanceType: getTestInstanceType(),
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{},
|
||||
@@ -3764,12 +3789,13 @@ func TestConnectNATS_RetriesOnFailure(t *testing.T) {
|
||||
},
|
||||
}
|
||||
clusterCfg.Nodes["node-1"] = cfg
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
daemon.natsMaxWait = 500 * time.Millisecond
|
||||
daemon.natsRetryDelay = 50 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
err := daemon.connectNATS()
|
||||
err = daemon.connectNATS()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err)
|
||||
@@ -3790,10 +3816,11 @@ func TestConnectNATS_SucceedsImmediately(t *testing.T) {
|
||||
},
|
||||
}
|
||||
clusterCfg.Nodes["node-1"] = cfg
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
err := daemon.connectNATS()
|
||||
err = daemon.connectNATS()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -3811,7 +3838,8 @@ func TestDaemonReadyFlag(t *testing.T) {
|
||||
}
|
||||
cfg := config.Config{}
|
||||
clusterCfg.Nodes["node-1"] = cfg
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, daemon.ready.Load(), "daemon should not be ready before Start()")
|
||||
|
||||
|
||||
@@ -259,10 +259,25 @@ func (d *Daemon) restartCrashedInstance(instance *vm.VM) {
|
||||
"instance", instance.ID,
|
||||
"restartCount", instance.Health.RestartCount)
|
||||
|
||||
// Re-allocate resources before relaunch — handleCrashedInstance deallocates
|
||||
// them, so we must re-reserve before starting the VM again.
|
||||
instanceType, ok := d.resourceMgr.instanceTypes[instance.InstanceType]
|
||||
if !ok || instanceType == nil {
|
||||
slog.Error("Unknown instance type during restart, cannot re-allocate resources",
|
||||
"instance", instance.ID, "type", instance.InstanceType)
|
||||
return
|
||||
}
|
||||
if err := d.resourceMgr.allocate(instanceType); err != nil {
|
||||
slog.Error("Insufficient resources to restart crashed instance",
|
||||
"instance", instance.ID, "type", instance.InstanceType, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Transition Error → Pending (valid now that we added it to the transitions map)
|
||||
if err := d.TransitionState(instance, vm.StatePending); err != nil {
|
||||
slog.Error("Failed to transition instance to pending for restart",
|
||||
"instance", instance.ID, "err", err)
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -271,6 +286,7 @@ func (d *Daemon) restartCrashedInstance(instance *vm.VM) {
|
||||
if err := d.LaunchInstance(instance); err != nil {
|
||||
slog.Error("Failed to restart crashed instance",
|
||||
"instance", instance.ID, "err", err)
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
if err := d.TransitionState(instance, vm.StateError); err != nil {
|
||||
slog.Error("Failed to transition instance back to error after restart failure",
|
||||
"instance", instance.ID, "err", err)
|
||||
|
||||
@@ -51,9 +51,12 @@ func TestHandleInstanceCrash_SkipsNonRunning(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
natsConn: nc,
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
|
||||
@@ -73,9 +76,12 @@ func TestHandleInstanceCrash_SkipsShuttingDown(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
natsConn: nc,
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
d.shuttingDown.Store(true)
|
||||
@@ -97,13 +103,16 @@ func TestMaybeRestart_ExceedsMaxInWindow(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
natsConn: nc,
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
|
||||
@@ -139,13 +148,16 @@ func TestMaybeRestart_ResetsAfterWindow(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
natsConn: nc,
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
|
||||
@@ -206,13 +218,16 @@ func newTestDaemon(t *testing.T) (*Daemon, func()) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
natsConn: nc,
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
return d, func() { nc.Close() }
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
|
||||
// TestBuildHeartbeat verifies that buildHeartbeat populates the struct from daemon state.
|
||||
func TestBuildHeartbeat(t *testing.T) {
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
clusterConfig: &config.ClusterConfig{
|
||||
@@ -20,7 +23,7 @@ func TestBuildHeartbeat(t *testing.T) {
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon", "nats", "viperblock"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
|
||||
@@ -38,6 +41,9 @@ func TestBuildHeartbeat(t *testing.T) {
|
||||
|
||||
// TestHeartbeatReflectsAllocation verifies that allocating resources changes the heartbeat values.
|
||||
func TestHeartbeatReflectsAllocation(t *testing.T) {
|
||||
rm, err := NewResourceManager()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "alloc-node",
|
||||
clusterConfig: &config.ClusterConfig{
|
||||
@@ -46,7 +52,7 @@ func TestHeartbeatReflectsAllocation(t *testing.T) {
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
resourceMgr: rm,
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
|
||||
@@ -64,7 +70,7 @@ func TestHeartbeatReflectsAllocation(t *testing.T) {
|
||||
require.NotEmpty(t, allocType, "Should have at least one allocatable instance type")
|
||||
|
||||
// Allocate resources
|
||||
err := d.resourceMgr.allocate(d.resourceMgr.instanceTypes[allocType])
|
||||
err = d.resourceMgr.allocate(d.resourceMgr.instanceTypes[allocType])
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a VM to the instance map
|
||||
|
||||
@@ -40,8 +40,12 @@ func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
||||
// 0. If tap already exists (e.g. unclean shutdown), clean it up first
|
||||
if _, err := os.Stat("/sys/class/net/" + tapName); err == nil {
|
||||
slog.Warn("Stale tap device found, cleaning up before recreate", "tap", tapName)
|
||||
_ = sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).Run()
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
if err := sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).Run(); err != nil {
|
||||
slog.Warn("Failed to remove stale tap from br-int", "tap", tapName, "err", err)
|
||||
}
|
||||
if err := sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run(); err != nil {
|
||||
slog.Warn("Failed to delete stale tap device", "tap", tapName, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Create tap device
|
||||
@@ -51,7 +55,9 @@ func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
||||
|
||||
// 2. Bring tap up
|
||||
if out, err := sudoCommand("ip", "link", "set", tapName, "up").CombinedOutput(); err != nil {
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
if cleanErr := sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run(); cleanErr != nil {
|
||||
slog.Warn("Failed to clean up tap device after bring-up failure", "tap", tapName, "err", cleanErr)
|
||||
}
|
||||
return fmt.Errorf("bring up tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
@@ -62,7 +68,9 @@ func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
||||
fmt.Sprintf("external_ids:iface-id=%s", ifaceID),
|
||||
fmt.Sprintf("external_ids:attached-mac=%s", mac),
|
||||
).CombinedOutput(); err != nil {
|
||||
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
||||
if cleanErr := sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run(); cleanErr != nil {
|
||||
slog.Warn("Failed to clean up tap device after OVS failure", "tap", tapName, "err", cleanErr)
|
||||
}
|
||||
return fmt.Errorf("add tap to br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -50,7 +52,8 @@ func createDaemonWithJetStream(t *testing.T) *Daemon {
|
||||
Node: "node-1",
|
||||
Nodes: map[string]config.Config{"node-1": {BaseDir: tmpDir}},
|
||||
}
|
||||
daemon := NewDaemon(clusterCfg)
|
||||
daemon, err := NewDaemon(clusterCfg)
|
||||
require.NoError(t, err)
|
||||
daemon.config = &config.Config{BaseDir: tmpDir}
|
||||
|
||||
nc, err := nats.Connect(natsURL)
|
||||
@@ -1043,3 +1046,152 @@ func TestPendingWatchdog_MarksStuckInstanceFailed(t *testing.T) {
|
||||
freshInst := daemon.Instances.VMS["i-fresh"]
|
||||
assert.Equal(t, vm.StatePending, freshInst.Status)
|
||||
}
|
||||
|
||||
func TestAreVolumeSocketsValid_ValidSocket(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
// Create a real Unix socket listener
|
||||
sockPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
listener, err := net.Listen("unix", sockPath)
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-valid-sock",
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: "vol-1", NBDURI: "nbd:unix:" + sockPath},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.True(t, daemon.areVolumeSocketsValid(instance))
|
||||
}
|
||||
|
||||
func TestAreVolumeSocketsValid_MissingSocket(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-missing-sock",
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: "vol-1", NBDURI: "nbd:unix:/nonexistent/path.sock"},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.False(t, daemon.areVolumeSocketsValid(instance))
|
||||
}
|
||||
|
||||
func TestAreVolumeSocketsValid_StaleSocketFile(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
// Create a socket file with no listener — simulates viperblock crash
|
||||
// leaving a stale socket on disk
|
||||
sockPath := filepath.Join(t.TempDir(), "stale.sock")
|
||||
listener, err := net.Listen("unix", sockPath)
|
||||
require.NoError(t, err)
|
||||
listener.Close() // close listener but leave file on disk
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-stale-sock",
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: "vol-1", NBDURI: "nbd:unix:" + sockPath},
|
||||
},
|
||||
},
|
||||
}
|
||||
// os.Stat would return true here, but dial should fail
|
||||
assert.False(t, daemon.areVolumeSocketsValid(instance))
|
||||
}
|
||||
|
||||
func TestAreVolumeSocketsValid_EmptyRequests(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-no-vols",
|
||||
EBSRequests: types.EBSRequests{},
|
||||
}
|
||||
assert.True(t, daemon.areVolumeSocketsValid(instance))
|
||||
}
|
||||
|
||||
func TestAreVolumeSocketsValid_TCPTransport(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-tcp",
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: "vol-tcp", NBDURI: "nbd://192.168.1.1:10809"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// TCP transport can't be validated locally — should return true
|
||||
assert.True(t, daemon.areVolumeSocketsValid(instance))
|
||||
}
|
||||
|
||||
func TestMarkInstanceFailed_AlreadyShuttingDown(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-already-shutting",
|
||||
Status: vm.StateShuttingDown,
|
||||
Instance: &ec2.Instance{},
|
||||
}
|
||||
daemon.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// Should be a no-op — instance is already being cleaned up
|
||||
daemon.markInstanceFailed(instance, "test_reason")
|
||||
|
||||
// Status should not change
|
||||
assert.Equal(t, vm.StateShuttingDown, instance.Status)
|
||||
}
|
||||
|
||||
func TestMarkInstanceFailed_AlreadyTerminated(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-already-term",
|
||||
Status: vm.StateTerminated,
|
||||
Instance: &ec2.Instance{},
|
||||
}
|
||||
daemon.Instances.VMS[instance.ID] = instance
|
||||
|
||||
daemon.markInstanceFailed(instance, "test_reason")
|
||||
|
||||
// Status should not change
|
||||
assert.Equal(t, vm.StateTerminated, instance.Status)
|
||||
}
|
||||
|
||||
func TestRestoreInstances_ProvisioningInstanceLaunchTimeReset(t *testing.T) {
|
||||
daemon := createDaemonWithJetStream(t)
|
||||
|
||||
// Provisioning instances (daemon crashed during first launch) should get
|
||||
// their LaunchTime reset so the pending watchdog doesn't immediately kill
|
||||
// them after a prolonged outage.
|
||||
staleTime := time.Now().Add(-30 * time.Minute)
|
||||
daemon.Instances.VMS["i-provisioning"] = &vm.VM{
|
||||
ID: "i-provisioning",
|
||||
Status: vm.StateProvisioning,
|
||||
Instance: &ec2.Instance{LaunchTime: &staleTime},
|
||||
}
|
||||
require.NoError(t, daemon.WriteState())
|
||||
|
||||
daemon.Instances.VMS = make(map[string]*vm.VM)
|
||||
before := time.Now()
|
||||
simulateCleanRestore(t, daemon)
|
||||
|
||||
// LoadState inside restoreInstances creates fresh objects, so look up
|
||||
// from the map. The instance may have been removed by markInstanceFailed
|
||||
// (launch fails in test env), so load directly from JetStream state.
|
||||
require.NoError(t, daemon.LoadState())
|
||||
instance := daemon.Instances.VMS["i-provisioning"]
|
||||
if instance == nil {
|
||||
// Instance was cleaned up by markInstanceFailed — that's fine.
|
||||
// The important thing is that the LaunchTime WAS reset before
|
||||
// the launch attempt. We can't verify it post-cleanup, so skip.
|
||||
t.Skip("Instance was cleaned up during recovery — LaunchTime was reset before launch attempt")
|
||||
}
|
||||
require.NotNil(t, instance.Instance)
|
||||
require.NotNil(t, instance.Instance.LaunchTime)
|
||||
assert.True(t, instance.Instance.LaunchTime.After(before) || instance.Instance.LaunchTime.Equal(before),
|
||||
"LaunchTime should be reset for provisioning instances, got %v", *instance.Instance.LaunchTime)
|
||||
}
|
||||
|
||||
@@ -154,8 +154,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
// Compare signatures using constant-time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(providedSignature)) != 1 {
|
||||
slog.Debug("Signature mismatch",
|
||||
"expected", expectedSignature,
|
||||
"provided", providedSignature,
|
||||
"accessKeyId", accessKey,
|
||||
)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
|
||||
@@ -269,6 +269,8 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(xmlOutput)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write EC2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -321,7 +321,9 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
_, _ = w.Write(xmlError)
|
||||
if _, err := w.Write(xmlError); err != nil {
|
||||
slog.Error("Failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse AWS query arguments (used by some services like EC2/S3)
|
||||
|
||||
@@ -131,6 +131,8 @@ func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) err
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(xmlOutput)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write IAM response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func New(cfg any) (svc *Service, err error) {
|
||||
|
||||
func (svc *Service) Start() (int, error) {
|
||||
if err := utils.WritePidFileTo(svc.Config.NodeBaseDir(), serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
err := launchService(svc.Config)
|
||||
if err != nil {
|
||||
@@ -51,7 +51,14 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.NodeBaseDir(), serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
|
||||
@@ -38,7 +38,7 @@ func New(config any) (svc *Service, err error) {
|
||||
|
||||
func (svc *Service) Start() (int, error) {
|
||||
if err := utils.WritePidFileTo(svc.Config.DataDir, serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
err := launchService(svc.Config)
|
||||
if err != nil {
|
||||
@@ -52,7 +52,14 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.DataDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
@@ -99,7 +106,7 @@ func launchService(config *Config) (err error) {
|
||||
|
||||
}
|
||||
|
||||
fmt.Println(opts)
|
||||
slog.Debug("NATS server options", "opts", opts)
|
||||
|
||||
// Initialize new server with options
|
||||
ns, err := server.NewServer(opts)
|
||||
|
||||
@@ -57,7 +57,7 @@ func New(config any) (svc *Service, err error) {
|
||||
// Start starts the predastore service
|
||||
func (svc *Service) Start() (int, error) {
|
||||
if err := utils.WritePidFileTo(svc.Config.BasePath, serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
|
||||
server, err := s3.NewServer(
|
||||
@@ -106,7 +106,14 @@ func (svc *Service) Stop() error {
|
||||
|
||||
// Status returns the status of the predastore service
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BasePath, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the predastore service
|
||||
|
||||
@@ -100,10 +100,10 @@ func TestServiceMethods(t *testing.T) {
|
||||
svc, err := New(cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Status - returns empty string and no error
|
||||
// Test Status - returns "stopped" when no PID file exists
|
||||
status, err := svc.Status()
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, status)
|
||||
assert.Equal(t, "stopped", status)
|
||||
|
||||
// Test Reload - returns no error
|
||||
err = svc.Reload()
|
||||
|
||||
@@ -34,7 +34,7 @@ func (svc *Service) SetConfigPath(path string) {
|
||||
|
||||
func (svc *Service) Start() (int, error) {
|
||||
if err := utils.WritePidFileTo(svc.Config.NodeBaseDir(), serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
err := launchService(svc.Config, svc.ConfigPath)
|
||||
if err != nil {
|
||||
@@ -49,7 +49,14 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.NodeBaseDir(), serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
@@ -61,8 +68,10 @@ func (svc *Service) Reload() (err error) {
|
||||
}
|
||||
|
||||
func launchService(config *config.ClusterConfig, configPath string) (err error) {
|
||||
|
||||
d := daemon.NewDaemon(config)
|
||||
d, err := daemon.NewDaemon(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create daemon: %w", err)
|
||||
}
|
||||
d.SetConfigPath(configPath)
|
||||
slog.Info("Starting Spinifex daemon ...")
|
||||
err = d.Start()
|
||||
|
||||
@@ -100,7 +100,10 @@ func (svc *Service) Stop() error {
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFile(serviceName)
|
||||
if err != nil {
|
||||
return "stopped", nil
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
@@ -121,9 +121,8 @@ func makeSnapshotHandler(vb *viperblock.VB, volumeName string) nats.MsgHandler {
|
||||
}
|
||||
|
||||
func (svc *Service) Start() (int, error) {
|
||||
|
||||
if err := utils.WritePidFileTo(svc.Config.BaseDir, serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
err := launchService(svc.Config)
|
||||
|
||||
@@ -140,7 +139,14 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BaseDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
@@ -609,7 +615,7 @@ func launchService(cfg *Config) (err error) {
|
||||
PluginPath: cfg.PluginPath,
|
||||
BaseDir: cfg.BaseDir,
|
||||
Host: cfg.S3Host,
|
||||
Verbose: true,
|
||||
Verbose: false,
|
||||
Size: utils.SafeUint64ToInt64(vb.GetVolumeSize()),
|
||||
Volume: ebsRequest.Name,
|
||||
Bucket: cfg.Bucket,
|
||||
|
||||
@@ -155,10 +155,10 @@ func TestServiceMethods(t *testing.T) {
|
||||
svc, err := New(cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Status - returns empty string and no error
|
||||
// Test Status - returns "stopped" when no PID file exists
|
||||
status, err := svc.Status()
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, status)
|
||||
assert.Equal(t, "stopped", status)
|
||||
|
||||
// Test Reload - returns no error
|
||||
err = svc.Reload()
|
||||
|
||||
@@ -580,8 +580,9 @@ func (h *TopologyHandler) handleIGWAttach(msg *nats.Msg) {
|
||||
} else {
|
||||
vpcCIDR := router.ExternalIDs["spinifex:cidr"]
|
||||
if vpcCIDR == "" {
|
||||
// Fall back to a reasonable default — list subnets for this VPC
|
||||
vpcCIDR = "10.0.0.0/8"
|
||||
slog.Warn("vpcd: VPC CIDR missing from router metadata, using overbroad fallback",
|
||||
"router", routerName, "fallbackCIDR", vpcCIDR, "vpcId", evt.VpcId)
|
||||
}
|
||||
snatRule := &nbdb.NAT{
|
||||
Type: "snat",
|
||||
@@ -653,6 +654,8 @@ func (h *TopologyHandler) handleIGWDetach(msg *nats.Msg) {
|
||||
vpcCIDR := router.ExternalIDs["spinifex:cidr"]
|
||||
if vpcCIDR == "" {
|
||||
vpcCIDR = "10.0.0.0/8"
|
||||
slog.Warn("vpcd: VPC CIDR missing from router metadata, using overbroad fallback for NAT cleanup",
|
||||
"router", routerName, "fallbackCIDR", vpcCIDR)
|
||||
}
|
||||
if err := h.ovn.DeleteNAT(ctx, routerName, "snat", vpcCIDR); err != nil {
|
||||
slog.Warn("vpcd: failed to delete SNAT rule", "router", routerName, "err", err)
|
||||
@@ -739,7 +742,11 @@ func respond(msg *nats.Msg, err error) {
|
||||
resp.Error = err.Error()
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(resp)
|
||||
data, marshalErr := json.Marshal(resp)
|
||||
if marshalErr != nil {
|
||||
slog.Error("vpcd: failed to marshal NATS response", "err", marshalErr)
|
||||
data = []byte(`{"success":false,"error":"internal marshal failure"}`)
|
||||
}
|
||||
if err := msg.Respond(data); err != nil {
|
||||
slog.Error("vpcd: failed to respond to NATS request", "err", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func New(config any) (*Service, error) {
|
||||
// Start starts the vpcd service.
|
||||
func (svc *Service) Start() (int, error) {
|
||||
if err := utils.WritePidFileTo(svc.Config.BaseDir, serviceName, os.Getpid()); err != nil {
|
||||
slog.Error("Failed to write pid file", "err", err)
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
|
||||
err := launchService(svc.Config)
|
||||
@@ -79,7 +79,14 @@ func (svc *Service) Stop() error {
|
||||
|
||||
// Status returns the vpcd service status.
|
||||
func (svc *Service) Status() (string, error) {
|
||||
return "", nil
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BaseDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the vpcd service.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -263,25 +264,25 @@ func extractDiskImagePath(imagedir string, output []byte) (diskimage string, err
|
||||
r := bufio.NewReader(reader)
|
||||
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
line = strings.Replace(line, "\n", "", 1)
|
||||
line, readErr := r.ReadString('\n')
|
||||
line = strings.TrimRight(line, "\n")
|
||||
|
||||
// MacOS tar, filenames begin with `x FILE` (to STDERR)
|
||||
if runtime.GOOS == "darwin" && strings.HasPrefix(line, "x ") {
|
||||
line = strings.Replace(line, "x ", "", 1)
|
||||
|
||||
}
|
||||
|
||||
if strings.HasSuffix(line, ".raw") || strings.HasSuffix(line, ".img") {
|
||||
diskimage := fmt.Sprintf("%s/%s", imagedir, line)
|
||||
|
||||
err = validateDiskImagePath(diskimage)
|
||||
|
||||
return diskimage, err
|
||||
}
|
||||
|
||||
if err != nil && err.Error() == "EOF" {
|
||||
break
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("read tar output: %w", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +297,10 @@ func validateDiskImagePath(diskimage string) (err error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command("file", args...)
|
||||
output, _ := cmd.CombinedOutput()
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("run file command on %s: %w", diskimage, err)
|
||||
}
|
||||
|
||||
filetype := strings.Split(string(output), ":")
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ func ConnectNATS(host, token string) (*nats.Conn, error) {
|
||||
nats.ReconnectWait(time.Second),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
|
||||
slog.Debug("NATS disconnected", "err", err)
|
||||
slog.Warn("NATS disconnected", "err", err)
|
||||
}),
|
||||
nats.ReconnectHandler(func(nc *nats.Conn) {
|
||||
slog.Debug("NATS reconnected", "url", nc.ConnectedUrl())
|
||||
slog.Warn("NATS reconnected", "url", nc.ConnectedUrl())
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -159,6 +160,32 @@ func pidPath() string {
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
// WaitForProcessExit polls until the given PID is no longer alive or the
|
||||
// timeout expires. Unlike WaitForPidFileRemoval, this checks the process
|
||||
// itself via kill(pid, 0), so it works after SIGKILL where the target
|
||||
// cannot clean up its own PID file.
|
||||
func WaitForProcessExit(pid int, timeout time.Duration) error {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("timeout waiting for process %d to exit", pid)
|
||||
case <-ticker.C:
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return nil // process gone
|
||||
}
|
||||
if proc.Signal(syscall.Signal(0)) != nil {
|
||||
return nil // process no longer alive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WaitForPidFileRemoval(instanceID string, timeout time.Duration) error {
|
||||
timeoutCh := time.After(timeout)
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
|
||||
@@ -76,7 +76,8 @@ start_node_services() {
|
||||
echo "Starting services for node$node_num at $node_ip..."
|
||||
|
||||
# Start all services - each node's config binds to its specific IP
|
||||
./scripts/start-dev.sh "$data_dir"
|
||||
# UI is not needed for E2E tests and fails in pseudo multi-node (wrong cert path)
|
||||
UI=false ./scripts/start-dev.sh "$data_dir"
|
||||
|
||||
echo "Node$node_num services started"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user