mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-24 21:23:51 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1331b3785 | |||
| 341f36a432 | |||
| 5800f39e42 | |||
| 7e356b5b9b | |||
| 331e46951f | |||
| 6ba9e11461 | |||
| 1d1b1f6a51 | |||
| 5b8f20b89f | |||
| 6389b37433 | |||
| 0708ac4816 | |||
| 9cec5a50c6 | |||
| d96bc3c095 | |||
| 04c7efde19 | |||
| 95973eeb3c | |||
| e79ca59d4b | |||
| 61319ba4a4 | |||
| 6e13a9d9f4 | |||
| 12085f029c | |||
| 4803469037 | |||
| eae776b2f8 | |||
| f50f69caff | |||
| f682d09a72 | |||
| 1d7d5e1750 | |||
| e26a480e54 | |||
| b505887b36 | |||
| 1d55404a73 | |||
| 5001a04b6a | |||
| f595129c7a | |||
| a36c6d819e | |||
| 528bb1e483 | |||
| 8989fc9ba8 | |||
| a55544b7d4 | |||
| f9c9f0b38d | |||
| d6b356b658 | |||
| e0259b7638 | |||
| 88b57b1805 | |||
| 318e43c66f | |||
| 7e8cbeef6e | |||
| d2efbee043 | |||
| 344a490a70 | |||
| 62e7f983ee | |||
| 68abc0bfb3 | |||
| cae76ba1b6 | |||
| c054b0213e | |||
| a3885db916 | |||
| 369c237f01 | |||
| 2119981924 | |||
| 5fd74a8212 | |||
| 291e550e8a | |||
| 826a441beb | |||
| 2e7136c893 | |||
| 4793b5ac09 | |||
| 3e0aab174a | |||
| 10ce0fba8f | |||
| 77c0e337a6 | |||
| 4274184ba4 |
@@ -0,0 +1,52 @@
|
||||
name: Resolve OTEL job id
|
||||
description: >-
|
||||
Append ci.job_id to SPINIFEX_OTEL_ATTRS so telemetry emitted by this job can
|
||||
be pivoted straight back to its GitHub job log. Must run before the step that
|
||||
bakes SPINIFEX_OTEL_ATTRS into a VM's telemetry.env, and requires
|
||||
`actions: read` on the calling job.
|
||||
|
||||
inputs:
|
||||
job-name:
|
||||
description: >-
|
||||
Rendered name of the calling job, exactly as it appears in the Actions UI.
|
||||
github.job is only the YAML key and no context exposes the numeric id, so
|
||||
the id has to be looked up by name.
|
||||
required: true
|
||||
token:
|
||||
description: Token used for the Actions jobs API lookup.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve CI job id (best-effort)
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.token }}
|
||||
JOB_NAME: ${{ inputs.job-name }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# Never fail the caller: on any lookup error or name mismatch ci.job_id
|
||||
# is simply omitted and the run proceeds with the labels it already has.
|
||||
set +e
|
||||
|
||||
if [ -z "${SPINIFEX_OTEL_ATTRS:-}" ]; then
|
||||
echo "SPINIFEX_OTEL_ATTRS is unset or empty — telemetry is disabled, nothing to label"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# env.JOB_NAME rather than shell interpolation into the jq filter: job
|
||||
# names carry quotes, slashes and non-ASCII separators that would
|
||||
# otherwise break the expression.
|
||||
JOB_ID=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs" --paginate \
|
||||
--jq '.jobs[] | select(.name == env.JOB_NAME) | .id' 2>/dev/null | head -n1)
|
||||
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
echo "SPINIFEX_OTEL_ATTRS=${SPINIFEX_OTEL_ATTRS},ci.job_id=${JOB_ID}" >> "$GITHUB_ENV"
|
||||
echo "resolved ci.job_id=${JOB_ID} for job '${JOB_NAME}'"
|
||||
else
|
||||
echo "::warning::could not resolve numeric job id for '${JOB_NAME}' — continuing without ci.job_id"
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,58 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034 # every variable here is consumed by the sourcing workflow
|
||||
# Source from a workflow `run:` block:
|
||||
# . "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
#
|
||||
# Which e2e suites each topology can run. Sourced by every workflow that drives
|
||||
# suites (e2e.yml, e2e-suites.yml, e2e-nightly.yml) so the lists cannot drift
|
||||
# apart — they previously disagreed three ways.
|
||||
#
|
||||
# Eligibility is a property of the suite, not of the workflow: a suite belongs
|
||||
# to a topology when all of its assertions are reachable there. lb is
|
||||
# multi-only for exactly that reason — on a single node every internet-facing
|
||||
# subtest skips for want of a peer, so a single-node lb run reports green
|
||||
# having exercised a fraction of the suite.
|
||||
|
||||
# Suites runnable against a one-node environment.
|
||||
#
|
||||
# partialblock gates merges because it earned it, not because it is new: on one
|
||||
# node, same binary and workload, it fails against the pre-fix engine (1758 of
|
||||
# 8192 halves reverted a generation) and passes against the fixed one (0 of
|
||||
# 8192). A suite that has never been shown to go red for the reason it claims
|
||||
# does not belong here.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock"
|
||||
|
||||
# Suites runnable against a multi-node environment.
|
||||
E2E_SUITES_MULTI="multinode lb cert"
|
||||
|
||||
# grep -xE alternation form, for narrowing a requested set to the eligible one.
|
||||
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
|
||||
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
|
||||
|
||||
# e2e-nightly runs a narrower set than the two sets above, deliberately: its
|
||||
# ~19 cells each have a 35-minute budget and exist to prove that every
|
||||
# install / network / host-OS permutation boots and serves. Suite breadth is
|
||||
# the PR gate's job, and it already runs on every push. These are subsets, and
|
||||
# e2e_suites_assert_subset below enforces that.
|
||||
E2E_SUITES_NIGHTLY_SINGLE="single cert"
|
||||
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
|
||||
|
||||
# Fail loudly when a narrowed list names a suite its topology cannot run. This
|
||||
# is the guard that stops the lists drifting again: a suite added to a nightly
|
||||
# subset without also being declared eligible above stops the job here rather
|
||||
# than silently skipping half its assertions in CI.
|
||||
# e2e_suites_assert_subset <label> <subset> <eligible-set>
|
||||
e2e_suites_assert_subset() {
|
||||
local label="$1" subset="$2" eligible="$3" suite found eligible_suite
|
||||
for suite in ${subset}; do
|
||||
found=0
|
||||
for eligible_suite in ${eligible}; do
|
||||
if [ "${suite}" = "${eligible_suite}" ]; then found=1; break; fi
|
||||
done
|
||||
if [ "${found}" -ne 1 ]; then
|
||||
echo "::error::${label} names suite '${suite}', which is not eligible for that topology (eligible: ${eligible}). Fix the list in .github/scripts/suite-sets.sh." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
@@ -9,6 +9,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
otel-attrs:
|
||||
description: "OTEL_RESOURCE_ATTRIBUTES value for this run, e.g. ci.run_id=123,ci.cell=20 (caller-specific, so it's an input rather than a workflow-level default)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,6 +21,10 @@ permissions:
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
# Same central ES/APM sink as the other four e2e workflows. Bare-metal had
|
||||
# no default here before — this cell's telemetry never reached the sink at
|
||||
# all (not just missing ci.cell), see bm-bootstrap.sh's telemetry.env step.
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
|
||||
jobs:
|
||||
e2e_baremetal:
|
||||
@@ -28,6 +37,9 @@ jobs:
|
||||
env:
|
||||
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-20-${{ github.run_id }}
|
||||
# Caller-supplied (workflow_call input); empty for a manual
|
||||
# workflow_dispatch run, which bm-bootstrap.sh treats as "no attrs".
|
||||
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
|
||||
@@ -13,6 +13,9 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the otel-job-id action look up this job's numeric id via
|
||||
# the Actions API — github.job is only the YAML key, no context exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -28,6 +31,10 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
|
||||
# Job-level env wins over the workflow-level default. ci.cell=ddil marks
|
||||
# this workflow's telemetry so a DDIL run's partition-induced errors are
|
||||
# never mistaken for a healthy multi-node run's in the sink.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=ddil
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
@@ -72,6 +79,13 @@ jobs:
|
||||
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
|
||||
"${BIN_DIR}/gotestsum" --version
|
||||
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VMs, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: DDIL Real Multi-Node
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the "Resolve CI job id" step below look up this job's
|
||||
# numeric id via the Actions API — github.job is only the YAML key, no
|
||||
# context exposes the id itself.
|
||||
actions: read
|
||||
|
||||
# Scheduled runs are loaded from the default branch (main), so the schedule
|
||||
# trigger must stay in sync on main for cron to fire.
|
||||
@@ -46,6 +50,11 @@ jobs:
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
# Job-level env wins over the workflow-level default above, so telemetry
|
||||
# from this cell carries ci.cell/ci.matrix. Nine cells run under one
|
||||
# run id and every cell names its hosts node1/node2/node3, so without
|
||||
# this every cell's telemetry collapses into one unattributable bucket.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.cell }},ci.matrix=${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}
|
||||
|
||||
steps:
|
||||
# blank or scheduled run = full matrix.
|
||||
@@ -92,6 +101,16 @@ jobs:
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
# Nine cells share one run id and every cell names its hosts
|
||||
# node1/node2/node3, so ci.cell above separates them and ci.job_id here
|
||||
# links each cell's telemetry to the job log that produced it. Runs
|
||||
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
|
||||
- name: Resolve CI job id
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
|
||||
- name: Build Install Artifacts
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -147,13 +166,20 @@ jobs:
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Nightly runs a documented subset of the single-node set; the assert
|
||||
# fails the cell if that subset ever names a suite this topology
|
||||
# cannot fully exercise.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly single" "${E2E_SUITES_NIGHTLY_SINGLE}" "${E2E_SUITES_SINGLE}"
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
|
||||
for SUITE in single cert lb; do \
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_SINGLE}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
@@ -190,6 +216,11 @@ jobs:
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# See the single-node step: same subset guard, multi-node set.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly multi" "${E2E_SUITES_NIGHTLY_MULTI}" "${E2E_SUITES_MULTI}"
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
@@ -204,7 +235,7 @@ jobs:
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
|
||||
for SUITE in multinode cert lb; do \
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_MULTI}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
|
||||
@@ -373,4 +404,8 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
# A reusable-workflow call gets none of this workflow's context (env:
|
||||
# above, matrix.cell) for free — cell-20 needs its own attrs string.
|
||||
# There's no matrix leg here, just the one fixed baremetal cell.
|
||||
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=20,ci.matrix=baremetal/pxe/real-hw
|
||||
secrets: inherit
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
# Restructures the looped e2e.yml into env-grouped job blocks so each suite
|
||||
# surfaces as its own node in the Actions graph (mulga-siv-283):
|
||||
#
|
||||
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks, iam)
|
||||
# single_provision ─┬─ single_suite (matrix: the single-node-eligible suites)
|
||||
# └─ single_teardown (if: always)
|
||||
# multi_provision ─┬─ multi_suite (matrix: multinode)
|
||||
# multi_provision ─┬─ multi_suite (matrix: the multi-node-eligible suites)
|
||||
# └─ multi_teardown (if: always)
|
||||
#
|
||||
# Both matrices come from .github/scripts/suite-sets.sh, the one definition of
|
||||
# which suites a topology can run, shared with e2e.yml and e2e-nightly.yml.
|
||||
#
|
||||
# Constraint: tofu state is runner-filesystem-local, so *_provision and
|
||||
# *_teardown must execute on the same self-hosted runner (one runner per label
|
||||
# today). Teardown reuses the persisted mulga/ tofu-cluster workspace and does
|
||||
@@ -25,9 +28,13 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
single_suites:
|
||||
description: "Single-node suites to run as matrix legs"
|
||||
description: "Single-node suites to run as matrix legs (empty = every single-node-eligible suite, per .github/scripts/suite-sets.sh)"
|
||||
required: false
|
||||
default: "single cert lb eks iam"
|
||||
default: ""
|
||||
multi_suites:
|
||||
description: "Multi-node suites to run as matrix legs (empty = every multi-node-eligible suite, per .github/scripts/suite-sets.sh)"
|
||||
required: false
|
||||
default: ""
|
||||
run_multinode:
|
||||
description: "Also run the multinode env block"
|
||||
type: boolean
|
||||
@@ -40,6 +47,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# actions: read lets the otel-job-id action look up a provision job's numeric
|
||||
# id via the Actions API — github.job is only the YAML key, no context
|
||||
# exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -64,6 +75,13 @@ jobs:
|
||||
matrix: ${{ steps.suites.outputs.matrix }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
# This job (not single_suite) is where "Bootstrap via Binary Install"
|
||||
# bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env, before the
|
||||
# suite matrix even exists — single_suite's legs share this one
|
||||
# already-booted VM, so daemon telemetry can't be scoped any finer
|
||||
# than "single env" per suite leg. ci.cell=single distinguishes it
|
||||
# from multi_provision's VMs within the same run id.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -115,12 +133,22 @@ jobs:
|
||||
- name: Re-enable Go toolchain auto-download
|
||||
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
|
||||
|
||||
# Default and allow-list both come from the shared definition, so this
|
||||
# workflow cannot drift from e2e.yml about what a single node can run.
|
||||
- name: Compute suite matrix
|
||||
id: suites
|
||||
env:
|
||||
REQUESTED: ${{ inputs.single_suites }}
|
||||
run: |
|
||||
LIST="${{ inputs.single_suites || 'single cert lb eks iam' }}"
|
||||
set -euo pipefail
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
LIST="${REQUESTED:-${E2E_SUITES_SINGLE}}"
|
||||
e2e_suites_assert_subset "single_suites" "${LIST}" "${E2E_SUITES_SINGLE}"
|
||||
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
|
||||
JSON=$(printf '%s\n' $LIST | jq -R . | jq -cs .)
|
||||
# Unquoted on purpose: the space-separated list must word-split into
|
||||
# one JSON element per suite.
|
||||
# shellcheck disable=SC2086
|
||||
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reclaim docker build cache
|
||||
@@ -130,6 +158,14 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# ci.job_id names the provision job, not the suite job that later drives
|
||||
# the env: the suite legs share this env and its baked telemetry.env, so
|
||||
# the provisioner is the only job the daemon telemetry can belong to.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single env · provision
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -497,8 +533,13 @@ jobs:
|
||||
node1_ip: ${{ steps.cluster.outputs.node1_ip }}
|
||||
ssh_key: ${{ steps.cluster.outputs.ssh_key }}
|
||||
nodes_csv: ${{ steps.cluster.outputs.nodes_csv }}
|
||||
suites: ${{ steps.suites.outputs.list }}
|
||||
matrix: ${{ steps.suites.outputs.matrix }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
# See single_provision above — daemon telemetry is baked in here, at
|
||||
# provision time, before the multi_suite matrix exists.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -554,6 +595,12 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# See the matching note on single_provision.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi env · provision
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -601,14 +648,36 @@ jobs:
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build multinode binary on runner
|
||||
# Mirrors single_provision's step: the multi leg previously ran multinode
|
||||
# alone, silently dropping the lb and cert coverage that only a
|
||||
# multi-node env can complete.
|
||||
- name: Compute suite matrix
|
||||
id: suites
|
||||
env:
|
||||
REQUESTED: ${{ inputs.multi_suites }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
LIST="${REQUESTED:-${E2E_SUITES_MULTI}}"
|
||||
e2e_suites_assert_subset "multi_suites" "${LIST}" "${E2E_SUITES_MULTI}"
|
||||
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
|
||||
# Unquoted on purpose: the space-separated list must word-split into
|
||||
# one JSON element per suite.
|
||||
# shellcheck disable=SC2086
|
||||
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build multinode binaries on runner
|
||||
working-directory: spinifex/tests/e2e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
banner "build multinode, teardown"
|
||||
# teardown always builds: the run-scoped sweep binary runs in the
|
||||
# teardown job, it is never a selectable suite leg.
|
||||
SUITES_BUILD="${{ steps.suites.outputs.list }} teardown"
|
||||
banner "build ${SUITES_BUILD// /, }"
|
||||
group_open "go test -c"
|
||||
make e2e-build SUITES_BUILD="multinode teardown"
|
||||
make e2e-build SUITES_BUILD="${SUITES_BUILD}"
|
||||
group_close
|
||||
ok "$(basename _bin/multinode.test) $(stat -c '%s' _bin/multinode.test | numfmt --to=iec)"
|
||||
|
||||
@@ -652,7 +721,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [multinode]
|
||||
suite: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
|
||||
env:
|
||||
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
|
||||
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
|
||||
|
||||
+48
-72
@@ -26,10 +26,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
# actions: read lets the otel-job-id action look up a job's numeric id via
|
||||
# the Actions API — github.job is only the YAML key, no context exposes it.
|
||||
actions: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam' }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
|
||||
# empty string disables telemetry entirely.
|
||||
@@ -37,57 +40,28 @@ env:
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
# In-process integration front gate. Provisions nothing -- no tofu, no
|
||||
# docker, no Spinifex daemons, no fixed ports -- so it runs directly on the
|
||||
# runner host rather than inside a provisioned VM, and completes in seconds.
|
||||
#
|
||||
# It runs on ci-single rather than a GitHub-hosted runner so it shares the
|
||||
# toolchain and sibling-repo build of the live suites, and so the run stays
|
||||
# entirely on the LAN that hosts the Elastic sink. Holding a ci-single slot
|
||||
# for seconds is negligible against the 50-minute e2e_single that follows.
|
||||
#
|
||||
# e2e_single and e2e_multi both require this job to succeed, so a red
|
||||
# integration run is caught before either provisions a tofu env or pays the
|
||||
# Spinifex install -- which is the expensive half, not the tofu apply.
|
||||
integration:
|
||||
name: Integration (front gate)
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# This runner is shared with e2e_single, which leaves root-owned tofu and
|
||||
# mount artifacts behind; a plain checkout would fail on them. Unmount
|
||||
# anything still bound under the workspace before wiping it.
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Run in-process integration tests
|
||||
run: make test-integration
|
||||
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
needs: [integration]
|
||||
# The integration front gate must be green: a red integration run stops
|
||||
# here, before this job provisions a tofu env or pays the Spinifex install.
|
||||
#
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists (single|iam|cert|eks|ecs|storagegrowth). Gating on a suite the
|
||||
# job won't run — lb, which only executes in the multinode job — would
|
||||
# allow-lists, i.e. E2E_SUITES_SINGLE in .github/scripts/suite-sets.sh —
|
||||
# an `if:` expression cannot source that file, so this one list is
|
||||
# hand-mirrored and must be updated with it. Gating on a suite the job
|
||||
# won't run — lb, which only executes in the multinode job — would
|
||||
# provision a tofu VM and pay the Spinifex install only to run nothing and
|
||||
# tear down. Push triggers keep running via the null-check on inputs.suites.
|
||||
if: needs.integration.result == 'success' && (github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth'))
|
||||
# tear down. Push triggers keep running via the null-check on
|
||||
# inputs.suites.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock')
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
|
||||
# Job-level env wins over the workflow-level default. This job and
|
||||
# e2e_multi share one run id and both name their hosts node1/node2/node3,
|
||||
# so without ci.cell their telemetry collapses into one bucket. This job
|
||||
# is where "Bootstrap via Binary Install" bakes SPINIFEX_OTEL_ATTRS into
|
||||
# the VM's telemetry.env, so the label has to be set here.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -152,6 +126,13 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VM, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single-Node E2E
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -317,8 +298,11 @@ jobs:
|
||||
set +e
|
||||
# Allow-list, not a deny-list: a suite runs here only if named. Deny-lists
|
||||
# silently fan a newly added suite out to every job, which is how lb ended up
|
||||
# running twice per e2e run.
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE 'single|iam|cert|eks|ecs|storagegrowth' | tr '\n' ' ')
|
||||
# running twice per e2e run. The allow-list itself is shared with the other
|
||||
# e2e workflows so the three cannot disagree about what a topology can run;
|
||||
# suite-sets.sh also carries the per-suite rationale.
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_SINGLE_RE}" | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
@@ -495,19 +479,19 @@ jobs:
|
||||
|
||||
e2e_multi:
|
||||
name: Multi-Node E2E
|
||||
needs: [integration]
|
||||
# Gated on the integration front gate for the same reason as e2e_single:
|
||||
# the 3-node env is never stood up if the control plane is already broken.
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists (multinode|lb|cert) so lb — which only runs here — still
|
||||
# provisions the 3-node env when requested on its own, and the env stays
|
||||
# unprovisioned when none of them are asked for.
|
||||
if: needs.integration.result == 'success' && (github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert'))
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
|
||||
# See the matching note on e2e_single: ci.cell is what separates this
|
||||
# job's 3-node telemetry from the single-node job's under one run id.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -572,6 +556,12 @@ jobs:
|
||||
docker buildx prune -af --filter "until=24h" || true
|
||||
docker system df || true
|
||||
|
||||
# See the matching note on e2e_single.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi-Node E2E
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
@@ -714,7 +704,8 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
# Allow-list, not a deny-list — see the single-node job for why.
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE 'multinode|lb|cert' | tr '\n' ' ')
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_MULTI_RE}" | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
@@ -930,7 +921,7 @@ jobs:
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always()
|
||||
needs: [integration, e2e_single, e2e_multi]
|
||||
needs: [e2e_single, e2e_multi]
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -939,22 +930,15 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPORT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# integration must have actually succeeded -- unlike e2e_single/multi,
|
||||
# "skipped" is never an acceptable result for it (it has no `if:` of
|
||||
# its own), so a red or skipped integration run fails the gate here
|
||||
# even though e2e_single/e2e_multi themselves report "skipped" (their
|
||||
# `if:` never let them start).
|
||||
integration_ok=true
|
||||
case "${{ needs.integration.result }}" in success) ;; *) integration_ok=false ;; esac
|
||||
single_ok=true; multi_ok=true
|
||||
case "${{ needs.e2e_single.result }}" in success|skipped) ;; *) single_ok=false ;; esac
|
||||
case "${{ needs.e2e_multi.result }}" in success|skipped) ;; *) multi_ok=false ;; esac
|
||||
if $integration_ok && $single_ok && $multi_ok; then
|
||||
if $single_ok && $multi_ok; then
|
||||
STATE="success"
|
||||
DESC="E2E suites passed"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="E2E failed: integration=${{ needs.integration.result }} single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
fi
|
||||
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
|
||||
-f state="$STATE" \
|
||||
@@ -971,7 +955,7 @@ jobs:
|
||||
# status-report check.
|
||||
e2e_es_summary:
|
||||
name: ES CI Run Summary
|
||||
needs: [integration, e2e_single, e2e_multi]
|
||||
needs: [e2e_single, e2e_multi]
|
||||
if: always()
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 5
|
||||
@@ -1011,7 +995,6 @@ jobs:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
INTEGRATION_RESULT: ${{ needs.integration.result }}
|
||||
SINGLE_RESULT: ${{ needs.e2e_single.result }}
|
||||
MULTI_RESULT: ${{ needs.e2e_multi.result }}
|
||||
SINGLE_DIR: ${{ github.workspace }}/es-summary-single
|
||||
@@ -1040,20 +1023,15 @@ jobs:
|
||||
FAILED_JSON=$(printf '%s' "$FAILED_SUITES" | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
FAILED_COUNT=$(printf '%s' "$FAILED_JSON" | jq 'length')
|
||||
|
||||
# Mirror e2e_status's integration_ok/single_ok/multi_ok logic exactly
|
||||
# so the PR status and this ES doc can never disagree on overall
|
||||
# pass/fail. Like e2e_status, "skipped" is only acceptable for
|
||||
# single/multi (their `if:` may legitimately not select them); a
|
||||
# skipped or failed integration always fails the gate.
|
||||
integration_ok=true
|
||||
case "$INTEGRATION_RESULT" in success) ;; *) integration_ok=false ;; esac
|
||||
# Mirror e2e_status's single_ok/multi_ok logic exactly so the PR
|
||||
# status and this ES doc can never disagree on overall pass/fail.
|
||||
single_ok=true
|
||||
case "$SINGLE_RESULT" in success | skipped) ;; *) single_ok=false ;; esac
|
||||
multi_ok=true
|
||||
case "$MULTI_RESULT" in success | skipped) ;; *) multi_ok=false ;; esac
|
||||
|
||||
STATE=success
|
||||
if [ "$integration_ok" != true ] || [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
if [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
STATE=failure
|
||||
fi
|
||||
|
||||
@@ -1070,7 +1048,6 @@ jobs:
|
||||
--arg sha "${SHA}" \
|
||||
--arg run_attempt "${RUN_ATTEMPT}" \
|
||||
--arg state "${STATE}" \
|
||||
--arg integration_result "${INTEGRATION_RESULT}" \
|
||||
--arg single_result "${SINGLE_RESULT}" \
|
||||
--arg multi_result "${MULTI_RESULT}" \
|
||||
--argjson duration "${DURATION}" \
|
||||
@@ -1081,7 +1058,6 @@ jobs:
|
||||
labels: { ci_run_id: $run_id },
|
||||
ci: { workflow: $workflow, branch: $branch, sha: $sha, run_attempt: $run_attempt },
|
||||
state: $state,
|
||||
integration_result: $integration_result,
|
||||
single_result: $single_result,
|
||||
multi_result: $multi_result,
|
||||
failed_suites: $failed_suites,
|
||||
|
||||
@@ -44,32 +44,6 @@ on:
|
||||
description: "Optional -test.run regex scoped to tests/e2e/gpu (empty = run all GPU tests)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths: &gpu_paths
|
||||
- "spinifex/gpu/**"
|
||||
- "spinifex/instancetypes/**"
|
||||
- "spinifex/daemon/gpu_adapter.go"
|
||||
- "spinifex/daemon/gpu_pool_reconciler.go"
|
||||
- "spinifex/handlers/ecs/gpu_report.go"
|
||||
- "spinifex/handlers/eks/nodegroup.go"
|
||||
- "spinifex/handlers/eks/nodegroup_userdata.go"
|
||||
- "spinifex/vm/vm.go"
|
||||
- "spinifex/vm/lifecycle.go"
|
||||
- "cmd/ecs-agent/gpu_discovery.go"
|
||||
- "cmd/ecs-agent/gpu_ledger.go"
|
||||
- "cmd/spinifex/cmd/admin_gpu.go"
|
||||
- "tests/e2e/gpu/**"
|
||||
- "scripts/images/eks-node-gpu/**"
|
||||
- "scripts/images/ecs-agent-gpu/**"
|
||||
- ".github/workflows/gpu-e2e.yml"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths: *gpu_paths
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -88,7 +62,11 @@ env:
|
||||
# the GPU suite's own daemon-side spans are emitted by wattle's already-
|
||||
# configured obs-agent, not by this job.
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
# ci.cell=gpu keeps this workflow's telemetry separable from the tofu-env
|
||||
# workflows'. Nothing in this job bakes these attrs into a daemon (see the
|
||||
# note above), so the label only takes effect once wattle's obs-agent is
|
||||
# given them; there is deliberately no ci.job_id lookup for the same reason.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=gpu
|
||||
WATTLE_SSH_HOST: ${{ vars.WATTLE_SSH_HOST || '192.168.1.13' }}
|
||||
WATTLE_SSH_USER: ${{ vars.WATTLE_SSH_USER || 'tf-user' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
|
||||
@@ -68,15 +68,6 @@ jobs:
|
||||
- name: Manifest check + drift lint
|
||||
run: make -C spinifex manifest-check manifest-lint
|
||||
|
||||
# In-process integration tier (gateway.SetupRoutes over embedded NATS
|
||||
# JetStream). Runs here, on every PR, rather than only relying on
|
||||
# e2e.yml's "Integration (front gate)" job: that job is push-triggered
|
||||
# against main/dev on a self-hosted runner, so it never blocks a PR
|
||||
# before merge. This step provisions nothing and completes in seconds,
|
||||
# so it belongs as an ordinary required PR check, not a separate job.
|
||||
- name: Run Integration Tests
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
run: make -C spinifex test-cover
|
||||
|
||||
@@ -86,6 +77,55 @@ jobs:
|
||||
- name: Check Diff Coverage
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
# In-process integration tier (gateway.SetupRoutes over embedded NATS
|
||||
# JetStream). Its own job so it runs in parallel with the unit tiers rather
|
||||
# than adding to their wall time, and it still blocks a PR — unlike e2e.yml's
|
||||
# "Integration (front gate)" job, which is push-triggered against main/dev on
|
||||
# a self-hosted runner and so never gates a PR before merge.
|
||||
integration_tests:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
race_detection:
|
||||
name: Race Detection
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
+56
-38
@@ -1,5 +1,8 @@
|
||||
version: "2"
|
||||
|
||||
issues:
|
||||
max-same-issues: 10
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
@@ -14,7 +17,6 @@ linters:
|
||||
- bidichk # checks for dangerous unicode bidi character sequences
|
||||
- bodyclose # checks whether HTTP response body is closed successfully
|
||||
- canonicalheader # checks whether net/http.Header uses canonical header keys
|
||||
- decorder # checks declaration order of types, consts, vars, and funcs
|
||||
- depguard # blocks imports of packages with a preferred replacement
|
||||
- durationcheck # checks for two durations multiplied together
|
||||
- embeddedstructfieldcheck # checks embedded sync.Mutex/RWMutex in structs
|
||||
@@ -24,17 +26,20 @@ linters:
|
||||
- errorlint # catches ==/!= on errors, type assertions on errors, and %v instead of %w — all break on wrapped errors
|
||||
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- forbidigo # blocks the legacy JetStream/KV symbols
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- godot # checks that declaration comments end in a period
|
||||
- goprintffuncname # checks that printf-like functions are named with f at the end
|
||||
- gosec # inspects source code for security problems
|
||||
- govet # reports suspicious constructs (Printf alignment, shadow, etc.)
|
||||
- iface # detects interface pollution — duplicate interface declarations
|
||||
- ineffassign # detects assignments to existing variables that are not used
|
||||
- iotamixing # flags const blocks that mix iota with non-iota declarations
|
||||
- makezero # finds slice declarations with non-zero initial length followed by append
|
||||
- mirror # catches wrong mirror patterns in bytes/strings usage
|
||||
- misspell # finds commonly misspelled English words
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- nakedret # finds naked returns in functions
|
||||
- nilerr # catches code that returns nil even after checking err != nil
|
||||
- nilnesserr # catches returning a different nil error variable after err != nil check
|
||||
@@ -55,9 +60,6 @@ linters:
|
||||
- whitespace # detects leading and trailing whitespace in functions
|
||||
|
||||
settings:
|
||||
embeddedstructfieldcheck:
|
||||
forbid-mutex: true
|
||||
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
@@ -67,11 +69,32 @@ linters:
|
||||
- pkg: math/rand$
|
||||
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
|
||||
|
||||
embeddedstructfieldcheck:
|
||||
forbid-mutex: true
|
||||
|
||||
errcheck:
|
||||
check-type-assertions: true # replaces forcetypeassert
|
||||
exclude-functions:
|
||||
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
|
||||
|
||||
forbidigo:
|
||||
analyze-types: true
|
||||
forbid:
|
||||
- pattern: '\.JetStream$'
|
||||
pkg: '^github\.com/nats-io/nats\.go$'
|
||||
msg: legacy nc.JetStream() is superseded; construct with jetstream.New(nc).
|
||||
- pattern: '^nats\.(JetStreamContext|KeyValue.*|KeyWatcher|Err(KeyNotFound|KeyExists|BucketNotFound|NoKeysFound|StreamNotFound))$'
|
||||
pkg: '^github\.com/nats-io/nats\.go$'
|
||||
msg: legacy nats KV types/sentinels are superseded; use github.com/nats-io/nats.go/jetstream.
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unchecked errors — errcheck is stricter
|
||||
- G116 # bidi chars — bidichk is more thorough
|
||||
- G117 # use of net/http serve functions (we handle TLS separately)
|
||||
- G703 # path traversal taint analysis — false positives on config file loading
|
||||
- G705 # XSS taint analysis — false positives on internal error responses
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
@@ -79,6 +102,28 @@ linters:
|
||||
- shadow # too noisy to retrofit — all 50 findings are err re-declarations
|
||||
- unusedwrite # false positives on struct field assignments before marshal/send
|
||||
|
||||
modernize:
|
||||
disable:
|
||||
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
|
||||
- stringsbuilder # don't replace small string concatenations with strings.Builder
|
||||
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
|
||||
perfsprint:
|
||||
concat-loop: false
|
||||
err-error: false # don't flag err.Error()
|
||||
errorf: false # don't convert fmt.Errorf to errors.New
|
||||
strconcat: false
|
||||
|
||||
reassign:
|
||||
patterns:
|
||||
- ".*"
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
@@ -90,40 +135,8 @@ linters:
|
||||
- -ST1020 # godoc on exported types — not enforcing yet
|
||||
- -ST1021 # godoc on exported type groups — not enforcing yet
|
||||
- -ST1022 # godoc on exported vars/consts — not enforcing yet
|
||||
- -SA1019 # deprecated usage (handle separately)
|
||||
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
|
||||
- -SA9005 # ineffective break (staticcheck false positives)
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unchecked errors — errcheck is stricter
|
||||
- G116 # bidi chars — bidichk is more thorough
|
||||
- G117 # use of net/http serve functions (we handle TLS separately)
|
||||
- G703 # path traversal taint analysis — false positives on config file loading
|
||||
- G705 # XSS taint analysis — false positives on internal error responses
|
||||
- G706 # log injection taint analysis — false positives on structured slog usage
|
||||
|
||||
modernize:
|
||||
disable:
|
||||
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
|
||||
- stringsbuilder # don't replace small string concatenations with strings.Builder
|
||||
|
||||
perfsprint:
|
||||
concat-loop: false
|
||||
err-error: false # don't flag err.Error()
|
||||
errorf: false # don't convert fmt.Errorf to errors.New
|
||||
strconcat: false
|
||||
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
|
||||
reassign:
|
||||
patterns:
|
||||
- ".*"
|
||||
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
|
||||
|
||||
testifylint:
|
||||
disable:
|
||||
@@ -153,3 +166,8 @@ linters:
|
||||
linters: [gosec] # subprocess execution — legitimate in VM orchestration
|
||||
- text: "G304"
|
||||
linters: [gosec] # file path from variable — necessary for config loading
|
||||
|
||||
# aws-sdk-go v1 is deprecated wholesale (EOL 2025-07-31); the v1->v2
|
||||
# migration is tracked separately, so suppress its SA1019 noise for now.
|
||||
- text: "aws-sdk-go is deprecated"
|
||||
linters: [staticcheck]
|
||||
|
||||
@@ -120,10 +120,15 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
|
||||
|
||||
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
|
||||
# Use this before committing to catch CI failures locally.
|
||||
# Preflight — the pre-commit gate: manifest checks, lint, vuln, and the unit,
|
||||
# race and e2e-harness tiers.
|
||||
#
|
||||
# The integration tier is deliberately NOT here. CI runs it as its own parallel
|
||||
# job, so it is still gated before merge, and keeping it out leaves preflight
|
||||
# the fast common-path check — run `make test-integration` directly when a
|
||||
# change touches the gateway router or the NATS subjects it drives.
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness test-integration
|
||||
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
@@ -137,10 +142,10 @@ test-harness:
|
||||
# JetStream, with only the daemon-side NATS subjects stubbed. Build-tagged
|
||||
# `integration` so it's skipped by the default `go test ./spinifex/...` and by
|
||||
# `test-cover`/`test-race`. Nothing provisioned — no tofu, no docker, no
|
||||
# Spinifex daemons, so the whole package runs in well under a minute — part
|
||||
# of `preflight` (and its own PR-blocking CI step) rather than the
|
||||
# self-hosted, push-triggered live e2e tiers, so a regression here is caught
|
||||
# before it can be merged, not just after.
|
||||
# Spinifex daemons, so the whole package runs in well under a minute. It gets
|
||||
# its own PR-blocking CI job rather than the self-hosted, push-triggered live
|
||||
# e2e tiers, so a regression here is caught before it can be merged, not just
|
||||
# after.
|
||||
test-integration:
|
||||
@echo -e "\n....Running in-process integration tests...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
|
||||
@@ -266,6 +271,13 @@ govulncheck:
|
||||
$(_Q)go tool govulncheck ./...
|
||||
@echo " govulncheck ok"
|
||||
|
||||
# NilAway — advisory nil-panic analysis. Not in preflight: it has a known
|
||||
# false-positive rate, so findings are triaged by hand rather than gating commits.
|
||||
nilaway:
|
||||
@echo "Running nilaway..."
|
||||
$(_Q)go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./...
|
||||
@echo " nilaway ok"
|
||||
|
||||
# Build release tarballs — use distro-ARCH for single arch, distro for both
|
||||
distro: distro-amd64 distro-arm64
|
||||
@echo ""
|
||||
@@ -312,5 +324,5 @@ distro-clean:
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness test-integration manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
lint fix govulncheck nilaway \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
+9
-25
@@ -6,7 +6,7 @@
|
||||
// Usage (must run as root — needs AF_PACKET):
|
||||
//
|
||||
// sudo dhcptest --iface=br-wan
|
||||
// sudo dhcptest --iface=br-wan --mac=02:00:00:7c:b7:b9 --promisc
|
||||
// sudo dhcptest --iface=br-wan --mac=02:54:df:71:22:5c --promisc
|
||||
// sudo dhcptest --iface=br-wan --spinifex-id=gateway-wan1
|
||||
package main
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -44,9 +44,11 @@ func main() {
|
||||
|
||||
switch {
|
||||
case *spinifexID != "":
|
||||
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
|
||||
// vpcd's own derivation, so the probe leases under the MAC the daemon
|
||||
// would have used for this id.
|
||||
hwAddr, err = dhcp.DeriveMAC(*spinifexID)
|
||||
if err != nil {
|
||||
slog.Error("Could not parse generated MAC", "spinifex-id", *spinifexID, "error", err)
|
||||
slog.Error("Could not derive MAC", "spinifex-id", *spinifexID, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
|
||||
@@ -109,17 +111,9 @@ func main() {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Identity modifiers — client-id / hostname / vendor-class for upstream lease tagging.
|
||||
var mods []dhcpv4.Modifier
|
||||
if cid != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
|
||||
}
|
||||
if *hostname != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(*hostname)))
|
||||
}
|
||||
if *vendorClass != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(*vendorClass)))
|
||||
}
|
||||
// Identity modifiers — client-id / hostname / vendor-class for upstream lease
|
||||
// tagging. Built by the vpcd code path so the probe cannot drift from it.
|
||||
mods := dhcp.IdentityModifiers(cid, *hostname, *vendorClass, hwAddr)
|
||||
|
||||
fmt.Println("Sending DHCP Discover...")
|
||||
start := time.Now()
|
||||
@@ -153,16 +147,6 @@ func (slogPrintfer) Printf(format string, v ...any) {
|
||||
slog.Info(fmt.Sprintf(format, v...), "component", "nclient4")
|
||||
}
|
||||
|
||||
// generateMAC is the same deterministic hash used by vpcd's topology.go.
|
||||
// It produces a locally-administered MAC (02:00:00:xx:xx:xx) from a resource ID.
|
||||
func generateMAC(resourceID string) string {
|
||||
h := uint32(0)
|
||||
for _, c := range resourceID {
|
||||
h = h*31 + uint32(c)
|
||||
}
|
||||
return fmt.Sprintf("02:00:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
|
||||
}
|
||||
|
||||
func ifaceMAC(name string) (net.HardwareAddr, error) {
|
||||
ifc, err := net.InterfaceByName(name)
|
||||
if err != nil {
|
||||
|
||||
+167
-48
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/gpu"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/hostdns"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/host"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -339,6 +340,7 @@ func init() {
|
||||
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
|
||||
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
|
||||
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
|
||||
adminInitCmd.Flags().Bool("skip-host-dns", false, "Do not point this node's host resolver at its local northstar (LB/EKS names then won't resolve from the node)")
|
||||
|
||||
// Flags for admin join
|
||||
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
|
||||
@@ -355,6 +357,7 @@ func init() {
|
||||
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
|
||||
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
|
||||
adminJoinCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
|
||||
adminJoinCmd.Flags().Bool("skip-host-dns", false, "Do not point this node's host resolver at its local northstar (LB/EKS names then won't resolve from the node)")
|
||||
adminJoinCmd.Flags().Int("predastore-compaction-interval", 0, "Predastore compactor interval in seconds (0 = unset, uses built-in default). Test clusters set a short interval.")
|
||||
adminJoinCmd.MarkFlagRequired("node")
|
||||
adminJoinCmd.MarkFlagRequired("host")
|
||||
@@ -707,7 +710,25 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// command, which would collapse the path to a relative config/ca.pem.
|
||||
bakeCACertIntoImage(extractedImagePath, filepath.Join(baseDir, "config", "ca.pem"))
|
||||
|
||||
err = v_utils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath)
|
||||
// Render the flush bar here rather than inside viperblock, which stays a
|
||||
// pure storage library. The bar is built lazily on the first update so it is
|
||||
// never drawn if the import fails before any bytes flush; viperblock
|
||||
// throttles the callback to ≤101 invocations, so the human-readable title
|
||||
// renders without the per-block render-frequency regression.
|
||||
var flushBar *pterm.ProgressbarPrinter
|
||||
var flushUpdate func(current uint64)
|
||||
progress := func(current, total uint64) {
|
||||
if flushBar == nil {
|
||||
flushBar, flushUpdate = utils.NewByteProgressBar("Flushing image to storage", total)
|
||||
}
|
||||
flushUpdate(current)
|
||||
}
|
||||
|
||||
err = v_utils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath, progress)
|
||||
|
||||
if flushBar != nil {
|
||||
_, _ = flushBar.Stop()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not import image to predastore: %v\n", err)
|
||||
@@ -1190,10 +1211,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Detect DNS servers from the host for VM DHCP
|
||||
// Validate IP address format
|
||||
if net.ParseIP(bindIP) == nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve the off-host advertise IP before detecting DNS. DetectNetwork may
|
||||
// have failed earlier, so retry lazily when the listener still needs a WAN IP.
|
||||
if advertiseFlag == "" && (bindIP == "0.0.0.0" || bindIP == "127.0.0.1") && detectedNet == nil {
|
||||
if d, derr := admin.DetectNetwork(); derr == nil {
|
||||
detectedNet = d
|
||||
}
|
||||
}
|
||||
advertiseIP, err := resolveAdvertiseIP(bindIP, advertiseFlag, detectedNet)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Exclude the node-local Northstar listener from its own upstreams. On a
|
||||
// forced re-init, resolvconf may still list this address first; forwarding it
|
||||
// back to Northstar creates a recursive DNS loop.
|
||||
var dnsServers []string
|
||||
if externalMode != "" {
|
||||
dnsServers = detectDNSServers(externalIface)
|
||||
dnsServers = detectDNSServers(externalIface, advertiseIP)
|
||||
if len(dnsServers) > 0 {
|
||||
fmt.Printf(" DNS servers: %s\n", strings.Join(dnsServers, ", "))
|
||||
}
|
||||
@@ -1229,25 +1271,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate IP address format
|
||||
if net.ParseIP(bindIP) == nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve the off-host advertise IP. DetectNetwork may have failed earlier;
|
||||
// detect lazily if we still need the WAN IP.
|
||||
if advertiseFlag == "" && (bindIP == "0.0.0.0" || bindIP == "127.0.0.1") && detectedNet == nil {
|
||||
if d, derr := admin.DetectNetwork(); derr == nil {
|
||||
detectedNet = d
|
||||
}
|
||||
}
|
||||
advertiseIP, err := resolveAdvertiseIP(bindIP, advertiseFlag, detectedNet)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if port < 1 || port > 65535 {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: Port must be between 1 and 65535, got: %d\n", port)
|
||||
@@ -1633,6 +1656,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
|
||||
|
||||
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
|
||||
configureHostDNS(configSettings, skipHostDNS)
|
||||
|
||||
// Write node.conf so spx admin banner works on source installs (not just ISO).
|
||||
nodeHostname, _ := os.Hostname()
|
||||
if nodeHostname == "" {
|
||||
@@ -1877,6 +1903,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
|
||||
|
||||
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
|
||||
configureHostDNS(configSettings, skipHostDNS)
|
||||
|
||||
// Keep formation server running briefly so joining nodes can fetch complete status
|
||||
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
|
||||
time.Sleep(15 * time.Second)
|
||||
@@ -2344,6 +2373,9 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
|
||||
finalizeNodeSetup(dataDir, caCertPath, creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, bindIP)
|
||||
|
||||
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
|
||||
configureHostDNS(configSettings, skipHostDNS)
|
||||
|
||||
// Print cluster summary
|
||||
fmt.Println("\n🎉 Node successfully joined cluster!")
|
||||
fmt.Printf(" Cluster: %s (%d nodes)\n", creds.ClusterName, len(statusResp.Nodes))
|
||||
@@ -2434,7 +2466,9 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
|
||||
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
|
||||
}
|
||||
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey, len(cfg.Nodes))
|
||||
// Background: this runs at CLI top level, where there is no request to
|
||||
// inherit a deadline from and the process exits after the one command.
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey, len(cfg.Nodes))
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
|
||||
@@ -2468,7 +2502,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
|
||||
// Create default VPC for the new account (belt-and-suspenders: daemon also
|
||||
// does this via iam.account.created event, but daemon may not be running).
|
||||
nodeConfig := cfg.Nodes[cfg.Node]
|
||||
vpcSvc, vpcErr := handlers_ec2_vpc.NewVPCServiceImplWithNATS(&nodeConfig, nc)
|
||||
vpcSvc, vpcErr := handlers_ec2_vpc.NewVPCServiceImplWithNATS(context.Background(), &nodeConfig, nc)
|
||||
if vpcErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not create default VPC service: %v\n", vpcErr)
|
||||
} else if _, vpcErr = vpcSvc.EnsureDefaultVPC(accountID); vpcErr != nil {
|
||||
@@ -2704,6 +2738,45 @@ func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings
|
||||
return admin.GenerateConfigFiles(configs, settings)
|
||||
}
|
||||
|
||||
// hostDNSParams derives the resolver target from the settings just rendered into
|
||||
// northstar.toml and reports whether host DNS should be configured at all. Host
|
||||
// DNS is pointed at the node's own northstar only when a northstar config was
|
||||
// rendered (scoped DNS credentials present) and the operator did not opt out.
|
||||
// The resolver is the node AdvertiseIP, falling back to BindIP.
|
||||
func hostDNSParams(settings admin.ConfigSettings, skip bool) (hostdns.Params, bool) {
|
||||
northstarEnabled := settings.NorthstarAccessKey != "" && settings.NorthstarSecretKey != ""
|
||||
if !northstarEnabled || skip {
|
||||
return hostdns.Params{}, false
|
||||
}
|
||||
resolverIP := settings.AdvertiseIP
|
||||
if resolverIP == "" {
|
||||
resolverIP = settings.BindIP
|
||||
}
|
||||
return hostdns.Params{
|
||||
ResolverIP: resolverIP,
|
||||
BaseDomain: settings.NorthstarDefaultDomain,
|
||||
InternalDomain: settings.NorthstarInternalDomain,
|
||||
}, true
|
||||
}
|
||||
|
||||
// configureHostDNS points this node's host resolver at its own northstar so the
|
||||
// Spinifex authoritative zones (ELB/EKS names) resolve from the node itself on
|
||||
// every install path. Failures are surfaced loudly but never abort a formed
|
||||
// cluster, mirroring installCACertificate.
|
||||
func configureHostDNS(settings admin.ConfigSettings, skip bool) {
|
||||
params, ok := hostDNSParams(settings, skip)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Println("\n🔧 Configuring host DNS for Spinifex zones...")
|
||||
if err := hostdns.Configure(params); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ Warning: could not configure host DNS: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, " LB/EKS names may not resolve from this node until the host resolver points at %s:53.\n", params.ResolverIP)
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Host DNS: %s + %s -> %s:53 (northstar)\n", params.BaseDomain, params.InternalDomain, params.ResolverIP)
|
||||
}
|
||||
|
||||
// finalizeNodeSetup configures AWS credentials, creates service directories,
|
||||
// and sets ownership when running as root.
|
||||
func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region, bindIP string) {
|
||||
@@ -3009,15 +3082,36 @@ func bridgeModeFor(externalMode string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectDNSServers auto-detects DNS servers from the host for the specified
|
||||
// interface. Uses resolvectl (systemd-resolved) first, then falls back to
|
||||
// /etc/resolv.conf. Returns up to 3 servers. Falls back to public DNS if none found.
|
||||
func detectDNSServers(iface string) []string {
|
||||
// dnsDetectionCommandTimeout prevents a wedged resolver manager from blocking init.
|
||||
const dnsDetectionCommandTimeout = 5 * time.Second
|
||||
|
||||
// dnsDetectionSources isolates host resolver discovery for deterministic tests.
|
||||
type dnsDetectionSources struct {
|
||||
queryResolvectl func(args ...string) (string, error)
|
||||
readResolvConf func() (string, error)
|
||||
}
|
||||
|
||||
// detectDNSServers auto-detects up to three host DNS servers, preferring
|
||||
// systemd-resolved before resolv.conf and public fallbacks.
|
||||
func detectDNSServers(iface string, excludedIPs ...string) []string {
|
||||
return detectDNSServersWithSources(iface, excludedIPs, dnsDetectionSources{
|
||||
queryResolvectl: func(args ...string) (string, error) {
|
||||
return utils.RunCommandWithTimeout(dnsDetectionCommandTimeout, "resolvectl", args...)
|
||||
},
|
||||
readResolvConf: func() (string, error) {
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
return string(data), err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// detectDNSServersWithSources finds usable upstreams in resolver-manager order.
|
||||
func detectDNSServersWithSources(iface string, excludedIPs []string, sources dnsDetectionSources) []string {
|
||||
// Try resolvectl for the specific link first (most reliable on modern systems)
|
||||
if iface != "" {
|
||||
out, err := exec.Command("resolvectl", "dns", iface).CombinedOutput()
|
||||
out, err := sources.queryResolvectl("dns", iface)
|
||||
if err == nil {
|
||||
servers := parseDNSFromResolvectl(string(out))
|
||||
servers := filterDNSServers(parseDNSFromResolvectl(out), excludedIPs...)
|
||||
if len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
@@ -3025,38 +3119,66 @@ func detectDNSServers(iface string) []string {
|
||||
}
|
||||
|
||||
// Try resolvectl global
|
||||
out, err := exec.Command("resolvectl", "dns").CombinedOutput()
|
||||
out, err := sources.queryResolvectl("dns")
|
||||
if err == nil {
|
||||
servers := parseDNSFromResolvectl(string(out))
|
||||
servers := filterDNSServers(parseDNSFromResolvectl(out), excludedIPs...)
|
||||
if len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to /etc/resolv.conf
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
resolvConf, err := sources.readResolvConf()
|
||||
if err == nil {
|
||||
var servers []string
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
for line := range strings.SplitSeq(resolvConf, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "nameserver ") {
|
||||
ip := strings.TrimSpace(strings.TrimPrefix(line, "nameserver"))
|
||||
// Skip localhost (systemd-resolved stub)
|
||||
if ip != "127.0.0.53" && ip != "127.0.0.1" && net.ParseIP(ip) != nil {
|
||||
servers = append(servers, ip)
|
||||
}
|
||||
servers = append(servers, strings.TrimSpace(strings.TrimPrefix(line, "nameserver")))
|
||||
}
|
||||
}
|
||||
if len(servers) > 0 {
|
||||
if len(servers) > 3 {
|
||||
servers = servers[:3]
|
||||
}
|
||||
if servers = filterDNSServers(servers, excludedIPs...); len(servers) > 0 {
|
||||
return servers
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to well-known public DNS
|
||||
return []string{"8.8.8.8", "1.1.1.1"}
|
||||
return filterDNSServers([]string{"8.8.8.8", "1.1.1.1"}, excludedIPs...)
|
||||
}
|
||||
|
||||
// filterDNSServers removes local, duplicate, and invalid resolver addresses and
|
||||
// caps the upstream list at the three entries supported by generated configs.
|
||||
func filterDNSServers(servers []string, excludedIPs ...string) []string {
|
||||
excluded := make(map[string]struct{}, len(excludedIPs)+2)
|
||||
excluded["127.0.0.1"] = struct{}{}
|
||||
excluded["127.0.0.53"] = struct{}{}
|
||||
for _, value := range excludedIPs {
|
||||
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
|
||||
excluded[ip.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, min(len(servers), 3))
|
||||
seen := make(map[string]struct{}, len(servers))
|
||||
for _, value := range servers {
|
||||
ip := net.ParseIP(strings.TrimSpace(value))
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
normalized := ip.String()
|
||||
if _, skip := excluded[normalized]; skip {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[normalized]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
filtered = append(filtered, normalized)
|
||||
if len(filtered) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// parseDNSFromResolvectl extracts IP addresses from resolvectl dns output.
|
||||
@@ -3071,14 +3193,11 @@ func parseDNSFromResolvectl(output string) []string {
|
||||
}
|
||||
fields := strings.FieldsSeq(after)
|
||||
for f := range fields {
|
||||
if net.ParseIP(f) != nil && f != "127.0.0.53" && f != "127.0.0.1" {
|
||||
if net.ParseIP(f) != nil {
|
||||
servers = append(servers, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(servers) > 3 {
|
||||
servers = servers[:3]
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/hostdns"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -545,6 +546,32 @@ func TestSpinifexTomlTemplate_EncryptionKeyFile(t *testing.T) {
|
||||
assert.NotContains(t, string(data2), "encryption_key_file", "blank key must omit the field")
|
||||
}
|
||||
|
||||
// A rendered node config must turn chunk GC on. Asserting the template text
|
||||
// alone is not enough: the value has to survive parsing into a non-nil true
|
||||
// pointer, because every consumer treats nil as off. Rendering and loading
|
||||
// together is the only check that covers the whole path.
|
||||
func TestSpinifexTomlTemplate_GCEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
settings := admin.ConfigSettings{
|
||||
Node: "node1", Az: "ap-southeast-2a", Port: "4432", Region: "ap-southeast-2",
|
||||
BindIP: "0.0.0.0", AccessKey: "AKIATEST", SecretKey: "SECRET",
|
||||
AccountID: "123456789012", NatsToken: "token",
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
ConfigDir: dir,
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "spinifex.toml")
|
||||
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
|
||||
|
||||
cfg, err := config.LoadConfig(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
node, ok := cfg.Nodes["node1"]
|
||||
require.True(t, ok, "rendered config must define the node")
|
||||
require.NotNil(t, node.Viperblock.GCEnabled, "gc_enabled must be rendered; nil leaves chunk GC off on every deployed node")
|
||||
assert.True(t, *node.Viperblock.GCEnabled, "chunk GC must be enabled in a freshly rendered node config")
|
||||
}
|
||||
|
||||
// Joiners run `spx admin join` against a configDir that doesn't yet contain
|
||||
// a predastore/ subdir. The helper must create it (predastore subdir is
|
||||
// owned by spinifex-storage in prod, but the helper just needs the dir to
|
||||
@@ -1057,6 +1084,135 @@ func TestAdminInitFlag_CompactionIntervalReachesRenderedConfig(t *testing.T) {
|
||||
assert.Contains(t, out, "interval_seconds = 45")
|
||||
}
|
||||
|
||||
func TestFilterDNSServersExcludesLocalNorthstar(t *testing.T) {
|
||||
servers := []string{
|
||||
"10.0.0.5",
|
||||
"127.0.0.53",
|
||||
"1.1.1.1",
|
||||
"1.1.1.1",
|
||||
"not-an-ip",
|
||||
"2001:0db8::1",
|
||||
"8.8.8.8",
|
||||
"9.9.9.9",
|
||||
"4.4.4.4",
|
||||
}
|
||||
|
||||
got := filterDNSServers(servers, "10.0.0.5", "2001:db8::1")
|
||||
assert.Equal(t, []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}, got)
|
||||
}
|
||||
|
||||
func TestDetectDNSServersContinuesAfterExcludedSource(t *testing.T) {
|
||||
var queries []string
|
||||
sources := dnsDetectionSources{
|
||||
queryResolvectl: func(args ...string) (string, error) {
|
||||
query := strings.Join(args, " ")
|
||||
queries = append(queries, query)
|
||||
switch query {
|
||||
case "dns br-wan":
|
||||
return "Link 2 (br-wan): 10.0.0.5 127.0.0.53\n", nil
|
||||
case "dns":
|
||||
return "Global: 10.0.0.5 10.0.0.5 127.0.0.53 10.20.0.53 8.8.8.8\n", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected query %q", query)
|
||||
}
|
||||
},
|
||||
readResolvConf: func() (string, error) {
|
||||
t.Fatal("resolv.conf read despite usable global resolvers")
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
|
||||
got := detectDNSServersWithSources("br-wan", []string{"10.0.0.5"}, sources)
|
||||
assert.Equal(t, []string{"10.20.0.53", "8.8.8.8"}, got)
|
||||
assert.Equal(t, []string{"dns br-wan", "dns"}, queries)
|
||||
}
|
||||
|
||||
func TestDetectDNSServersFallsBackToResolvConf(t *testing.T) {
|
||||
sources := dnsDetectionSources{
|
||||
queryResolvectl: func(_ ...string) (string, error) {
|
||||
return "", errors.New("resolver manager unavailable")
|
||||
},
|
||||
readResolvConf: func() (string, error) {
|
||||
return "nameserver 10.0.0.5\nnameserver 10.20.0.53\n", nil
|
||||
},
|
||||
}
|
||||
|
||||
got := detectDNSServersWithSources("br-wan", []string{"10.0.0.5"}, sources)
|
||||
assert.Equal(t, []string{"10.20.0.53"}, got)
|
||||
}
|
||||
|
||||
func TestHostDNSParams(t *testing.T) {
|
||||
// northstarEnabled requires both credentials; the zones and IPs mirror what
|
||||
// admin init/join hold in memory after rendering northstar.toml.
|
||||
enabled := admin.ConfigSettings{
|
||||
NorthstarAccessKey: "AK",
|
||||
NorthstarSecretKey: "SK",
|
||||
NorthstarDefaultDomain: "spx3.net",
|
||||
NorthstarInternalDomain: "compute.internal",
|
||||
AdvertiseIP: "10.0.0.5",
|
||||
BindIP: "0.0.0.0",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
settings admin.ConfigSettings
|
||||
skip bool
|
||||
wantOK bool
|
||||
wantParams hostdns.Params
|
||||
}{
|
||||
{
|
||||
name: "enabled selects AdvertiseIP over BindIP",
|
||||
settings: enabled,
|
||||
wantOK: true,
|
||||
wantParams: hostdns.Params{
|
||||
ResolverIP: "10.0.0.5",
|
||||
BaseDomain: "spx3.net",
|
||||
InternalDomain: "compute.internal",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "falls back to BindIP when AdvertiseIP is unset",
|
||||
settings: func() admin.ConfigSettings {
|
||||
s := enabled
|
||||
s.AdvertiseIP = ""
|
||||
s.BindIP = "10.0.0.9"
|
||||
return s
|
||||
}(),
|
||||
wantOK: true,
|
||||
wantParams: hostdns.Params{
|
||||
ResolverIP: "10.0.0.9",
|
||||
BaseDomain: "spx3.net",
|
||||
InternalDomain: "compute.internal",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opt-out skips even when enabled",
|
||||
settings: enabled,
|
||||
skip: true,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "northstar disabled skips",
|
||||
settings: func() admin.ConfigSettings {
|
||||
s := enabled
|
||||
s.NorthstarSecretKey = ""
|
||||
return s
|
||||
}(),
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := hostDNSParams(tt.settings, tt.skip)
|
||||
assert.Equal(t, tt.wantOK, ok)
|
||||
if tt.wantOK {
|
||||
assert.Equal(t, tt.wantParams, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The image is written into a root volume of exactly the size this returns, so
|
||||
// anything less than the image size truncates the image and the guest never
|
||||
// finds its root. The old flooring conversion undersized every non-round image.
|
||||
|
||||
@@ -92,7 +92,7 @@ gpu_passthrough = true
|
||||
host = "{{.BindIP}}:9999"
|
||||
tlskey = "config/server.key"
|
||||
tlscert = "config/server.pem"
|
||||
debug = true
|
||||
debug = false
|
||||
config = "awsgw/awsgw.toml"
|
||||
|
||||
[nodes.{{.Node}}.nats]
|
||||
@@ -106,6 +106,7 @@ token = "{{.NatsToken}}"
|
||||
|
||||
[nodes.{{.Node}}.viperblock]
|
||||
shardwal = false
|
||||
gc_enabled = true
|
||||
{{- if .EncryptionKeyFile}}
|
||||
encryption_key_file = "{{.EncryptionKeyFile}}"
|
||||
{{- end}}
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/`. `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--skip-host-dns`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services`, `--skip-host-dns` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations → points the host resolver at this node's local northstar so the Spinifex zones resolve from the node itself (opt out with `--skip-host-dns`) |
|
||||
|
||||
### Version
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Mirror the cloud image you intend to run as guest VMs:
|
||||
|
||||
```bash
|
||||
mkdir -p images
|
||||
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/20260518-2482/debian-13-genericcloud-amd64-20260518-2482.qcow2" \
|
||||
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \
|
||||
-o images/debian-13-amd64.qcow2
|
||||
```
|
||||
|
||||
|
||||
@@ -107,8 +107,10 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
|
||||
|
||||
| Destination | Purpose | Protocol | Verification |
|
||||
|-------------|---------|----------|--------------|
|
||||
| `https://cloud.debian.org/images/cloud/trixie/` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://cloud-images.ubuntu.com/resolute/` | Ubuntu 26.04 LTS cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://cloud.debian.org/images/cloud/trixie/latest/` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://cloud-images.ubuntu.com/releases/resolute/release/` | Ubuntu 26.04 LTS cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://dl.rockylinux.org/pub/rocky/10/images/` | Rocky Linux 10 cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://dl-cdn.alpinelinux.org/alpine/` | Alpine Linux cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://d2yp8ipz5jfqcw.cloudfront.net` | Alpine image for managed HAProxy load-balancer | HTTPS | TLS + checksum verification |
|
||||
| `https://install.mulgadc.com/install` | One-shot install telemetry POST on `spx admin init` / `join`. | HTTPS | TLS |
|
||||
|
||||
@@ -116,7 +118,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
|
||||
|
||||
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
|
||||
|
||||
**Air-gapped deployments.** The three image URLs are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
|
||||
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
|
||||
|
||||
## 3. Cross-Node (Internal) Connections
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
@@ -20,9 +20,9 @@ require (
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
|
||||
github.com/klauspost/cpuid/v2 v2.4.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/mulgadc/northstar v1.13.0
|
||||
github.com/mulgadc/predastore v1.13.0
|
||||
github.com/mulgadc/viperblock v1.13.0
|
||||
github.com/mulgadc/northstar v1.14.0
|
||||
github.com/mulgadc/predastore v1.14.0
|
||||
github.com/mulgadc/viperblock v1.14.0
|
||||
github.com/nats-io/nats-server/v2 v2.14.3
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/opencontainers/runtime-spec v1.3.0
|
||||
@@ -62,19 +62,19 @@ require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
|
||||
github.com/aws/smithy-go v1.27.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -104,7 +104,7 @@ require (
|
||||
github.com/containerd/ttrpc v1.2.8 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgraph-io/badger/v4 v4.9.4 // indirect
|
||||
github.com/dgraph-io/badger/v4 v4.9.5 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
@@ -172,7 +172,7 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
@@ -187,7 +187,9 @@ require (
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
|
||||
@@ -198,7 +200,7 @@ require (
|
||||
golang.org/x/vuln v1.4.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
@@ -215,4 +217,7 @@ require (
|
||||
// stop requiring the monolith.
|
||||
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20260622175928-b703f567277d
|
||||
|
||||
tool golang.org/x/vuln/cmd/govulncheck
|
||||
tool (
|
||||
go.uber.org/nilaway/cmd/nilaway
|
||||
golang.org/x/vuln/cmd/govulncheck
|
||||
)
|
||||
|
||||
@@ -524,72 +524,72 @@ github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZw
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.5/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.14/go.mod h1:0pI6JQBHKwd0JnwAZS3VCapLKMO++UL2BOkWwyyzTnA=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.4/go.mod h1:zq2FFXK3A416kiukwpsd+rD4ny6JC7QSkp4QdN1Mp2g=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.14/go.mod h1:85ckagDuzdIOnZRwws1eLKnymJs3ZM1QwVC1XcuNGOY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.4/go.mod h1:+30tpwrkOgvkJL1rUZuRLoxcJwtI/OkeBLYnHxJtVe0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.65/go.mod h1:4zyjAuGOdikpNYiSGpsGz8hLGmUzlY8pc8r9QQ/RXYQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23/go.mod h1:mOtmAg65GT1HIL/HT/PynwPbS+UG0BgCZ6vhkPqnxWo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29/go.mod h1:Dip3sIGv485+xerzVv24emnjX5Sg88utCL8fwGmCeWg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2/go.mod h1:wRQv0nN6v9wDXuWThpovGQjqF1HFdcgWjporw14lS8k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23/go.mod h1:mr6c4cHC+S/MMkrjtSlG4QA36kOznDep+0fga5L/fGQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2/go.mod h1:tyF5sKccmDz0Bv4NrstEr+/9YkSPJHrcO7UsUKf7pWM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30/go.mod h1:vsbq62AOBwQ1LJ/GWKFxX8beUEYeRp/Agitrxee2/qM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23/go.mod h1:9uPh+Hrz2Vn6oMnQYiUi/zbh3ovbnQk19YKINkQny44=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.2/go.mod h1:Ru7vg1iQ7cR4i7SZ/JTLYN9kaXtbL69UdgG0OQWQxW0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.3/go.mod h1:jtLIhd+V+lft6ktxpItycqHqiVXrPIRjWIsFIlzMriw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.3/go.mod h1:zVwRrfdSmbRZWkUkWjOItY7SOalnFnq/Yg2LVPqDjwc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.4/go.mod h1:1mKZHLLpDMHTNSYPJ7qrcnCQdHCWsNQaT0xRvq2u80s=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.1/go.mod h1:uQ7YYKZt3adCRrdCBREm1CD3efFLOUNH77MrUCvx5oA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
|
||||
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
|
||||
github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
@@ -735,8 +735,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v4 v4.9.4 h1:bcw+waCpzRZ2nmcSPbnPvDVhiEsn98TKmvnAhK7r7LM=
|
||||
github.com/dgraph-io/badger/v4 v4.9.4/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
|
||||
github.com/dgraph-io/badger/v4 v4.9.5 h1:zT46OMrF3ntqsfI3ynKp7hUkQrGlcK2CX5psQmH0iW0=
|
||||
github.com/dgraph-io/badger/v4 v4.9.5/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
@@ -1317,12 +1317,12 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mulgadc/northstar v1.13.0 h1:L9PyYZ9Klg7dv2kE5+DHOcgPHelaZRDIvnwBsbHsdGI=
|
||||
github.com/mulgadc/northstar v1.13.0/go.mod h1:erQE/PzK9UMtgH8Y+uI5RdiboO/nqe1CUJbIpkae/FM=
|
||||
github.com/mulgadc/predastore v1.13.0 h1:jDPpLJP/GA4WK2t+9lksTSFDuQiV8vgDl6ISnPJRUuo=
|
||||
github.com/mulgadc/predastore v1.13.0/go.mod h1:38YSuk9zvYmbmgyM13WTkIQPJtvo2KmGJBYJul6NeAE=
|
||||
github.com/mulgadc/viperblock v1.13.0 h1:NuqmPRm26/j58GKbzcyshm0uZwdobzdjKK1qEsb2pCY=
|
||||
github.com/mulgadc/viperblock v1.13.0/go.mod h1:sW0hxZjN7WDQQZ39L5mE58fVLbSv3mBP3/ifFPdwJtU=
|
||||
github.com/mulgadc/northstar v1.14.0 h1:8JzDcS4k3SN5PF3jBdB8x9FOWC24eITCClmOdwpoY8Q=
|
||||
github.com/mulgadc/northstar v1.14.0/go.mod h1:6EArbWIQUrCFQ+XZ5ZJrGS/ep/u/rF6l+dKZh5vVIRs=
|
||||
github.com/mulgadc/predastore v1.14.0 h1:vG+egKIP08SLql4hDUwke31ritValUYaDbyH39Ta6qw=
|
||||
github.com/mulgadc/predastore v1.14.0/go.mod h1:C/mlefzQgqRAXNMbKc/rmW5BVSoB3R0OPHsgOi/islI=
|
||||
github.com/mulgadc/viperblock v1.14.0 h1:Q6REPTPHh+3fnkjbyBSfLXWSppgiLsEcxwuvaHow0LY=
|
||||
github.com/mulgadc/viperblock v1.14.0/go.mod h1:5ENQb0OrjTplIRiLShRW/oNWOzbFvSCFgo+PkNH4858=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
@@ -1475,8 +1475,8 @@ github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
|
||||
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -1546,6 +1546,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -1812,6 +1813,8 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a h1:CsRd824LAzMmlpu9nYv2PHOqPQYv4Zjd4kGM+g0ahS0=
|
||||
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a/go.mod h1:i9Hi8GeZFLBtUCXP73ZNSHKZ4K2JtxT0pLmo3sbJzlo=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
@@ -1911,6 +1914,8 @@ golang.org/x/exp/shiny v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:UH99kUObWAZk
|
||||
golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0=
|
||||
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||
golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 h1:GTD/WuaexTazIG/SxLOz4rEKZPDVilmVVC2nz4xhwfE=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
@@ -3094,8 +3099,9 @@ google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y=
|
||||
google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM=
|
||||
|
||||
@@ -265,6 +265,15 @@ else
|
||||
CUST+=(--memsize 2048)
|
||||
fi
|
||||
|
||||
# The guest's /etc/resolv.conf symlinks to systemd-resolved's stub
|
||||
# (127.0.0.53), which isn't running inside the appliance chroot, so DNS is
|
||||
# dead until package installs need it. Point it at a real resolver first, then
|
||||
# restore the stub symlink after package installs so the shipped AMI still
|
||||
# gets its DNS from systemd-resolved at boot like a stock cloud image.
|
||||
if [[ -n "${APK_PACKAGES:-}${APT_PACKAGES:-}" ]]; then
|
||||
CUST+=(--run-command 'rm -f /etc/resolv.conf; printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolv.conf')
|
||||
fi
|
||||
|
||||
# Packages
|
||||
if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
|
||||
echo "Will install packages: ${APK_PACKAGES}"
|
||||
@@ -338,6 +347,12 @@ else
|
||||
CUST+=(--run-command 'export DEBIAN_FRONTEND=noninteractive; apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* 2>/dev/null || true')
|
||||
fi
|
||||
|
||||
# Restore the stock systemd-resolved stub symlink so the shipped AMI gets its
|
||||
# DNS from the instance's own resolver at boot, not the static appliance one.
|
||||
if [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APK_PACKAGES:-}${APT_PACKAGES:-}${SETUP_SCRIPT:-}" ]]; then
|
||||
CUST+=(--run-command 'rm -f /etc/resolv.conf; ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf')
|
||||
fi
|
||||
|
||||
echo "Customizing image (libguestfs appliance)..."
|
||||
"${CUST[@]}"
|
||||
|
||||
|
||||
@@ -71,18 +71,21 @@ EOF
|
||||
|
||||
apt-get install -y --no-install-recommends "linux-headers-${KVER}"
|
||||
|
||||
# Ubuntu 26.04+ no longer ships unversioned meta-packages; detect the
|
||||
# latest available versioned NVIDIA server driver.
|
||||
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
|
||||
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
|
||||
if [ -z "${NVIDIA_VER}" ]; then
|
||||
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
|
||||
apt-cache search nvidia-dkms || true
|
||||
exit 1
|
||||
fi
|
||||
echo "[nvidia-gpu-stack] installing NVIDIA server driver version: ${NVIDIA_VER}"
|
||||
# Ubuntu 26.04+ no longer ships unversioned meta-packages, so the branch is
|
||||
# named explicitly. Pinned rather than resolved at build time: the number in
|
||||
# the package name is a branch alias, not a driver version (nvidia-dkms-535-server
|
||||
# installs 580.126.20), so picking the highest number sorts on a value that
|
||||
# need not match what gets installed. Resolving also let identical source
|
||||
# produce different drivers on different days. Bumping is a deliberate edit.
|
||||
NVIDIA_VER=595
|
||||
echo "[nvidia-gpu-stack] installing NVIDIA server driver branch: ${NVIDIA_VER}"
|
||||
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
|
||||
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
|
||||
# initialize under the closed kernel module — RmInitAdapter fails with
|
||||
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
|
||||
# nvidia-utils has no separate open variant (userspace tools only).
|
||||
apt-get install -y --no-install-recommends \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server" \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server-open" \
|
||||
"nvidia-utils-${NVIDIA_VER}-server"
|
||||
|
||||
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
|
||||
|
||||
@@ -90,18 +90,21 @@ EOF
|
||||
|
||||
apt-get install -y --no-install-recommends "linux-headers-${KVER}"
|
||||
|
||||
# Ubuntu 26.04+ no longer ships unversioned meta-packages; detect the
|
||||
# latest available versioned NVIDIA server driver.
|
||||
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
|
||||
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
|
||||
if [ -z "${NVIDIA_VER}" ]; then
|
||||
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
|
||||
apt-cache search nvidia-dkms || true
|
||||
exit 1
|
||||
fi
|
||||
echo "[nvidia-gpu-stack] installing NVIDIA server driver version: ${NVIDIA_VER}"
|
||||
# Ubuntu 26.04+ no longer ships unversioned meta-packages, so the branch is
|
||||
# named explicitly. Pinned rather than resolved at build time: the number in
|
||||
# the package name is a branch alias, not a driver version (nvidia-dkms-535-server
|
||||
# installs 580.126.20), so picking the highest number sorts on a value that
|
||||
# need not match what gets installed. Resolving also let identical source
|
||||
# produce different drivers on different days. Bumping is a deliberate edit.
|
||||
NVIDIA_VER=595
|
||||
echo "[nvidia-gpu-stack] installing NVIDIA server driver branch: ${NVIDIA_VER}"
|
||||
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
|
||||
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
|
||||
# initialize under the closed kernel module — RmInitAdapter fails with
|
||||
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
|
||||
# nvidia-utils has no separate open variant (userspace tools only).
|
||||
apt-get install -y --no-install-recommends \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server" \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server-open" \
|
||||
"nvidia-utils-${NVIDIA_VER}-server"
|
||||
|
||||
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
|
||||
|
||||
@@ -24939,6 +24939,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-applicationset-controller
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- args:
|
||||
- /usr/local/bin/argocd-applicationset-controller
|
||||
@@ -25180,6 +25191,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-dex-server
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
@@ -25304,6 +25326,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-notifications-controller
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- args:
|
||||
- /usr/local/bin/argocd-notifications
|
||||
@@ -25403,6 +25436,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-redis
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
@@ -25486,6 +25530,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-repo-server
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
@@ -25840,6 +25895,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-server
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
@@ -26251,6 +26317,17 @@ spec:
|
||||
labels:
|
||||
app.kubernetes.io/name: argocd-application-controller
|
||||
spec:
|
||||
# Local delta over the vendored upstream manifest: a cluster with a
|
||||
# GPU-only nodegroup has no untainted node — the CP carries
|
||||
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
|
||||
# these the pod stays Pending forever. Upstream never needs it because
|
||||
# real EKS control planes are not schedulable nodes.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
|
||||
@@ -44,6 +44,18 @@ spec:
|
||||
kubernetes.io/os: linux
|
||||
serviceAccountName: ebs-csi-controller-sa
|
||||
priorityClassName: system-cluster-critical
|
||||
# A cluster with a GPU-only nodegroup has no untainted node: the CP carries
|
||||
# CriticalAddonsOnly (EKS parity keeps user workloads off it) and every
|
||||
# worker carries nvidia.com/gpu (nodegroup_userdata.go). Without these
|
||||
# tolerations the controller has nowhere to schedule and stays Pending
|
||||
# forever. Real EKS never needs this: its control plane isn't a schedulable
|
||||
# node, so the stock upstream manifest carries no such toleration.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
|
||||
+12
@@ -348,6 +348,18 @@ spec:
|
||||
readOnly: true
|
||||
{{IRSA_VOLUME_MOUNT}}
|
||||
priorityClassName: system-cluster-critical
|
||||
# A cluster with a GPU-only nodegroup has no untainted node: the CP carries
|
||||
# CriticalAddonsOnly (EKS parity keeps user workloads off it) and every
|
||||
# worker carries nvidia.com/gpu (nodegroup_userdata.go). Without these
|
||||
# tolerations the controller has nowhere to schedule and stays Pending
|
||||
# forever. Real EKS never needs this: its control plane isn't a schedulable
|
||||
# node, so the stock upstream manifest carries no such toleration.
|
||||
tolerations:
|
||||
- key: CriticalAddonsOnly
|
||||
operator: Exists
|
||||
- key: nvidia.com/gpu
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
securityContext:
|
||||
fsGroup: 1337
|
||||
serviceAccountName: aws-load-balancer-controller
|
||||
|
||||
@@ -4,13 +4,27 @@
|
||||
#
|
||||
# Spinifex carries tenant traffic over an OVN geneve underlay (~1442B usable on a
|
||||
# 1500B host link). The EKS node's primary ENI (eth0) comes up at the default
|
||||
# 1500 — cloud-init's Ec2 datasource has no MTU leaf and Alpine's udhcpc ignores
|
||||
# DHCP option 26 — so flannel derives flannel.1 = 1500-50 = 1450. VXLAN frames up
|
||||
# to 1500 then exceed the underlay and are silently dropped: small packets (TCP
|
||||
# handshakes) pass while large ones (TLS, kubelet streams, pod-to-pod payloads)
|
||||
# blackhole. Pinning eth0 down to 1320 (flannel.1 = 1270) leaves margin under the
|
||||
# underlay and restores the datapath. This must run before k3s starts flannel,
|
||||
# which reads the iface MTU once at startup.
|
||||
# 1500 — cloud-init's Ec2 datasource has no MTU leaf — so flannel derives
|
||||
# flannel.1 = 1500-50 = 1450. VXLAN frames up to 1500 then exceed the underlay and
|
||||
# are silently dropped: small packets (TCP handshakes) pass while large ones (TLS,
|
||||
# kubelet streams, pod-to-pod payloads) blackhole. Pinning eth0 down to 1320
|
||||
# (flannel.1 = 1270) leaves margin under the underlay and restores the datapath.
|
||||
# This must run before k3s starts flannel, which reads the iface MTU once at
|
||||
# startup.
|
||||
#
|
||||
# Pinning the LINK is not sufficient on its own. This image runs dhcpcd, not
|
||||
# udhcpc (an earlier version of this comment had that backwards), and dhcpcd
|
||||
# honours DHCP option 26 by stamping the advertised MTU onto every route it
|
||||
# installs:
|
||||
#
|
||||
# default via 10.32.101.1 dev eth0 proto dhcp src 10.32.101.7 metric 1002 mtu 1442
|
||||
#
|
||||
# Linux prefers a route's MTU metric over the device MTU (dst_mtu()), so the node
|
||||
# still advertises an MSS derived from 1442 no matter what the link says, and
|
||||
# large inbound segments blackhole in the HOST netns — which is where containerd
|
||||
# pulls run, so image pulls fail with `TLS handshake timeout` while pod traffic
|
||||
# (correctly sized off cni0) is fine. Pod netns working while the node netns does
|
||||
# not is the signature. Re-stamp the routes to match the pinned link.
|
||||
#
|
||||
# eth0 is always the primary data ENI (cloud-init set-name; mgmt0, when present,
|
||||
# is a separate NIC and is left at its default). The default-route interface is
|
||||
@@ -30,9 +44,47 @@ pin() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Rewrite the MTU metric dhcpcd stamped onto the routes of one interface, so no
|
||||
# route can advertise a larger MSS than the pinned link carries. Routes with no
|
||||
# MTU metric inherit the device MTU and are left alone.
|
||||
#
|
||||
# `ip route show dev X` omits the "dev X" token from each line, so it is added
|
||||
# back before replacing; a "default" line already carries its own dev.
|
||||
restamp() {
|
||||
iface="$1"
|
||||
[ -n "$iface" ] || return 0
|
||||
ip -4 route show dev "$iface" 2>/dev/null | while read -r line; do
|
||||
case "$line" in
|
||||
*" mtu "*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
cur=$(echo "$line" | sed -n 's/.* mtu \([0-9]*\).*/\1/p')
|
||||
[ -n "$cur" ] || continue
|
||||
[ "$cur" != "$MTU" ] || continue
|
||||
spec=$(echo "$line" | sed "s/ mtu $cur/ mtu $MTU/")
|
||||
case "$spec" in
|
||||
*" dev "*) ;;
|
||||
*) spec="$spec dev $iface" ;;
|
||||
esac
|
||||
# Word splitting is intended: $spec is a route specification, not a path.
|
||||
# shellcheck disable=SC2086
|
||||
if ip route replace $spec; then
|
||||
echo "[mulga-vpc-mtu] $iface route MTU $cur -> $MTU ($line)"
|
||||
else
|
||||
echo "[mulga-vpc-mtu] WARNING: failed to restamp $iface route: $line" >&2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
pin eth0
|
||||
|
||||
route_iface=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')
|
||||
[ "$route_iface" = "eth0" ] || pin "$route_iface"
|
||||
|
||||
# setup.sh writes `nooption interface_mtu` to dhcpcd.conf so no future lease can
|
||||
# re-stamp a route MTU; this pass fixes routes installed before that took effect
|
||||
# (an already-running dhcpcd, or an image built before the fix).
|
||||
restamp eth0
|
||||
[ "$route_iface" = "eth0" ] || restamp "$route_iface"
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/bin/sh
|
||||
# Self-contained POSIX test for mulga-vpc-mtu.sh route re-stamping. No root or
|
||||
# real interfaces needed: `ip` is stubbed on PATH, serving fixture route output
|
||||
# and recording every mutating call.
|
||||
#
|
||||
# Covers the fault this script exists to prevent: dhcpcd stamps the DHCP MTU
|
||||
# (option 26) onto its routes, Linux prefers a route's MTU metric over the device
|
||||
# MTU, so pinning only the link leaves the node advertising an MSS too large for
|
||||
# the path and large inbound segments blackhole in the host netns.
|
||||
#
|
||||
# Run: sh scripts/images/eks-node/mulga-vpc-mtu_test.sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
PINNER="${SCRIPT_DIR}/mulga-vpc-mtu.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
STUBBIN="${WORK}/bin"
|
||||
mkdir -p "${STUBBIN}"
|
||||
|
||||
# Stub `ip`: serves ${ROUTES_DEFAULT} / ${ROUTES_DEV} for show, records mutations
|
||||
# ("link set …" / "route replace …") to ${CALLS}. Anything else is a no-op.
|
||||
cat > "${STUBBIN}/ip" <<'STUB'
|
||||
#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
*"route show default"*) cat "${ROUTES_DEFAULT}"; exit 0 ;;
|
||||
*"route show dev "*) cat "${ROUTES_DEV}"; exit 0 ;;
|
||||
"link show dev "*) exit 0 ;;
|
||||
"link set dev "*) echo "$args" >> "${CALLS}"; exit 0 ;;
|
||||
"route replace "*) echo "$args" >> "${CALLS}"; exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "${STUBBIN}/ip"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check() { # check <desc> <expected> <actual>
|
||||
if [ "$2" = "$3" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo "FAIL: $1"
|
||||
echo " expected: [$2]"
|
||||
echo " actual: [$3]"
|
||||
fi
|
||||
}
|
||||
|
||||
# run_case <name> <default-routes> <dev-routes>: runs the pinner against fixture
|
||||
# route tables and leaves the recorded mutations in ${CALLS}.
|
||||
run_case() {
|
||||
CASE="${WORK}/$1"
|
||||
mkdir -p "${CASE}"
|
||||
CALLS="${CASE}/calls"
|
||||
ROUTES_DEFAULT="${CASE}/default"
|
||||
ROUTES_DEV="${CASE}/dev"
|
||||
: > "${CALLS}"
|
||||
printf '%s\n' "$2" > "${ROUTES_DEFAULT}"
|
||||
printf '%s\n' "$3" > "${ROUTES_DEV}"
|
||||
export CALLS ROUTES_DEFAULT ROUTES_DEV
|
||||
PATH="${STUBBIN}:${PATH}" sh "${PINNER}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
replaces() { grep -c '^route replace' "${CALLS}" 2>/dev/null | head -1; }
|
||||
|
||||
# 1. The real-world fault: dhcpcd's 1442 on the default route is rewritten to the
|
||||
# pinned link MTU, and the rest of the route spec is preserved verbatim.
|
||||
run_case dhcpcd-1442 \
|
||||
'default via 10.32.101.1 dev eth0 proto dhcp src 10.32.101.7 metric 1002 mtu 1442' \
|
||||
'default via 10.32.101.1 proto dhcp src 10.32.101.7 metric 1002 mtu 1442'
|
||||
check "link is pinned to 1320" \
|
||||
"link set dev eth0 mtu 1320" \
|
||||
"$(grep '^link set' "${CALLS}" | head -1)"
|
||||
check "route MTU rewritten to the pinned link MTU" \
|
||||
"route replace default via 10.32.101.1 proto dhcp src 10.32.101.7 metric 1002 mtu 1320 dev eth0" \
|
||||
"$(grep '^route replace' "${CALLS}" | head -1)"
|
||||
|
||||
# 2. `ip route show dev X` omits the dev token; it must be added back or the
|
||||
# replace would be rejected (and the fault would silently persist).
|
||||
run_case dev-token-restored \
|
||||
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1442' \
|
||||
'10.32.101.0/24 proto kernel scope link src 10.32.101.7 metric 1002 mtu 1442'
|
||||
check "dev token re-added to a dev-scoped route" \
|
||||
"route replace 10.32.101.0/24 proto kernel scope link src 10.32.101.7 metric 1002 mtu 1320 dev eth0" \
|
||||
"$(grep '^route replace' "${CALLS}" | head -1)"
|
||||
|
||||
# 3. Idempotent: a route already at the pinned MTU is left alone, so a re-run (or
|
||||
# a boot after the dhcpcd.conf fix) issues no route changes.
|
||||
run_case already-pinned \
|
||||
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1320' \
|
||||
'default via 10.32.101.1 proto dhcp metric 1002 mtu 1320'
|
||||
check "no replace when the route MTU already matches" "0" "$(replaces)"
|
||||
|
||||
# 4. A route with no MTU metric inherits the device MTU — leave it alone rather
|
||||
# than pinning a value the link may later change.
|
||||
run_case no-mtu-metric \
|
||||
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002' \
|
||||
'default via 10.32.101.1 proto dhcp metric 1002'
|
||||
check "no replace when the route carries no MTU metric" "0" "$(replaces)"
|
||||
|
||||
# 5. The pinned value is overridable, and the override reaches the routes too.
|
||||
run_case custom-mtu \
|
||||
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1442' \
|
||||
'default via 10.32.101.1 proto dhcp metric 1002 mtu 1442'
|
||||
CASE="${WORK}/custom-mtu-run"
|
||||
mkdir -p "${CASE}"
|
||||
CALLS="${CASE}/calls"
|
||||
: > "${CALLS}"
|
||||
export CALLS
|
||||
PATH="${STUBBIN}:${PATH}" MULGA_VPC_NIC_MTU=1408 sh "${PINNER}" >/dev/null 2>&1
|
||||
check "MULGA_VPC_NIC_MTU overrides both link and route" \
|
||||
"route replace default via 10.32.101.1 proto dhcp metric 1002 mtu 1408 dev eth0" \
|
||||
"$(grep '^route replace' "${CALLS}" | head -1)"
|
||||
|
||||
echo "pass=${PASS} fail=${FAIL}"
|
||||
[ "${FAIL}" -eq 0 ]
|
||||
@@ -142,4 +142,16 @@ if ! grep -qxF 'noipv4ll' /etc/dhcpcd.conf 2>/dev/null; then
|
||||
echo 'noipv4ll' >> /etc/dhcpcd.conf
|
||||
fi
|
||||
|
||||
# Ignore the DHCP MTU (option 26). dhcpcd honours it by stamping the advertised
|
||||
# value onto every route it installs, and Linux prefers a route's MTU metric over
|
||||
# the device MTU — so the subnet's advertised MTU wins over the link MTU that
|
||||
# mulga-vpc-mtu pins, and the node advertises an MSS too large for the real path.
|
||||
# Large inbound segments then blackhole in the host netns (image pulls fail with
|
||||
# `TLS handshake timeout`) while pod traffic, sized off cni0, is unaffected.
|
||||
# mulga-vpc-mtu is the single source of truth for this node's MTU; this stops
|
||||
# dhcpcd re-stamping a different one on every lease renewal.
|
||||
if ! grep -qxF 'nooption interface_mtu' /etc/dhcpcd.conf 2>/dev/null; then
|
||||
echo 'nooption interface_mtu' >> /etc/dhcpcd.conf
|
||||
fi
|
||||
|
||||
echo "[eks-node-setup] done"
|
||||
|
||||
@@ -60,19 +60,22 @@ EOF
|
||||
apt-get install -y --no-install-recommends \
|
||||
"linux-headers-${KVER}"
|
||||
|
||||
# Detect the latest available versioned NVIDIA server driver. Ubuntu 26.04+
|
||||
# no longer ships unversioned meta-packages (nvidia-dkms-server); the packages
|
||||
# are now versioned (e.g. nvidia-dkms-570-server).
|
||||
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
|
||||
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
|
||||
if [[ -z "${NVIDIA_VER}" ]]; then
|
||||
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
|
||||
apt-cache search nvidia-dkms || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing NVIDIA server driver version: ${NVIDIA_VER}"
|
||||
# Ubuntu 26.04+ no longer ships unversioned meta-packages (nvidia-dkms-server);
|
||||
# the packages are versioned (e.g. nvidia-dkms-570-server), so the branch is
|
||||
# named explicitly. Pinned rather than resolved at build time: the number in the
|
||||
# package name is a branch alias, not a driver version (nvidia-dkms-535-server
|
||||
# installs 580.126.20), so picking the highest number sorts on a value that need
|
||||
# not match what gets installed. Resolving also let identical source produce
|
||||
# different drivers on different days. Bumping is a deliberate edit.
|
||||
NVIDIA_VER=595
|
||||
echo "Installing NVIDIA server driver branch: ${NVIDIA_VER}"
|
||||
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
|
||||
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
|
||||
# initialize under the closed kernel module — RmInitAdapter fails with
|
||||
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
|
||||
# nvidia-utils has no separate open variant (userspace tools only).
|
||||
apt-get install -y --no-install-recommends \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server" \
|
||||
"nvidia-dkms-${NVIDIA_VER}-server-open" \
|
||||
"nvidia-utils-${NVIDIA_VER}-server"
|
||||
|
||||
# Detect the installed NVIDIA DKMS module name + version for explicit build.
|
||||
|
||||
+1
-75
@@ -15,7 +15,7 @@
|
||||
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
|
||||
# SETUP_STAGES Comma-separated subset of stages to run:
|
||||
# deps, aws, users, sudoers, files, directories,
|
||||
# env, systemd, logrotate, udev, fixown, resolved,
|
||||
# env, systemd, logrotate, udev, fixown,
|
||||
# migrations
|
||||
# Unset = run every stage appropriate for the current mode.
|
||||
|
||||
@@ -794,79 +794,6 @@ EOF
|
||||
info "Swap enabled: $swapfile (${size_mb}MiB), vm.swappiness=10"
|
||||
}
|
||||
|
||||
# --- Configure host split DNS for Spinifex zones (systemd-resolved) ---
|
||||
# When systemd-resolved manages the host resolver, route the Spinifex
|
||||
# authoritative zones (default_domain + compute.internal) to the local northstar
|
||||
# resolver on the node WAN IP, while every other name keeps using the existing
|
||||
# link/DHCP DNS. The "~" routing-domain prefix restricts the DNS= server to
|
||||
# exactly those zones (resolved.conf(5)). northstar binds <wan>:53 (not
|
||||
# 127.0.0.1), so DNS= must be the WAN IP. Idempotent; no-op without resolved.
|
||||
setup_resolved_dns() {
|
||||
stage "configuring systemd-resolved split DNS for Spinifex zones"
|
||||
|
||||
# ISO chroot has no live resolver to reconfigure; firstboot handles it.
|
||||
if [ "${ISO_BUILD:-0}" = "1" ]; then
|
||||
info "Resolved split DNS skipped (ISO_BUILD=1; firstboot configures it)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Only act when systemd-resolved is the active resolver; otherwise the
|
||||
# operator must point their resolver at the node :53 manually.
|
||||
if ! $SUDO systemctl is-active --quiet systemd-resolved 2>/dev/null; then
|
||||
info "systemd-resolved not active — skipping host split DNS"
|
||||
info " To resolve the Spinifex zones, forward them to the node WAN IP on :53"
|
||||
return
|
||||
fi
|
||||
|
||||
# northstar binds :53 on the node WAN IP. Detect the default-route source.
|
||||
local wan_ip
|
||||
wan_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
|
||||
[ -z "$wan_ip" ] && wan_ip=$(ip -4 route show default 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
|
||||
if [ -z "$wan_ip" ]; then
|
||||
warn "Could not detect a WAN IP — skipping resolved split DNS"
|
||||
warn " Re-run once the WAN is up: sudo SETUP_STAGES=resolved $0"
|
||||
return
|
||||
fi
|
||||
|
||||
# Default zones match `spx admin init` defaults; honour a custom
|
||||
# default_domain if northstar.toml already exists (post-init re-run).
|
||||
local base_domain="spx3.net" internal_domain="compute.internal"
|
||||
local ns_toml=/etc/spinifex/northstar/northstar.toml
|
||||
if $SUDO test -f "$ns_toml"; then
|
||||
local cfg_domain
|
||||
cfg_domain=$($SUDO grep -E '^[[:space:]]*default_domain[[:space:]]*=' "$ns_toml" 2>/dev/null \
|
||||
| head -1 | sed -E 's/.*=[[:space:]]*"?([^"#]*[^"# ])"?.*/\1/')
|
||||
[ -n "$cfg_domain" ] && base_domain="$cfg_domain"
|
||||
fi
|
||||
|
||||
local dropin_dir=/etc/systemd/resolved.conf.d
|
||||
local dropin="$dropin_dir/spinifex-dns.conf"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
cat > "$tmp" << EOF
|
||||
# Generated by setup.sh — route the Spinifex authoritative zones to the local
|
||||
# northstar resolver on the node WAN IP. The "~" routing-domain prefix restricts
|
||||
# this DNS= server to exactly these zones; all other names use the link DNS.
|
||||
# northstar binds <wan>:53 (not 127.0.0.1), so DNS= is the WAN IP.
|
||||
[Resolve]
|
||||
DNS=${wan_ip}
|
||||
Domains=~${base_domain} ~${internal_domain}
|
||||
EOF
|
||||
|
||||
# Skip the resolved restart when nothing changed (idempotent re-runs).
|
||||
if $SUDO test -f "$dropin" && $SUDO cmp -s "$tmp" "$dropin"; then
|
||||
rm -f "$tmp"
|
||||
info "Host split DNS already current: ${base_domain} + ${internal_domain} -> ${wan_ip}:53"
|
||||
return
|
||||
fi
|
||||
|
||||
$SUDO mkdir -p "$dropin_dir"
|
||||
$SUDO install -m 0644 "$tmp" "$dropin"
|
||||
rm -f "$tmp"
|
||||
$SUDO systemctl restart systemd-resolved 2>/dev/null || true
|
||||
info "Host split DNS: ${base_domain} + ${internal_domain} -> ${wan_ip}:53 (northstar)"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
main() {
|
||||
info "Spinifex installer"
|
||||
@@ -903,7 +830,6 @@ main() {
|
||||
stage_enabled logrotate && install_logrotate
|
||||
stage_enabled udev && install_udev
|
||||
stage_enabled swap && setup_swap
|
||||
stage_enabled resolved && setup_resolved_dns
|
||||
|
||||
# Migrations are only safe on a live system (need a running NATS and a
|
||||
# persisted config file). Skip under ISO_BUILD and under any explicit
|
||||
|
||||
@@ -522,6 +522,38 @@ func ValidErrorCode(code string) string {
|
||||
return ErrorServerInternal
|
||||
}
|
||||
|
||||
// ResolveErrorCode returns the first registered AWS error code in err's unwrap tree.
|
||||
func ResolveErrorCode(err error) (string, bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
code := err.Error()
|
||||
if _, ok := ErrorLookup[code]; ok {
|
||||
return code, true
|
||||
}
|
||||
|
||||
if joined, ok := err.(interface{ Unwrap() []error }); ok {
|
||||
for _, inner := range joined.Unwrap() {
|
||||
if code, found := ResolveErrorCode(inner); found {
|
||||
return code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if wrapped, ok := err.(interface{ Unwrap() error }); ok {
|
||||
return ResolveErrorCode(wrapped.Unwrap())
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ValidErrorCodeFromError resolves err or returns ErrorServerInternal.
|
||||
func ValidErrorCodeFromError(err error) string {
|
||||
if code, ok := ResolveErrorCode(err); ok {
|
||||
return code
|
||||
}
|
||||
return ErrorServerInternal
|
||||
}
|
||||
|
||||
// HasErrorCode reports whether s is exactly a registered AWS error code.
|
||||
// Use it to decide whether an error string may be surfaced to clients verbatim
|
||||
// (mapped to its real HTTP status) rather than wrapped/sanitized to 500.
|
||||
|
||||
@@ -70,6 +70,47 @@ func TestValidErrorCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveErrorCode(t *testing.T) {
|
||||
known := errors.New(ErrorInsufficientAddressCapacity)
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "nil", err: nil},
|
||||
{name: "exact code", err: known, want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "single wrapper", err: fmt.Errorf("allocate public address: %w", known), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "nested wrappers", err: fmt.Errorf("launch on node: %w", fmt.Errorf("prepare instance: %w", known)), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "joined errors", err: errors.Join(errors.New("opaque"), known), want: ErrorInsufficientAddressCapacity, wantOK: true},
|
||||
{name: "first joined code wins", err: errors.Join(errors.New(ErrorAuthFailure), known), want: ErrorAuthFailure, wantOK: true},
|
||||
{name: "unknown error", err: errors.New("opaque")},
|
||||
{name: "code substring is not exact", err: errors.New("launch failed: " + ErrorInsufficientAddressCapacity)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ResolveErrorCode(tt.err)
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Errorf("ResolveErrorCode() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidErrorCodeFromError(t *testing.T) {
|
||||
wrapped := fmt.Errorf("launch: %w", errors.New(ErrorInsufficientInstanceCapacity))
|
||||
if got := ValidErrorCodeFromError(wrapped); got != ErrorInsufficientInstanceCapacity {
|
||||
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
if got := ValidErrorCodeFromError(errors.New("opaque")); got != ErrorServerInternal {
|
||||
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorServerInternal)
|
||||
}
|
||||
if got := ValidErrorCodeFromError(nil); got != ErrorServerInternal {
|
||||
t.Errorf("ValidErrorCodeFromError(nil) = %q, want %q", got, ErrorServerInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasErrorCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+278
-268
@@ -57,6 +57,7 @@ import (
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/instancetypes"
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/host"
|
||||
@@ -66,6 +67,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// ResourceManager handles the allocation and tracking of system resources.
|
||||
@@ -770,123 +772,125 @@ func (d *Daemon) subscribeAll() error {
|
||||
subs := []natsSub{
|
||||
// ec2.RunInstances is handled by dynamic per-instance-type subscriptions
|
||||
// managed by ResourceManager.initSubscriptions()
|
||||
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "spinifex-workers"},
|
||||
{"ec2.DeleteKeyPair", d.handleEC2DeleteKeyPair, "spinifex-workers"},
|
||||
{"ec2.DescribeKeyPairs", d.handleEC2DescribeKeyPairs, "spinifex-workers"},
|
||||
{"ec2.ImportKeyPair", d.handleEC2ImportKeyPair, "spinifex-workers"},
|
||||
{"ec2.CreateKeyPair", handleNATSRequest(d.keyService.CreateKeyPair), "spinifex-workers"},
|
||||
{"ec2.DeleteKeyPair", handleNATSRequest(d.keyService.DeleteKeyPair), "spinifex-workers"},
|
||||
{"ec2.DescribeKeyPairs", handleNATSRequest(d.keyService.DescribeKeyPairs), "spinifex-workers"},
|
||||
{"ec2.ImportKeyPair", handleNATSRequest(d.keyService.ImportKeyPair), "spinifex-workers"},
|
||||
{"imds.ec2.get_public_key", d.handleIMDSGetPublicKey, "spinifex-workers"},
|
||||
{"ec2.DescribeImages", d.handleEC2DescribeImages, "spinifex-workers"},
|
||||
{"ec2.DescribeImages", handleNATSRequest(d.imageService.DescribeImages), "spinifex-workers"},
|
||||
{"ec2.CreateImage", d.handleEC2CreateImage, ""},
|
||||
{"ec2.DeregisterImage", d.handleEC2DeregisterImage, "spinifex-workers"},
|
||||
{"ec2.RegisterImage", d.handleEC2RegisterImage, "spinifex-workers"},
|
||||
{"ec2.CopyImage", d.handleEC2CopyImage, "spinifex-workers"},
|
||||
{"ec2.DescribeImageAttribute", d.handleEC2DescribeImageAttribute, "spinifex-workers"},
|
||||
{"ec2.ModifyImageAttribute", d.handleEC2ModifyImageAttribute, "spinifex-workers"},
|
||||
{"ec2.ResetImageAttribute", d.handleEC2ResetImageAttribute, "spinifex-workers"},
|
||||
{"ec2.CreateVolume", d.handleEC2CreateVolume, "spinifex-workers"},
|
||||
{"ec2.DescribeVolumes", d.handleEC2DescribeVolumes, "spinifex-workers"},
|
||||
{"ec2.DeregisterImage", handleNATSRequest(d.imageService.DeregisterImage), "spinifex-workers"},
|
||||
{"ec2.RegisterImage", handleNATSRequest(d.imageService.RegisterImage), "spinifex-workers"},
|
||||
{"ec2.CopyImage", handleNATSRequest(d.imageService.CopyImage), "spinifex-workers"},
|
||||
{"ec2.DescribeImageAttribute", handleNATSRequest(d.imageService.DescribeImageAttribute), "spinifex-workers"},
|
||||
{"ec2.ModifyImageAttribute", handleNATSRequest(d.imageService.ModifyImageAttribute), "spinifex-workers"},
|
||||
{"ec2.ResetImageAttribute", handleNATSRequest(d.imageService.ResetImageAttribute), "spinifex-workers"},
|
||||
{"ec2.CreateVolume", handleNATSRequest(d.volumeService.CreateVolume), "spinifex-workers"},
|
||||
{"ec2.DescribeVolumes", handleNATSRequest(d.volumeService.DescribeVolumes), "spinifex-workers"},
|
||||
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "spinifex-workers"},
|
||||
{"ec2.DeleteVolume", d.handleEC2DeleteVolume, "spinifex-workers"},
|
||||
{"ec2.DescribeVolumeStatus", d.handleEC2DescribeVolumeStatus, "spinifex-workers"},
|
||||
{"ec2.DescribeVolumesModifications", d.handleEC2DescribeVolumesModifications, "spinifex-workers"},
|
||||
{"ec2.CreateSnapshot", d.handleEC2CreateSnapshot, "spinifex-workers"},
|
||||
{"ec2.DescribeSnapshots", d.handleEC2DescribeSnapshots, "spinifex-workers"},
|
||||
{"ec2.DeleteSnapshot", d.handleEC2DeleteSnapshot, "spinifex-workers"},
|
||||
{"ec2.CopySnapshot", d.handleEC2CopySnapshot, "spinifex-workers"},
|
||||
{"ec2.CreateTags", d.handleEC2CreateTags, "spinifex-workers"},
|
||||
{"ec2.DeleteTags", d.handleEC2DeleteTags, "spinifex-workers"},
|
||||
{"ec2.DescribeTags", d.handleEC2DescribeTags, "spinifex-workers"},
|
||||
{"ec2.CreateEgressOnlyInternetGateway", d.handleEC2CreateEgressOnlyInternetGateway, "spinifex-workers"},
|
||||
{"ec2.DeleteEgressOnlyInternetGateway", d.handleEC2DeleteEgressOnlyInternetGateway, "spinifex-workers"},
|
||||
{"ec2.DescribeEgressOnlyInternetGateways", d.handleEC2DescribeEgressOnlyInternetGateways, "spinifex-workers"},
|
||||
{"ec2.CreateInternetGateway", d.handleEC2CreateInternetGateway, "spinifex-workers"},
|
||||
{"ec2.DeleteInternetGateway", d.handleEC2DeleteInternetGateway, "spinifex-workers"},
|
||||
{"ec2.DescribeInternetGateways", d.handleEC2DescribeInternetGateways, "spinifex-workers"},
|
||||
{"ec2.AttachInternetGateway", d.handleEC2AttachInternetGateway, "spinifex-workers"},
|
||||
{"ec2.DetachInternetGateway", d.handleEC2DetachInternetGateway, "spinifex-workers"},
|
||||
{"ec2.CreatePlacementGroup", d.handleEC2CreatePlacementGroup, "spinifex-workers"},
|
||||
{"ec2.DeletePlacementGroup", d.handleEC2DeletePlacementGroup, "spinifex-workers"},
|
||||
{"ec2.DescribePlacementGroups", d.handleEC2DescribePlacementGroups, "spinifex-workers"},
|
||||
{"ec2.ReserveSpreadNodes", d.handleEC2ReserveSpreadNodes, "spinifex-workers"},
|
||||
{"ec2.FinalizeSpreadInstances", d.handleEC2FinalizeSpreadInstances, "spinifex-workers"},
|
||||
{"ec2.ReleaseSpreadNodes", d.handleEC2ReleaseSpreadNodes, "spinifex-workers"},
|
||||
{"ec2.RemoveInstanceFromPlacementGroup", d.handleEC2RemoveInstanceFromPlacementGroup, "spinifex-workers"},
|
||||
{"ec2.ReserveClusterNode", d.handleEC2ReserveClusterNode, "spinifex-workers"},
|
||||
{"ec2.FinalizeClusterInstances", d.handleEC2FinalizeClusterInstances, "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplate", d.handleEC2CreateLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplateVersion", d.handleEC2CreateLaunchTemplateVersion, "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplate", d.handleEC2DeleteLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplateVersions", d.handleEC2DeleteLaunchTemplateVersions, "spinifex-workers"},
|
||||
{"ec2.ModifyLaunchTemplate", d.handleEC2ModifyLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplates", d.handleEC2DescribeLaunchTemplates, "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplateVersions", d.handleEC2DescribeLaunchTemplateVersions, "spinifex-workers"},
|
||||
{"ec2.PutSpotInstanceRequests", d.handleEC2PutSpotInstanceRequests, "spinifex-workers"},
|
||||
{"ec2.DescribeSpotInstanceRequests", d.handleEC2DescribeSpotInstanceRequests, "spinifex-workers"},
|
||||
{"ec2.CancelSpotInstanceRequests", d.handleEC2CancelSpotInstanceRequests, "spinifex-workers"},
|
||||
{"ec2.DeleteVolume", handleNATSRequest(d.volumeService.DeleteVolume), "spinifex-workers"},
|
||||
{"ec2.DescribeVolumeStatus", handleNATSRequest(d.volumeService.DescribeVolumeStatus), "spinifex-workers"},
|
||||
{"ec2.DescribeVolumesModifications", handleNATSRequest(d.volumeService.DescribeVolumesModifications), "spinifex-workers"},
|
||||
{"ec2.CreateSnapshot", handleNATSRequest(d.snapshotService.CreateSnapshot), "spinifex-workers"},
|
||||
{"ec2.DescribeSnapshots", handleNATSRequest(d.snapshotService.DescribeSnapshots), "spinifex-workers"},
|
||||
{"ec2.DeleteSnapshot", handleNATSRequest(d.snapshotService.DeleteSnapshot), "spinifex-workers"},
|
||||
{"ec2.CopySnapshot", handleNATSRequest(d.snapshotService.CopySnapshot), "spinifex-workers"},
|
||||
{"ec2.CreateTags", handleNATSRequest(d.createTags), "spinifex-workers"},
|
||||
{"ec2.DeleteTags", handleNATSRequest(d.deleteTags), "spinifex-workers"},
|
||||
{"ec2.DescribeTags", handleNATSRequest(d.tagsService.DescribeTags), "spinifex-workers"},
|
||||
{"ec2.CreateEgressOnlyInternetGateway", handleNATSRequest(d.eigwService.CreateEgressOnlyInternetGateway), "spinifex-workers"},
|
||||
{"ec2.DeleteEgressOnlyInternetGateway", handleNATSRequest(d.eigwService.DeleteEgressOnlyInternetGateway), "spinifex-workers"},
|
||||
{"ec2.DescribeEgressOnlyInternetGateways", handleNATSRequest(d.eigwService.DescribeEgressOnlyInternetGateways), "spinifex-workers"},
|
||||
{"ec2.CreateInternetGateway", handleNATSRequest(d.igwService.CreateInternetGateway), "spinifex-workers"},
|
||||
{"ec2.DeleteInternetGateway", handleNATSRequest(d.igwService.DeleteInternetGateway), "spinifex-workers"},
|
||||
{"ec2.DescribeInternetGateways", handleNATSRequest(d.igwService.DescribeInternetGateways), "spinifex-workers"},
|
||||
{"ec2.AttachInternetGateway", handleNATSRequest(d.igwService.AttachInternetGateway), "spinifex-workers"},
|
||||
{"ec2.DetachInternetGateway", handleNATSRequest(d.igwService.DetachInternetGateway), "spinifex-workers"},
|
||||
{"ec2.CreatePlacementGroup", handleNATSRequest(d.placementGroupService.CreatePlacementGroup), "spinifex-workers"},
|
||||
{"ec2.DeletePlacementGroup", handleNATSRequest(d.placementGroupService.DeletePlacementGroup), "spinifex-workers"},
|
||||
{"ec2.DescribePlacementGroups", handleNATSRequest(d.placementGroupService.DescribePlacementGroups), "spinifex-workers"},
|
||||
{"ec2.ReserveSpreadNodes", handleNATSRequest(d.placementGroupService.ReserveSpreadNodes), "spinifex-workers"},
|
||||
{"ec2.FinalizeSpreadInstances", handleNATSRequest(d.placementGroupService.FinalizeSpreadInstances), "spinifex-workers"},
|
||||
{"ec2.ReleaseSpreadNodes", handleNATSRequest(d.placementGroupService.ReleaseSpreadNodes), "spinifex-workers"},
|
||||
{"ec2.RemoveInstanceFromPlacementGroup", handleNATSRequest(d.placementGroupService.RemoveInstance), "spinifex-workers"},
|
||||
{"ec2.ReserveClusterNode", handleNATSRequest(d.placementGroupService.ReserveClusterNode), "spinifex-workers"},
|
||||
{"ec2.FinalizeClusterInstances", handleNATSRequest(d.placementGroupService.FinalizeClusterInstances), "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplate", handleNATSRequest(d.launchTemplateService.CreateLaunchTemplate), "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplateVersion", handleNATSRequest(d.launchTemplateService.CreateLaunchTemplateVersion), "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplate", handleNATSRequest(d.launchTemplateService.DeleteLaunchTemplate), "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplateVersions", handleNATSRequest(d.launchTemplateService.DeleteLaunchTemplateVersions), "spinifex-workers"},
|
||||
{"ec2.ModifyLaunchTemplate", handleNATSRequest(d.launchTemplateService.ModifyLaunchTemplate), "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplates", handleNATSRequest(d.launchTemplateService.DescribeLaunchTemplates), "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplateVersions", handleNATSRequest(d.launchTemplateService.DescribeLaunchTemplateVersions), "spinifex-workers"},
|
||||
{"ec2.PutSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.PutSpotInstanceRequests), "spinifex-workers"},
|
||||
{"ec2.DescribeSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.DescribeSpotInstanceRequests), "spinifex-workers"},
|
||||
{"ec2.CancelSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.CancelSpotInstanceRequests), "spinifex-workers"},
|
||||
// Capacity reservations: Create is node-targeted (gateway pins one node);
|
||||
// Describe fans out and Cancel broadcasts, so both use plain Subscribe.
|
||||
{fmt.Sprintf("ec2.CreateCapacityReservation.%s", d.node), d.handleEC2CreateCapacityReservation, ""},
|
||||
{"ec2.DescribeCapacityReservations", d.handleEC2DescribeCapacityReservations, ""},
|
||||
{"ec2.CancelCapacityReservation", d.handleEC2CancelCapacityReservation, ""},
|
||||
{"ec2.CreateNatGateway", d.handleEC2CreateNatGateway, "spinifex-workers"},
|
||||
{"ec2.DeleteNatGateway", d.handleEC2DeleteNatGateway, "spinifex-workers"},
|
||||
{"ec2.DescribeNatGateways", d.handleEC2DescribeNatGateways, "spinifex-workers"},
|
||||
{"ec2.CreateRouteTable", d.handleEC2CreateRouteTable, "spinifex-workers"},
|
||||
{"ec2.DeleteRouteTable", d.handleEC2DeleteRouteTable, "spinifex-workers"},
|
||||
{"ec2.DescribeRouteTables", d.handleEC2DescribeRouteTables, "spinifex-workers"},
|
||||
{"ec2.CreateRoute", d.handleEC2CreateRoute, "spinifex-workers"},
|
||||
{"ec2.DeleteRoute", d.handleEC2DeleteRoute, "spinifex-workers"},
|
||||
{"ec2.ReplaceRoute", d.handleEC2ReplaceRoute, "spinifex-workers"},
|
||||
{"ec2.AssociateRouteTable", d.handleEC2AssociateRouteTable, "spinifex-workers"},
|
||||
{"ec2.DisassociateRouteTable", d.handleEC2DisassociateRouteTable, "spinifex-workers"},
|
||||
{"ec2.ReplaceRouteTableAssociation", d.handleEC2ReplaceRouteTableAssociation, "spinifex-workers"},
|
||||
{"ec2.CreateVpc", d.handleEC2CreateVpc, "spinifex-workers"},
|
||||
{"ec2.DeleteVpc", d.handleEC2DeleteVpc, "spinifex-workers"},
|
||||
{"ec2.DescribeVpcs", d.handleEC2DescribeVpcs, "spinifex-workers"},
|
||||
{"ec2.CreateSubnet", d.handleEC2CreateSubnet, "spinifex-workers"},
|
||||
{"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "spinifex-workers"},
|
||||
{"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "spinifex-workers"},
|
||||
{"ec2.ModifySubnetAttribute", d.handleEC2ModifySubnetAttribute, "spinifex-workers"},
|
||||
{"ec2.ModifyVpcAttribute", d.handleEC2ModifyVpcAttribute, "spinifex-workers"},
|
||||
{"ec2.DescribeVpcAttribute", d.handleEC2DescribeVpcAttribute, "spinifex-workers"},
|
||||
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"},
|
||||
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"},
|
||||
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"},
|
||||
{"ec2.ModifyNetworkInterfaceAttribute", d.handleEC2ModifyNetworkInterfaceAttribute, "spinifex-workers"},
|
||||
{"ec2.CreateSecurityGroup", d.handleEC2CreateSecurityGroup, "spinifex-workers"},
|
||||
{"ec2.DeleteSecurityGroup", d.handleEC2DeleteSecurityGroup, "spinifex-workers"},
|
||||
{"ec2.DescribeSecurityGroups", d.handleEC2DescribeSecurityGroups, "spinifex-workers"},
|
||||
{"ec2.DescribeSecurityGroupRules", d.handleEC2DescribeSecurityGroupRules, "spinifex-workers"},
|
||||
{"ec2.AuthorizeSecurityGroupIngress", d.handleEC2AuthorizeSecurityGroupIngress, "spinifex-workers"},
|
||||
{"ec2.AuthorizeSecurityGroupEgress", d.handleEC2AuthorizeSecurityGroupEgress, "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupIngress", d.handleEC2RevokeSecurityGroupIngress, "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupEgress", d.handleEC2RevokeSecurityGroupEgress, "spinifex-workers"},
|
||||
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "spinifex-workers"},
|
||||
{"ec2.ModifyInstanceMetadataOptions", d.handleEC2ModifyInstanceMetadataOptions, "spinifex-workers"},
|
||||
{"ec2.CreateNatGateway", handleNATSRequest(d.natGatewayService.CreateNatGateway), "spinifex-workers"},
|
||||
{"ec2.DeleteNatGateway", handleNATSRequest(d.natGatewayService.DeleteNatGateway), "spinifex-workers"},
|
||||
{"ec2.DescribeNatGateways", handleNATSRequest(d.natGatewayService.DescribeNatGateways), "spinifex-workers"},
|
||||
{"ec2.CreateRouteTable", handleNATSRequest(d.routeTableService.CreateRouteTable), "spinifex-workers"},
|
||||
{"ec2.DeleteRouteTable", handleNATSRequest(d.routeTableService.DeleteRouteTable), "spinifex-workers"},
|
||||
{"ec2.DescribeRouteTables", handleNATSRequest(d.routeTableService.DescribeRouteTables), "spinifex-workers"},
|
||||
{"ec2.CreateRoute", handleNATSRequest(d.routeTableService.CreateRoute), "spinifex-workers"},
|
||||
{"ec2.DeleteRoute", handleNATSRequest(d.routeTableService.DeleteRoute), "spinifex-workers"},
|
||||
{"ec2.ReplaceRoute", handleNATSRequest(d.routeTableService.ReplaceRoute), "spinifex-workers"},
|
||||
{"ec2.AssociateRouteTable", handleNATSRequest(d.routeTableService.AssociateRouteTable), "spinifex-workers"},
|
||||
{"ec2.DisassociateRouteTable", handleNATSRequest(d.routeTableService.DisassociateRouteTable), "spinifex-workers"},
|
||||
{"ec2.ReplaceRouteTableAssociation", handleNATSRequest(d.routeTableService.ReplaceRouteTableAssociation), "spinifex-workers"},
|
||||
{"ec2.CreateVpc", handleNATSRequest(d.vpcService.CreateVpc), "spinifex-workers"},
|
||||
{"ec2.DeleteVpc", handleNATSRequest(d.vpcService.DeleteVpc), "spinifex-workers"},
|
||||
{"ec2.DescribeVpcs", handleNATSRequest(d.vpcService.DescribeVpcs), "spinifex-workers"},
|
||||
{"ec2.CreateSubnet", handleNATSRequest(d.vpcService.CreateSubnet), "spinifex-workers"},
|
||||
{"ec2.DeleteSubnet", handleNATSRequest(d.vpcService.DeleteSubnet), "spinifex-workers"},
|
||||
{"ec2.DescribeSubnets", handleNATSRequest(d.vpcService.DescribeSubnets), "spinifex-workers"},
|
||||
{"ec2.ModifySubnetAttribute", handleNATSRequest(d.vpcService.ModifySubnetAttribute), "spinifex-workers"},
|
||||
{"ec2.ModifyVpcAttribute", handleNATSRequest(d.vpcService.ModifyVpcAttribute), "spinifex-workers"},
|
||||
{"ec2.DescribeVpcAttribute", handleNATSRequest(d.vpcService.DescribeVpcAttribute), "spinifex-workers"},
|
||||
{"ec2.CreateNetworkInterface", handleNATSRequest(d.vpcService.CreateNetworkInterface), "spinifex-workers"},
|
||||
{"ec2.DeleteNetworkInterface", handleNATSRequest(d.vpcService.DeleteNetworkInterface), "spinifex-workers"},
|
||||
{"ec2.DescribeNetworkInterfaces", handleNATSRequest(d.vpcService.DescribeNetworkInterfaces), "spinifex-workers"},
|
||||
{"ec2.ModifyNetworkInterfaceAttribute", handleNATSRequest(d.vpcService.ModifyNetworkInterfaceAttribute), "spinifex-workers"},
|
||||
{"ec2.CreateSecurityGroup", handleNATSRequest(d.vpcService.CreateSecurityGroup), "spinifex-workers"},
|
||||
{"ec2.DeleteSecurityGroup", handleNATSRequest(d.vpcService.DeleteSecurityGroup), "spinifex-workers"},
|
||||
{"ec2.DescribeSecurityGroups", handleNATSRequest(d.vpcService.DescribeSecurityGroups), "spinifex-workers"},
|
||||
{"ec2.DescribeSecurityGroupRules", handleNATSRequest(d.vpcService.DescribeSecurityGroupRules), "spinifex-workers"},
|
||||
{"ec2.AuthorizeSecurityGroupIngress", handleNATSRequest(d.vpcService.AuthorizeSecurityGroupIngress), "spinifex-workers"},
|
||||
{"ec2.AuthorizeSecurityGroupEgress", handleNATSRequest(d.vpcService.AuthorizeSecurityGroupEgress), "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupIngress", handleNATSRequest(d.vpcService.RevokeSecurityGroupIngress), "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupEgress", handleNATSRequest(d.vpcService.RevokeSecurityGroupEgress), "spinifex-workers"},
|
||||
{"ec2.ModifyInstanceAttribute", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), "spinifex-workers"},
|
||||
{"ec2.ModifyInstanceMetadataOptions", handleNATSRequest(d.instanceService.ModifyInstanceMetadataOptions), "spinifex-workers"},
|
||||
{"ec2.start", d.handleEC2StartStoppedInstance, "spinifex-workers"},
|
||||
{fmt.Sprintf("ec2.start.%s", d.node), d.handleEC2StartStoppedInstanceDirect, ""},
|
||||
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "spinifex-workers"},
|
||||
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "spinifex-workers"},
|
||||
{"ec2.DescribeTerminatedInstances", d.handleEC2DescribeTerminatedInstances, "spinifex-workers"},
|
||||
// ec2.start.{node} is the node-targeted variant: it always starts locally
|
||||
// and never re-forwards, so it goes straight to the service (no routing loop).
|
||||
{fmt.Sprintf("ec2.start.%s", d.node), handleNATSRequest(d.instanceService.StartStoppedInstance), ""},
|
||||
{"ec2.terminate", handleNATSRequest(d.instanceService.TerminateStoppedInstance), "spinifex-workers"},
|
||||
{"ec2.DescribeStoppedInstances", handleNATSRequest(d.instanceService.DescribeStoppedInstances), "spinifex-workers"},
|
||||
{"ec2.DescribeTerminatedInstances", handleNATSRequest(d.instanceService.DescribeTerminatedInstances), "spinifex-workers"},
|
||||
// these fan out to all nodes and gateway aggregates the results. The
|
||||
// handler only sees per-daemon local state (vmMgr/stoppedStore), so
|
||||
// any queue-grouped routing produces 1/N false NotFound responses.
|
||||
{"ec2.DescribeInstances", d.handleEC2DescribeInstances, ""},
|
||||
{"ec2.DescribeInstanceStatus", d.handleEC2DescribeInstanceStatus, ""},
|
||||
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
|
||||
{"ec2.DescribeInstanceAttribute", d.handleEC2DescribeInstanceAttribute, ""},
|
||||
{"ec2.DescribeInstances", handleNATSRequest(d.instanceService.DescribeInstances), ""},
|
||||
{"ec2.DescribeInstanceStatus", handleNATSRequest(d.instanceService.DescribeInstanceStatus), ""},
|
||||
{"ec2.DescribeInstanceTypes", handleNATSRequest(d.instanceService.DescribeInstanceTypes), ""},
|
||||
{"ec2.DescribeInstanceAttribute", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), ""},
|
||||
// IAM instance profile associations: Disassociate/Replace mutate the
|
||||
// owning daemon's vm.VM (non-owners NoOp with Found=false); Describe
|
||||
// returns per-daemon matches that the gateway concatenates.
|
||||
{"ec2.IamProfileAssociation.disassociate", d.handleIamProfileDisassociate, ""},
|
||||
{"ec2.IamProfileAssociation.replace", d.handleIamProfileReplace, ""},
|
||||
{"ec2.IamProfileAssociation.describe", d.handleIamProfileDescribe, ""},
|
||||
{"ec2.EnableEbsEncryptionByDefault", d.handleEC2EnableEbsEncryptionByDefault, "spinifex-workers"},
|
||||
{"ec2.DisableEbsEncryptionByDefault", d.handleEC2DisableEbsEncryptionByDefault, "spinifex-workers"},
|
||||
{"ec2.GetEbsEncryptionByDefault", d.handleEC2GetEbsEncryptionByDefault, "spinifex-workers"},
|
||||
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "spinifex-workers"},
|
||||
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "spinifex-workers"},
|
||||
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "spinifex-workers"},
|
||||
{"ec2.IamProfileAssociation.disassociate", handleNATSRequest(d.instanceService.DisassociateIamProfileAssociation), ""},
|
||||
{"ec2.IamProfileAssociation.replace", handleNATSRequest(d.instanceService.ReplaceIamProfileAssociation), ""},
|
||||
{"ec2.IamProfileAssociation.describe", handleNATSRequest(d.instanceService.DescribeIamProfileAssociations), ""},
|
||||
{"ec2.EnableEbsEncryptionByDefault", handleNATSRequest(d.accountService.EnableEbsEncryptionByDefault), "spinifex-workers"},
|
||||
{"ec2.DisableEbsEncryptionByDefault", handleNATSRequest(d.accountService.DisableEbsEncryptionByDefault), "spinifex-workers"},
|
||||
{"ec2.GetEbsEncryptionByDefault", handleNATSRequest(d.accountService.GetEbsEncryptionByDefault), "spinifex-workers"},
|
||||
{"ec2.GetSerialConsoleAccessStatus", handleNATSRequest(d.accountService.GetSerialConsoleAccessStatus), "spinifex-workers"},
|
||||
{"ec2.EnableSerialConsoleAccess", handleNATSRequest(d.accountService.EnableSerialConsoleAccess), "spinifex-workers"},
|
||||
{"ec2.DisableSerialConsoleAccess", handleNATSRequest(d.accountService.DisableSerialConsoleAccess), "spinifex-workers"},
|
||||
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
|
||||
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
|
||||
{"spinifex.node.status", d.handleNodeStatus, ""},
|
||||
@@ -907,41 +911,41 @@ func (d *Daemon) subscribeAll() error {
|
||||
// Without a subscriber the gateway returns nats.ErrNoResponders → ServiceUnavailable.
|
||||
if d.elbv2Service.GatewayURL != "" {
|
||||
subs = append(subs,
|
||||
natsSub{"elbv2.CreateLoadBalancer", d.handleELBv2CreateLoadBalancer, "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteLoadBalancer", d.handleELBv2DeleteLoadBalancer, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeLoadBalancers", d.handleELBv2DescribeLoadBalancers, "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateTargetGroup", d.handleELBv2CreateTargetGroup, "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyTargetGroup", d.handleELBv2ModifyTargetGroup, "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteTargetGroup", d.handleELBv2DeleteTargetGroup, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetGroups", d.handleELBv2DescribeTargetGroups, "spinifex-workers"},
|
||||
natsSub{"elbv2.RegisterTargets", d.handleELBv2RegisterTargets, "spinifex-workers"},
|
||||
natsSub{"elbv2.DeregisterTargets", d.handleELBv2DeregisterTargets, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetHealth", d.handleELBv2DescribeTargetHealth, "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyListener", d.handleELBv2ModifyListener, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateRule", d.handleELBv2CreateRule, "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyRule", d.handleELBv2ModifyRule, "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteRule", d.handleELBv2DeleteRule, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeRules", d.handleELBv2DescribeRules, "spinifex-workers"},
|
||||
natsSub{"elbv2.SetRulePriorities", d.handleELBv2SetRulePriorities, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTags", d.handleELBv2DescribeTags, "spinifex-workers"},
|
||||
natsSub{"elbv2.AddTags", d.handleELBv2AddTags, "spinifex-workers"},
|
||||
natsSub{"elbv2.RemoveTags", d.handleELBv2RemoveTags, "spinifex-workers"},
|
||||
natsSub{"elbv2.LBAgentHeartbeat", d.handleELBv2LBAgentHeartbeat, "spinifex-workers"},
|
||||
natsSub{"elbv2.GetLBConfig", d.handleELBv2GetLBConfig, "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyTargetGroupAttributes", d.handleELBv2ModifyTargetGroupAttributes, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetGroupAttributes", d.handleELBv2DescribeTargetGroupAttributes, "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyLoadBalancerAttributes", d.handleELBv2ModifyLoadBalancerAttributes, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeLoadBalancerAttributes", d.handleELBv2DescribeLoadBalancerAttributes, "spinifex-workers"},
|
||||
natsSub{"elbv2.SetSecurityGroups", d.handleELBv2SetSecurityGroups, "spinifex-workers"},
|
||||
natsSub{"elbv2.SetIpAddressType", d.handleELBv2SetIpAddressType, "spinifex-workers"},
|
||||
natsSub{"elbv2.SetSubnets", d.handleELBv2SetSubnets, "spinifex-workers"},
|
||||
natsSub{"elbv2.AddListenerCertificates", d.handleELBv2AddListenerCertificates, "spinifex-workers"},
|
||||
natsSub{"elbv2.RemoveListenerCertificates", d.handleELBv2RemoveListenerCertificates, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeListenerCertificates", d.handleELBv2DescribeListenerCertificates, "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeSSLPolicies", d.handleELBv2DescribeSSLPolicies, "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateLoadBalancer", handleNATSRequest(d.elbv2Service.CreateLoadBalancer), "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteLoadBalancer", handleNATSRequest(d.elbv2Service.DeleteLoadBalancer), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeLoadBalancers", handleNATSRequest(d.elbv2Service.DescribeLoadBalancers), "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateTargetGroup", handleNATSRequest(d.elbv2Service.CreateTargetGroup), "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyTargetGroup", handleNATSRequest(d.elbv2Service.ModifyTargetGroup), "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteTargetGroup", handleNATSRequest(d.elbv2Service.DeleteTargetGroup), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetGroups", handleNATSRequest(d.elbv2Service.DescribeTargetGroups), "spinifex-workers"},
|
||||
natsSub{"elbv2.RegisterTargets", handleNATSRequest(d.elbv2Service.RegisterTargets), "spinifex-workers"},
|
||||
natsSub{"elbv2.DeregisterTargets", handleNATSRequest(d.elbv2Service.DeregisterTargets), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetHealth", handleNATSRequest(d.elbv2Service.DescribeTargetHealth), "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateListener", handleNATSRequest(d.elbv2Service.CreateListener), "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteListener", handleNATSRequest(d.elbv2Service.DeleteListener), "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyListener", handleNATSRequest(d.elbv2Service.ModifyListener), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeListeners", handleNATSRequest(d.elbv2Service.DescribeListeners), "spinifex-workers"},
|
||||
natsSub{"elbv2.CreateRule", handleNATSRequest(d.elbv2Service.CreateRule), "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyRule", handleNATSRequest(d.elbv2Service.ModifyRule), "spinifex-workers"},
|
||||
natsSub{"elbv2.DeleteRule", handleNATSRequest(d.elbv2Service.DeleteRule), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeRules", handleNATSRequest(d.elbv2Service.DescribeRules), "spinifex-workers"},
|
||||
natsSub{"elbv2.SetRulePriorities", handleNATSRequest(d.elbv2Service.SetRulePriorities), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTags", handleNATSRequest(d.elbv2Service.DescribeTags), "spinifex-workers"},
|
||||
natsSub{"elbv2.AddTags", handleNATSRequest(d.elbv2Service.AddTags), "spinifex-workers"},
|
||||
natsSub{"elbv2.RemoveTags", handleNATSRequest(d.elbv2Service.RemoveTags), "spinifex-workers"},
|
||||
natsSub{"elbv2.LBAgentHeartbeat", handleNATSRequest(d.elbv2Service.LBAgentHeartbeat), "spinifex-workers"},
|
||||
natsSub{"elbv2.GetLBConfig", handleNATSRequest(d.elbv2Service.GetLBConfig), "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyTargetGroupAttributes", handleNATSRequest(d.elbv2Service.ModifyTargetGroupAttributes), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeTargetGroupAttributes", handleNATSRequest(d.elbv2Service.DescribeTargetGroupAttributes), "spinifex-workers"},
|
||||
natsSub{"elbv2.ModifyLoadBalancerAttributes", handleNATSRequest(d.elbv2Service.ModifyLoadBalancerAttributes), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeLoadBalancerAttributes", handleNATSRequest(d.elbv2Service.DescribeLoadBalancerAttributes), "spinifex-workers"},
|
||||
natsSub{"elbv2.SetSecurityGroups", handleNATSRequest(d.elbv2Service.SetSecurityGroups), "spinifex-workers"},
|
||||
natsSub{"elbv2.SetIpAddressType", handleNATSRequest(d.elbv2Service.SetIpAddressType), "spinifex-workers"},
|
||||
natsSub{"elbv2.SetSubnets", handleNATSRequest(d.elbv2Service.SetSubnets), "spinifex-workers"},
|
||||
natsSub{"elbv2.AddListenerCertificates", handleNATSRequest(d.elbv2Service.AddListenerCertificates), "spinifex-workers"},
|
||||
natsSub{"elbv2.RemoveListenerCertificates", handleNATSRequest(d.elbv2Service.RemoveListenerCertificates), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeListenerCertificates", handleNATSRequest(d.elbv2Service.DescribeListenerCertificates), "spinifex-workers"},
|
||||
natsSub{"elbv2.DescribeSSLPolicies", handleNATSRequest(d.elbv2Service.DescribeSSLPolicies), "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -950,97 +954,97 @@ func (d *Daemon) subscribeAll() error {
|
||||
// stable while real bodies land.
|
||||
if d.eksService != nil {
|
||||
subs = append(subs,
|
||||
natsSub{"eks.CreateCluster", d.handleEKSCreateCluster, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeCluster", d.handleEKSDescribeCluster, "spinifex-workers"},
|
||||
natsSub{"eks.ListClusters", d.handleEKSListClusters, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateClusterConfig", d.handleEKSUpdateClusterConfig, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateClusterVersion", d.handleEKSUpdateClusterVersion, "spinifex-workers"},
|
||||
natsSub{"eks.DeleteCluster", d.handleEKSDeleteCluster, "spinifex-workers"},
|
||||
natsSub{"eks.CreateNodegroup", d.handleEKSCreateNodegroup, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeNodegroup", d.handleEKSDescribeNodegroup, "spinifex-workers"},
|
||||
natsSub{"eks.ListNodegroups", d.handleEKSListNodegroups, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateNodegroupConfig", d.handleEKSUpdateNodegroupConfig, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateNodegroupVersion", d.handleEKSUpdateNodegroupVersion, "spinifex-workers"},
|
||||
natsSub{"eks.DeleteNodegroup", d.handleEKSDeleteNodegroup, "spinifex-workers"},
|
||||
natsSub{"eks.CreateAccessEntry", d.handleEKSCreateAccessEntry, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAccessEntry", d.handleEKSDescribeAccessEntry, "spinifex-workers"},
|
||||
natsSub{"eks.ListAccessEntries", d.handleEKSListAccessEntries, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateAccessEntry", d.handleEKSUpdateAccessEntry, "spinifex-workers"},
|
||||
natsSub{"eks.DeleteAccessEntry", d.handleEKSDeleteAccessEntry, "spinifex-workers"},
|
||||
natsSub{"eks.AssociateAccessPolicy", d.handleEKSAssociateAccessPolicy, "spinifex-workers"},
|
||||
natsSub{"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, "spinifex-workers"},
|
||||
natsSub{"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, "spinifex-workers"},
|
||||
natsSub{"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, "spinifex-workers"},
|
||||
natsSub{"eks.ListAddons", d.handleEKSListAddons, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, "spinifex-workers"},
|
||||
natsSub{"eks.CreateAddon", d.handleEKSCreateAddon, "spinifex-workers"},
|
||||
natsSub{"eks.DeleteAddon", d.handleEKSDeleteAddon, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAddon", d.handleEKSDescribeAddon, "spinifex-workers"},
|
||||
natsSub{"eks.UpdateAddon", d.handleEKSUpdateAddon, "spinifex-workers"},
|
||||
natsSub{"eks.ListStagedAddonManifests", d.handleEKSListStagedAddonManifests, "spinifex-workers"},
|
||||
natsSub{"eks.GetRecoveryDirective", d.handleEKSGetRecoveryDirective, "spinifex-workers"},
|
||||
natsSub{"eks.SetRecoveryDirective", d.handleEKSSetRecoveryDirective, "spinifex-workers"},
|
||||
natsSub{"eks.RestoreSnapshot", d.handleEKSRestoreSnapshot, "spinifex-workers"},
|
||||
natsSub{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, "spinifex-workers"},
|
||||
natsSub{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, "spinifex-workers"},
|
||||
natsSub{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, "spinifex-workers"},
|
||||
natsSub{"eks.DisassociateIdentityProviderConfig", d.handleEKSDisassociateIdentityProviderConfig, "spinifex-workers"},
|
||||
natsSub{"eks.TagResource", d.handleEKSTagResource, "spinifex-workers"},
|
||||
natsSub{"eks.UntagResource", d.handleEKSUntagResource, "spinifex-workers"},
|
||||
natsSub{"eks.ListTagsForResource", d.handleEKSListTagsForResource, "spinifex-workers"},
|
||||
natsSub{"eks.CreateCluster", handleNATSRequestWithPrincipal(d.eksService.CreateCluster), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeCluster", handleNATSRequest(d.eksService.DescribeCluster), "spinifex-workers"},
|
||||
natsSub{"eks.ListClusters", handleNATSRequest(d.eksService.ListClusters), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateClusterConfig", handleNATSRequest(d.eksService.UpdateClusterConfig), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateClusterVersion", handleNATSRequest(d.eksService.UpdateClusterVersion), "spinifex-workers"},
|
||||
natsSub{"eks.DeleteCluster", handleNATSRequest(d.eksService.DeleteCluster), "spinifex-workers"},
|
||||
natsSub{"eks.CreateNodegroup", handleNATSRequest(d.eksService.CreateNodegroup), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeNodegroup", handleNATSRequest(d.eksService.DescribeNodegroup), "spinifex-workers"},
|
||||
natsSub{"eks.ListNodegroups", handleNATSRequest(d.eksService.ListNodegroups), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateNodegroupConfig", handleNATSRequest(d.eksService.UpdateNodegroupConfig), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateNodegroupVersion", handleNATSRequest(d.eksService.UpdateNodegroupVersion), "spinifex-workers"},
|
||||
natsSub{"eks.DeleteNodegroup", handleNATSRequest(d.eksService.DeleteNodegroup), "spinifex-workers"},
|
||||
natsSub{"eks.CreateAccessEntry", handleNATSRequest(d.eksService.CreateAccessEntry), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAccessEntry", handleNATSRequest(d.eksService.DescribeAccessEntry), "spinifex-workers"},
|
||||
natsSub{"eks.ListAccessEntries", handleNATSRequest(d.eksService.ListAccessEntries), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateAccessEntry", handleNATSRequest(d.eksService.UpdateAccessEntry), "spinifex-workers"},
|
||||
natsSub{"eks.DeleteAccessEntry", handleNATSRequest(d.eksService.DeleteAccessEntry), "spinifex-workers"},
|
||||
natsSub{"eks.AssociateAccessPolicy", handleNATSRequest(d.eksService.AssociateAccessPolicy), "spinifex-workers"},
|
||||
natsSub{"eks.DisassociateAccessPolicy", handleNATSRequest(d.eksService.DisassociateAccessPolicy), "spinifex-workers"},
|
||||
natsSub{"eks.ListAssociatedAccessPolicies", handleNATSRequest(d.eksService.ListAssociatedAccessPolicies), "spinifex-workers"},
|
||||
natsSub{"eks.ListAccessPolicies", handleNATSRequest(d.eksService.ListAccessPolicies), "spinifex-workers"},
|
||||
natsSub{"eks.ListAddons", handleNATSRequest(d.eksService.ListAddons), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAddonVersions", handleNATSRequest(d.eksService.DescribeAddonVersions), "spinifex-workers"},
|
||||
natsSub{"eks.CreateAddon", handleNATSRequest(d.eksService.CreateAddon), "spinifex-workers"},
|
||||
natsSub{"eks.DeleteAddon", handleNATSRequest(d.eksService.DeleteAddon), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeAddon", handleNATSRequest(d.eksService.DescribeAddon), "spinifex-workers"},
|
||||
natsSub{"eks.UpdateAddon", handleNATSRequest(d.eksService.UpdateAddon), "spinifex-workers"},
|
||||
natsSub{"eks.ListStagedAddonManifests", handleNATSRequest(d.eksService.ListStagedAddonManifests), "spinifex-workers"},
|
||||
natsSub{"eks.GetRecoveryDirective", handleNATSRequest(d.eksService.GetRecoveryDirective), "spinifex-workers"},
|
||||
natsSub{"eks.SetRecoveryDirective", handleNATSRequest(d.eksService.SetRecoveryDirective), "spinifex-workers"},
|
||||
natsSub{"eks.RestoreSnapshot", handleNATSRequest(d.eksService.RestoreSnapshot), "spinifex-workers"},
|
||||
natsSub{"eks.AssociateIdentityProviderConfig", handleNATSRequest(d.eksService.AssociateIdentityProviderConfig), "spinifex-workers"},
|
||||
natsSub{"eks.DescribeIdentityProviderConfig", handleNATSRequest(d.eksService.DescribeIdentityProviderConfig), "spinifex-workers"},
|
||||
natsSub{"eks.ListIdentityProviderConfigs", handleNATSRequest(d.eksService.ListIdentityProviderConfigs), "spinifex-workers"},
|
||||
natsSub{"eks.DisassociateIdentityProviderConfig", handleNATSRequest(d.eksService.DisassociateIdentityProviderConfig), "spinifex-workers"},
|
||||
natsSub{"eks.TagResource", handleNATSRequest(d.eksService.TagResource), "spinifex-workers"},
|
||||
natsSub{"eks.UntagResource", handleNATSRequest(d.eksService.UntagResource), "spinifex-workers"},
|
||||
natsSub{"eks.ListTagsForResource", handleNATSRequest(d.eksService.ListTagsForResource), "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
// ECS gateway → daemon subscriptions (control plane; per-account KV).
|
||||
if d.ecsService != nil {
|
||||
subs = append(subs,
|
||||
natsSub{"ecs.CreateCluster", d.handleECSCreateCluster, "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteCluster", d.handleECSDeleteCluster, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeClusters", d.handleECSDescribeClusters, "spinifex-workers"},
|
||||
natsSub{"ecs.ListClusters", d.handleECSListClusters, "spinifex-workers"},
|
||||
natsSub{"ecs.RegisterTaskDefinition", d.handleECSRegisterTaskDefinition, "spinifex-workers"},
|
||||
natsSub{"ecs.DeregisterTaskDefinition", d.handleECSDeregisterTaskDefinition, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeTaskDefinition", d.handleECSDescribeTaskDefinition, "spinifex-workers"},
|
||||
natsSub{"ecs.ListTaskDefinitions", d.handleECSListTaskDefinitions, "spinifex-workers"},
|
||||
natsSub{"ecs.RegisterContainerInstance", d.handleECSRegisterContainerInstance, "spinifex-workers"},
|
||||
natsSub{"ecs.DeregisterContainerInstance", d.handleECSDeregisterContainerInstance, "spinifex-workers"},
|
||||
natsSub{"ecs.UpdateContainerInstancesState", d.handleECSUpdateContainerInstancesState, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeContainerInstances", d.handleECSDescribeContainerInstances, "spinifex-workers"},
|
||||
natsSub{"ecs.ListContainerInstances", d.handleECSListContainerInstances, "spinifex-workers"},
|
||||
natsSub{"ecs.RunTask", d.handleECSRunTask, "spinifex-workers"},
|
||||
natsSub{"ecs.StartTask", d.handleECSStartTask, "spinifex-workers"},
|
||||
natsSub{"ecs.StopTask", d.handleECSStopTask, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeTasks", d.handleECSDescribeTasks, "spinifex-workers"},
|
||||
natsSub{"ecs.ListTasks", d.handleECSListTasks, "spinifex-workers"},
|
||||
natsSub{"ecs.CreateService", d.handleECSCreateService, "spinifex-workers"},
|
||||
natsSub{"ecs.UpdateService", d.handleECSUpdateService, "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteService", d.handleECSDeleteService, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeServices", d.handleECSDescribeServices, "spinifex-workers"},
|
||||
natsSub{"ecs.ListServices", d.handleECSListServices, "spinifex-workers"},
|
||||
natsSub{"ecs.SubmitTaskStateChange", d.handleECSSubmitTaskStateChange, "spinifex-workers"},
|
||||
natsSub{"ecs.PollAssignments", d.handleECSPollAssignments, "spinifex-workers"},
|
||||
natsSub{"ecs.ReportTaskGPU", d.handleECSReportTaskGPU, "spinifex-workers"},
|
||||
natsSub{"ecs.ProvisionCapacity", d.handleECSProvisionCapacity, "spinifex-workers"},
|
||||
natsSub{"ecs.TagResource", d.handleECSTagResource, "spinifex-workers"},
|
||||
natsSub{"ecs.UntagResource", d.handleECSUntagResource, "spinifex-workers"},
|
||||
natsSub{"ecs.ListTagsForResource", d.handleECSListTagsForResource, "spinifex-workers"},
|
||||
natsSub{"ecs.PutClusterCapacityProviders", d.handleECSPutClusterCapacityProviders, "spinifex-workers"},
|
||||
natsSub{"ecs.CreateCapacityProvider", d.handleECSCreateCapacityProvider, "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeCapacityProviders", d.handleECSDescribeCapacityProviders, "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteCapacityProvider", d.handleECSDeleteCapacityProvider, "spinifex-workers"},
|
||||
natsSub{"ecs.CreateCluster", handleNATSRequest(d.ecsService.CreateCluster), "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteCluster", handleNATSRequest(d.ecsService.DeleteCluster), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeClusters", handleNATSRequest(d.ecsService.DescribeClusters), "spinifex-workers"},
|
||||
natsSub{"ecs.ListClusters", handleNATSRequest(d.ecsService.ListClusters), "spinifex-workers"},
|
||||
natsSub{"ecs.RegisterTaskDefinition", handleNATSRequest(d.ecsService.RegisterTaskDefinition), "spinifex-workers"},
|
||||
natsSub{"ecs.DeregisterTaskDefinition", handleNATSRequest(d.ecsService.DeregisterTaskDefinition), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeTaskDefinition", handleNATSRequest(d.ecsService.DescribeTaskDefinition), "spinifex-workers"},
|
||||
natsSub{"ecs.ListTaskDefinitions", handleNATSRequest(d.ecsService.ListTaskDefinitions), "spinifex-workers"},
|
||||
natsSub{"ecs.RegisterContainerInstance", handleNATSRequest(d.ecsService.RegisterContainerInstance), "spinifex-workers"},
|
||||
natsSub{"ecs.DeregisterContainerInstance", handleNATSRequest(d.ecsService.DeregisterContainerInstance), "spinifex-workers"},
|
||||
natsSub{"ecs.UpdateContainerInstancesState", handleNATSRequest(d.ecsService.UpdateContainerInstancesState), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeContainerInstances", handleNATSRequest(d.ecsService.DescribeContainerInstances), "spinifex-workers"},
|
||||
natsSub{"ecs.ListContainerInstances", handleNATSRequest(d.ecsService.ListContainerInstances), "spinifex-workers"},
|
||||
natsSub{"ecs.RunTask", handleNATSRequest(d.ecsService.RunTask), "spinifex-workers"},
|
||||
natsSub{"ecs.StartTask", handleNATSRequest(d.ecsService.StartTask), "spinifex-workers"},
|
||||
natsSub{"ecs.StopTask", handleNATSRequest(d.ecsService.StopTask), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeTasks", handleNATSRequest(d.ecsService.DescribeTasks), "spinifex-workers"},
|
||||
natsSub{"ecs.ListTasks", handleNATSRequest(d.ecsService.ListTasks), "spinifex-workers"},
|
||||
natsSub{"ecs.CreateService", handleNATSRequest(d.ecsService.CreateService), "spinifex-workers"},
|
||||
natsSub{"ecs.UpdateService", handleNATSRequest(d.ecsService.UpdateService), "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteService", handleNATSRequest(d.ecsService.DeleteService), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeServices", handleNATSRequest(d.ecsService.DescribeServices), "spinifex-workers"},
|
||||
natsSub{"ecs.ListServices", handleNATSRequest(d.ecsService.ListServices), "spinifex-workers"},
|
||||
natsSub{"ecs.SubmitTaskStateChange", handleNATSRequest(d.ecsService.SubmitTaskStateChange), "spinifex-workers"},
|
||||
natsSub{"ecs.PollAssignments", handleNATSRequest(d.ecsService.PollAssignments), "spinifex-workers"},
|
||||
natsSub{"ecs.ReportTaskGPU", handleNATSRequest(d.ecsService.ReportTaskGPU), "spinifex-workers"},
|
||||
natsSub{"ecs.ProvisionCapacity", handleNATSRequest(d.ecsService.ProvisionCapacity), "spinifex-workers"},
|
||||
natsSub{"ecs.TagResource", handleNATSRequest(d.ecsService.TagResource), "spinifex-workers"},
|
||||
natsSub{"ecs.UntagResource", handleNATSRequest(d.ecsService.UntagResource), "spinifex-workers"},
|
||||
natsSub{"ecs.ListTagsForResource", handleNATSRequest(d.ecsService.ListTagsForResource), "spinifex-workers"},
|
||||
natsSub{"ecs.PutClusterCapacityProviders", handleNATSRequest(d.ecsService.PutClusterCapacityProviders), "spinifex-workers"},
|
||||
natsSub{"ecs.CreateCapacityProvider", handleNATSRequest(d.ecsService.CreateCapacityProvider), "spinifex-workers"},
|
||||
natsSub{"ecs.DescribeCapacityProviders", handleNATSRequest(d.ecsService.DescribeCapacityProviders), "spinifex-workers"},
|
||||
natsSub{"ecs.DeleteCapacityProvider", handleNATSRequest(d.ecsService.DeleteCapacityProvider), "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
// ACM gateway → daemon subscriptions (minimal certificate store).
|
||||
if d.acmService != nil {
|
||||
subs = append(subs,
|
||||
natsSub{"acm.ImportCertificate", d.handleACMImportCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.DescribeCertificate", d.handleACMDescribeCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.ListCertificates", d.handleACMListCertificates, "spinifex-workers"},
|
||||
natsSub{"acm.DeleteCertificate", d.handleACMDeleteCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.ListTagsForCertificate", d.handleACMListTagsForCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.AddTagsToCertificate", d.handleACMAddTagsToCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.RemoveTagsFromCertificate", d.handleACMRemoveTagsFromCertificate, "spinifex-workers"},
|
||||
natsSub{"acm.ImportCertificate", handleNATSRequest(d.acmService.ImportCertificate), "spinifex-workers"},
|
||||
natsSub{"acm.DescribeCertificate", handleNATSRequest(d.acmService.DescribeCertificate), "spinifex-workers"},
|
||||
natsSub{"acm.ListCertificates", handleNATSRequest(d.acmService.ListCertificates), "spinifex-workers"},
|
||||
natsSub{"acm.DeleteCertificate", handleNATSRequest(d.acmService.DeleteCertificate), "spinifex-workers"},
|
||||
natsSub{"acm.ListTagsForCertificate", handleNATSRequest(d.acmService.ListTagsForCertificate), "spinifex-workers"},
|
||||
natsSub{"acm.AddTagsToCertificate", handleNATSRequest(d.acmService.AddTagsToCertificate), "spinifex-workers"},
|
||||
natsSub{"acm.RemoveTagsFromCertificate", handleNATSRequest(d.acmService.RemoveTagsFromCertificate), "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1048,28 +1052,28 @@ func (d *Daemon) subscribeAll() error {
|
||||
// JetStream KV metadata; blob/manifest bytes never traverse these subjects.
|
||||
if d.ecrMetaService != nil {
|
||||
subs = append(subs,
|
||||
natsSub{handlers_ecr.SubjectRepoCreate, d.handleECRRepoCreate, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoDescribe, d.handleECRRepoDescribe, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoList, d.handleECRRepoList, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoDelete, d.handleECRRepoDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyPut, d.handleECRPolicyPut, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyGet, d.handleECRPolicyGet, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyDelete, d.handleECRPolicyDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecyclePut, d.handleECRLifecyclePut, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecycleGet, d.handleECRLifecycleGet, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecycleDelete, d.handleECRLifecycleDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagPut, d.handleECRTagPut, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagGet, d.handleECRTagGet, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagList, d.handleECRTagList, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagDelete, d.handleECRTagDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestPut, d.handleECRManifestPut, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestDescribe, d.handleECRManifestDescribe, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestList, d.handleECRManifestList, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestDelete, d.handleECRManifestDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadCreate, d.handleECRUploadCreate, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadGet, d.handleECRUploadGet, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadUpdate, d.handleECRUploadUpdate, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadDelete, d.handleECRUploadDelete, "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoCreate, handleNATSRequest(d.ecrMetaService.RepoCreate), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoDescribe, handleNATSRequest(d.ecrMetaService.RepoDescribe), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoList, handleNATSRequest(d.ecrMetaService.RepoList), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectRepoDelete, handleNATSRequest(d.ecrMetaService.RepoDelete), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyPut, handleNATSRequest(d.ecrMetaService.PolicyPut), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyGet, handleNATSRequest(d.ecrMetaService.PolicyGet), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectPolicyDelete, handleNATSRequest(d.ecrMetaService.PolicyDelete), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecyclePut, handleNATSRequest(d.ecrMetaService.LifecyclePut), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecycleGet, handleNATSRequest(d.ecrMetaService.LifecycleGet), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectLifecycleDelete, handleNATSRequest(d.ecrMetaService.LifecycleDelete), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagPut, handleNATSRequest(d.ecrMetaService.TagPut), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagGet, handleNATSRequest(d.ecrMetaService.TagGet), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagList, handleNATSRequest(d.ecrMetaService.TagList), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectTagDelete, handleNATSRequest(d.ecrMetaService.TagDelete), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestPut, handleNATSRequest(d.ecrMetaService.ManifestPut), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestDescribe, handleNATSRequest(d.ecrMetaService.ManifestDescribe), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestList, handleNATSRequest(d.ecrMetaService.ManifestList), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectManifestDelete, handleNATSRequest(d.ecrMetaService.ManifestDelete), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadCreate, handleNATSRequest(d.ecrMetaService.UploadCreate), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadGet, handleNATSRequest(d.ecrMetaService.UploadGet), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadUpdate, handleNATSRequest(d.ecrMetaService.UploadUpdate), "spinifex-workers"},
|
||||
natsSub{handlers_ecr.SubjectUploadDelete, handleNATSRequest(d.ecrMetaService.UploadDelete), "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1077,12 +1081,12 @@ func (d *Daemon) subscribeAll() error {
|
||||
// the disabled stub, so reads return empty lists and mutations a clean
|
||||
// UnsupportedOperation instead of a NATS timeout.
|
||||
subs = append(subs,
|
||||
natsSub{"ec2.AllocateAddress", d.handleEC2AllocateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.ReleaseAddress", d.handleEC2ReleaseAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddressesAttribute", d.handleEC2DescribeAddressesAttribute, "spinifex-workers"},
|
||||
natsSub{"ec2.AllocateAddress", handleNATSRequest(d.eipService.AllocateAddress), "spinifex-workers"},
|
||||
natsSub{"ec2.ReleaseAddress", handleNATSRequest(d.eipService.ReleaseAddress), "spinifex-workers"},
|
||||
natsSub{"ec2.AssociateAddress", handleNATSRequest(d.eipService.AssociateAddress), "spinifex-workers"},
|
||||
natsSub{"ec2.DisassociateAddress", handleNATSRequest(d.eipService.DisassociateAddress), "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddresses", handleNATSRequest(d.eipService.DescribeAddresses), "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddressesAttribute", handleNATSRequest(d.eipService.DescribeAddressesAttribute), "spinifex-workers"},
|
||||
)
|
||||
|
||||
for _, s := range subs {
|
||||
@@ -1294,8 +1298,8 @@ func (d *Daemon) startCluster() error {
|
||||
}
|
||||
|
||||
// Remove the obsolete spinifex-dhcp-leases bucket (idempotent).
|
||||
if js, jsErr := d.natsConn.JetStream(); jsErr == nil {
|
||||
if err := utils.DeleteKVBucketIfExists(js, "spinifex-dhcp-leases"); err != nil {
|
||||
if js, jsErr := jetstream.New(d.natsConn); jsErr == nil {
|
||||
if err := kvutil.DeleteBucketIfExists(d.ctx, js, "spinifex-dhcp-leases"); err != nil {
|
||||
slog.Warn("Failed to delete obsolete spinifex-dhcp-leases KV bucket", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1331,10 +1335,10 @@ func (d *Daemon) startCluster() error {
|
||||
|
||||
type snapResult struct {
|
||||
svc *handlers_ec2_snapshot.SnapshotServiceImpl
|
||||
kv nats.KeyValue
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
snap, err := initServiceWithRetry("snapshot service", func() (snapResult, error) {
|
||||
svc, kv, err := handlers_ec2_snapshot.NewSnapshotServiceImplWithNATS(d.config, d.natsConn)
|
||||
svc, kv, err := handlers_ec2_snapshot.NewSnapshotServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
return snapResult{svc, kv}, err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1346,42 +1350,42 @@ func (d *Daemon) startCluster() error {
|
||||
d.tagsService = handlers_ec2_tags.NewTagsServiceImpl(d.config)
|
||||
|
||||
d.eigwService, err = initServiceWithRetry("EIGW service", func() (*handlers_ec2_eigw.EgressOnlyIGWServiceImpl, error) {
|
||||
return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize EIGW service: %w", err)
|
||||
}
|
||||
|
||||
d.igwService, err = initServiceWithRetry("IGW service", func() (*handlers_ec2_igw.IGWServiceImpl, error) {
|
||||
return handlers_ec2_igw.NewIGWServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_igw.NewIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize IGW service: %w", err)
|
||||
}
|
||||
|
||||
d.placementGroupService, err = initServiceWithRetry("placement group service", func() (*handlers_ec2_placementgroup.PlacementGroupServiceImpl, error) {
|
||||
return handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize placement group service: %w", err)
|
||||
}
|
||||
|
||||
d.launchTemplateService, err = initServiceWithRetry("launch template service", func() (*handlers_ec2_launchtemplate.LaunchTemplateServiceImpl, error) {
|
||||
return handlers_ec2_launchtemplate.NewLaunchTemplateServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_launchtemplate.NewLaunchTemplateServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize launch template service: %w", err)
|
||||
}
|
||||
|
||||
d.spotInstanceService, err = initServiceWithRetry("spot instance service", func() (*handlers_ec2_spotinstance.SpotInstanceServiceImpl, error) {
|
||||
return handlers_ec2_spotinstance.NewSpotInstanceServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_spotinstance.NewSpotInstanceServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize spot instance service: %w", err)
|
||||
}
|
||||
|
||||
d.vpcService, err = initServiceWithRetry("VPC service", func() (*handlers_ec2_vpc.VPCServiceImpl, error) {
|
||||
return handlers_ec2_vpc.NewVPCServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_vpc.NewVPCServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize VPC service: %w", err)
|
||||
@@ -1392,7 +1396,7 @@ func (d *Daemon) startCluster() error {
|
||||
d.vpcService.SetDefaultPublicIPMapping(d.hasPublicIPPools())
|
||||
|
||||
d.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) {
|
||||
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize RouteTable service: %w", err)
|
||||
@@ -1402,7 +1406,7 @@ func (d *Daemon) startCluster() error {
|
||||
d.igwService.SetGatePublisher(d.routeTableService)
|
||||
|
||||
d.natGatewayService, err = initServiceWithRetry("NatGateway service", func() (*handlers_ec2_natgw.NatGatewayServiceImpl, error) {
|
||||
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.natsConn)
|
||||
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.ctx, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize NatGateway service: %w", err)
|
||||
@@ -1412,7 +1416,7 @@ func (d *Daemon) startCluster() error {
|
||||
// mode with a public pool alongside the transit segment). The transit pool
|
||||
// never enters IPAM — its addresses are gateway-LRP plumbing, not EIPs.
|
||||
if d.hasPublicIPPools() {
|
||||
js, jsErr := d.natsConn.JetStream()
|
||||
js, jsErr := jetstream.New(d.natsConn)
|
||||
if jsErr != nil {
|
||||
slog.Warn("Failed to get JetStream for external IPAM", "err", jsErr)
|
||||
} else {
|
||||
@@ -1438,7 +1442,7 @@ func (d *Daemon) startCluster() error {
|
||||
anyDHCP = true
|
||||
}
|
||||
}
|
||||
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(js, pools)
|
||||
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(d.ctx, js, pools)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to initialize external IPAM", "err", err)
|
||||
} else {
|
||||
@@ -1455,7 +1459,7 @@ func (d *Daemon) startCluster() error {
|
||||
|
||||
// Initialize EIP service if external IPAM is available
|
||||
if d.externalIPAM != nil && d.vpcService != nil {
|
||||
eipSvc, eipErr := handlers_ec2_eip.NewEIPServiceImpl(d.natsConn, d.externalIPAM, d.vpcService)
|
||||
eipSvc, eipErr := handlers_ec2_eip.NewEIPServiceImpl(d.ctx, d.natsConn, d.externalIPAM, d.vpcService)
|
||||
if eipErr != nil {
|
||||
slog.Warn("Failed to initialize EIP service", "err", eipErr)
|
||||
} else {
|
||||
@@ -1465,11 +1469,11 @@ func (d *Daemon) startCluster() error {
|
||||
|
||||
// Inject external IPAM + EIP KV into VPC service so DeleteNetworkInterface
|
||||
// can release auto-assigned public IPs and NAT rules.
|
||||
eipJS, eipJSErr := d.natsConn.JetStream()
|
||||
eipJS, eipJSErr := jetstream.New(d.natsConn)
|
||||
if eipJSErr != nil {
|
||||
slog.Warn("Failed to get JetStream for VPC external IPAM injection", "err", eipJSErr)
|
||||
} else {
|
||||
eipKV, eipKVErr := utils.GetOrCreateKVBucket(eipJS, handlers_ec2_eip.KVBucketEIPs, 10)
|
||||
eipKV, eipKVErr := kvutil.GetOrCreateBucket(d.ctx, eipJS, handlers_ec2_eip.KVBucketEIPs, 10)
|
||||
if eipKVErr != nil {
|
||||
slog.Warn("Failed to get EIP KV bucket for VPC service", "err", eipKVErr)
|
||||
} else {
|
||||
@@ -1493,7 +1497,7 @@ func (d *Daemon) startCluster() error {
|
||||
}
|
||||
|
||||
d.accountService, err = initServiceWithRetry("account settings service", func() (*handlers_ec2_account.AccountSettingsServiceImpl, error) {
|
||||
return handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize account settings service: %w", err)
|
||||
@@ -1545,9 +1549,9 @@ func (d *Daemon) startCluster() error {
|
||||
// reaper. The scheduler is disabled (handlers still serve) when JetStream is
|
||||
// unavailable.
|
||||
d.ecsService = handlers_ecs.NewService(d.natsConn, d.config.Region, d.clusterConfig.AWS.InternalSuffix).WithDeps(d.buildECSServiceDeps())
|
||||
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
|
||||
if js, jsErr := jetstream.New(d.natsConn); jsErr != nil {
|
||||
slog.Warn("ECS scheduler disabled: JetStream unavailable", "err", jsErr)
|
||||
} else if _, lbErr := handlers_ecs.InitLeaderBucket(js); lbErr != nil {
|
||||
} else if _, lbErr := handlers_ecs.InitLeaderBucket(d.ctx, js); lbErr != nil {
|
||||
slog.Warn("ECS scheduler disabled: leader bucket init failed", "err", lbErr)
|
||||
} else {
|
||||
d.ecsScheduler = handlers_ecs.NewScheduler(d.natsConn, d.ecsService, d.node)
|
||||
@@ -1562,7 +1566,7 @@ func (d *Daemon) startCluster() error {
|
||||
}
|
||||
|
||||
d.acmService, err = initServiceWithRetry("ACM service", func() (*handlers_acm.ACMServiceImpl, error) {
|
||||
return handlers_acm.NewACMServiceImplWithNATS(d.config, d.natsConn)
|
||||
return handlers_acm.NewACMServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize ACM service: %w", err)
|
||||
@@ -1571,7 +1575,7 @@ func (d *Daemon) startCluster() error {
|
||||
// ECR metadata service: owns per-account JetStream KV for repos, tags,
|
||||
// manifest records and upload-state CAS. Disabled (gateway returns NATS
|
||||
// timeouts) when JetStream is unavailable.
|
||||
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
|
||||
if js, jsErr := jetstream.New(d.natsConn); jsErr != nil {
|
||||
slog.Warn("ECR metadata service disabled: JetStream unavailable", "err", jsErr)
|
||||
} else {
|
||||
d.ecrMetaService = handlers_ecr.NewKVMetaService(js)
|
||||
@@ -1613,8 +1617,13 @@ func (d *Daemon) startCluster() error {
|
||||
return fmt.Errorf("restore instances: %w", err)
|
||||
}
|
||||
|
||||
// Rebuild mgmt IP allocator so already-allocated IPs aren't reused.
|
||||
if d.mgmtIPAllocator != nil {
|
||||
// Bind the mgmt IP allocator to cluster KV now that JetStream is up (DDIL
|
||||
// §1e-audit: startLocal built it KV-less). Rebuild then reconciles this
|
||||
// node's already-running VMs into the cluster-wide record instead of
|
||||
// just refreshing the local cache, so already-allocated IPs aren't
|
||||
// reused by another node either.
|
||||
if d.mgmtIPAllocator != nil && d.jsManager != nil {
|
||||
d.mgmtIPAllocator.BindKV(d.jsManager, d.node)
|
||||
d.mgmtIPAllocator.Rebuild(d.vmMgr.SnapshotMap())
|
||||
slog.Info("Rebuilt mgmt IP allocator from restored instances", "allocated", d.mgmtIPAllocator.AllocatedCount())
|
||||
}
|
||||
@@ -1655,6 +1664,7 @@ func (d *Daemon) startCluster() error {
|
||||
reapers := []vm.Reaper{
|
||||
d.vmMgr.NewTerminatedTeardownReaper(),
|
||||
d.vmMgr.NewOrphanQEMUReaper(),
|
||||
d.vmMgr.NewStuckTerminateReaper(),
|
||||
}
|
||||
if eniRec := d.newENIReconciler(); eniRec != nil {
|
||||
reapers = append(reapers, eniRec)
|
||||
|
||||
@@ -66,60 +66,65 @@ func respondWithJSON(msg *nats.Msg, data any) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleNATSRequest is a generic helper for the common unmarshal → service → marshal → respond pattern.
|
||||
// It opens a consumer span joining the producer's trace, extracts the account ID
|
||||
// from the NATS message header, and passes both to the service function.
|
||||
func handleNATSRequest[I any, O any](msg *nats.Msg, serviceFn func(context.Context, *I, string) (*O, error)) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
defer span.End()
|
||||
// handleNATSRequest returns the nats.MsgHandler for the common unmarshal → service →
|
||||
// marshal → respond pattern. Per message the handler opens a consumer span joining the
|
||||
// producer's trace, extracts the account ID from the NATS message header, and passes
|
||||
// both to the service function.
|
||||
func handleNATSRequest[I any, O any](serviceFn func(context.Context, *I, string) (*O, error)) nats.MsgHandler {
|
||||
return func(msg *nats.Msg) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
defer span.End()
|
||||
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
input := new(I)
|
||||
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
|
||||
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
input := new(I)
|
||||
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
|
||||
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
output, err := serviceFn(ctx, input, accountID)
|
||||
if err != nil {
|
||||
// The error was otherwise only recorded on the OTel span, invisible
|
||||
// without a trace backend. Log at Error so handler failures show up
|
||||
// in the journal too.
|
||||
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
}
|
||||
output, err := serviceFn(ctx, input, accountID)
|
||||
if err != nil {
|
||||
// The error was otherwise only recorded on the OTel span, invisible
|
||||
// without a trace backend. Log at Error so handler failures show up
|
||||
// in the journal too.
|
||||
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
}
|
||||
|
||||
// handleNATSRequestWithPrincipal is handleNATSRequest for service methods that
|
||||
// also need the caller's IAM principal ARN (X-Principal-ARN header) — e.g. EKS
|
||||
// CreateCluster, which mints the bootstrap-creator-admin AccessEntry for the
|
||||
// caller.
|
||||
func handleNATSRequestWithPrincipal[I any, O any](msg *nats.Msg, serviceFn func(context.Context, *I, string, string) (*O, error)) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
defer span.End()
|
||||
func handleNATSRequestWithPrincipal[I any, O any](serviceFn func(context.Context, *I, string, string) (*O, error)) nats.MsgHandler {
|
||||
return func(msg *nats.Msg) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
defer span.End()
|
||||
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
principalARN := utils.PrincipalARNFromMsg(msg)
|
||||
input := new(I)
|
||||
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
|
||||
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
principalARN := utils.PrincipalARNFromMsg(msg)
|
||||
input := new(I)
|
||||
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
|
||||
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
output, err := serviceFn(ctx, input, accountID, principalARN)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
}
|
||||
output, err := serviceFn(ctx, input, accountID, principalARN)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, output)
|
||||
}
|
||||
|
||||
// handleEC2Events processes incoming EC2 instance events (start, stop, terminate, attach-volume).
|
||||
@@ -155,6 +160,13 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
d.handleAttachVolume(ctx, msg, command, instance)
|
||||
case command.Attributes.DetachVolume:
|
||||
d.handleDetachVolume(ctx, msg, command, instance)
|
||||
case command.Attributes.DrainVolume:
|
||||
// A drain flushes the volume's whole dirty set to S3, so it is bounded by
|
||||
// the dirty set rather than by a unit of work. nats.go delivers a
|
||||
// subscription's messages serially, so running it inline would stall
|
||||
// every other command for this instance — stop, terminate, hot-plug —
|
||||
// for the duration. It touches no VM state, so it is safe off-thread.
|
||||
go d.handleDrainVolume(ctx, msg, command, instance)
|
||||
case command.Attributes.AttachENI:
|
||||
d.handleAttachNetworkInterface(ctx, msg, command, instance)
|
||||
case command.Attributes.DetachENI:
|
||||
@@ -171,7 +183,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(fmt.Appendf(nil, `{"status":"running","instanceId":"%s"}`, instance.ID)); err != nil {
|
||||
@@ -183,7 +195,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond([]byte(`{}`)); err != nil {
|
||||
@@ -199,7 +211,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
|
||||
endOpSpan(opSpan, err)
|
||||
if err != nil {
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond([]byte(`{}`)); err != nil {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2EnableEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.EnableEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisableEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.DisableEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2GetEbsEncryptionByDefault(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.GetEbsEncryptionByDefault)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2GetSerialConsoleAccessStatus(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.GetSerialConsoleAccessStatus)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2EnableSerialConsoleAccess(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.EnableSerialConsoleAccess)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisableSerialConsoleAccess(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.accountService.DisableSerialConsoleAccess)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleACMImportCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.ImportCertificate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMDescribeCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.DescribeCertificate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMListCertificates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.ListCertificates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMDeleteCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.DeleteCertificate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMListTagsForCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.ListTagsForCertificate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMAddTagsToCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.AddTagsToCertificate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleACMRemoveTagsFromCertificate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.acmService.RemoveTagsFromCertificate)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func (d *Daemon) handleEC2CreateCapacityReservation(msg *nats.Msg) {
|
||||
}
|
||||
|
||||
if err := d.resourceMgr.CreateReservation(rec); err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleECRRepoCreate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.RepoCreate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRRepoDescribe(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.RepoDescribe)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRRepoList(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.RepoList)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRRepoDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.RepoDelete)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRPolicyPut(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.PolicyPut)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRPolicyGet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.PolicyGet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRPolicyDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.PolicyDelete)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRLifecyclePut(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.LifecyclePut)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRLifecycleGet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.LifecycleGet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRLifecycleDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.LifecycleDelete)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRTagPut(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.TagPut)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRTagGet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.TagGet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRTagList(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.TagList)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRTagDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.TagDelete)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRManifestPut(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.ManifestPut)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRManifestDescribe(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.ManifestDescribe)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRManifestList(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.ManifestList)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRManifestDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.ManifestDelete)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRUploadCreate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.UploadCreate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRUploadGet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.UploadGet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRUploadUpdate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.UploadUpdate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECRUploadDelete(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecrMetaService.UploadDelete)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
// --- Cluster ---
|
||||
|
||||
func (d *Daemon) handleECSCreateCluster(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.CreateCluster)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDeleteCluster(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DeleteCluster)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeClusters(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeClusters)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListClusters(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListClusters)
|
||||
}
|
||||
|
||||
// --- Task definition ---
|
||||
|
||||
func (d *Daemon) handleECSRegisterTaskDefinition(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.RegisterTaskDefinition)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDeregisterTaskDefinition(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DeregisterTaskDefinition)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeTaskDefinition(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeTaskDefinition)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListTaskDefinitions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListTaskDefinitions)
|
||||
}
|
||||
|
||||
// --- Container instance ---
|
||||
|
||||
func (d *Daemon) handleECSRegisterContainerInstance(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.RegisterContainerInstance)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDeregisterContainerInstance(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DeregisterContainerInstance)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSUpdateContainerInstancesState(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.UpdateContainerInstancesState)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeContainerInstances(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeContainerInstances)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListContainerInstances(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListContainerInstances)
|
||||
}
|
||||
|
||||
// --- Task ---
|
||||
|
||||
func (d *Daemon) handleECSRunTask(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.RunTask)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSStartTask(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.StartTask)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSStopTask(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.StopTask)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeTasks(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeTasks)
|
||||
}
|
||||
|
||||
// --- Service ---
|
||||
|
||||
func (d *Daemon) handleECSCreateService(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.CreateService)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSUpdateService(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.UpdateService)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDeleteService(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DeleteService)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeServices(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeServices)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListServices(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListServices)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSSubmitTaskStateChange(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.SubmitTaskStateChange)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListTasks(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListTasks)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSPollAssignments(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.PollAssignments)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSReportTaskGPU(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ReportTaskGPU)
|
||||
}
|
||||
|
||||
// --- Capacity ---
|
||||
|
||||
func (d *Daemon) handleECSProvisionCapacity(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ProvisionCapacity)
|
||||
}
|
||||
|
||||
// --- Tags ---
|
||||
|
||||
func (d *Daemon) handleECSTagResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.TagResource)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSUntagResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.UntagResource)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSListTagsForResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.ListTagsForResource)
|
||||
}
|
||||
|
||||
// --- Capacity providers ---
|
||||
|
||||
func (d *Daemon) handleECSPutClusterCapacityProviders(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.PutClusterCapacityProviders)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSCreateCapacityProvider(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.CreateCapacityProvider)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDescribeCapacityProviders(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DescribeCapacityProviders)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleECSDeleteCapacityProvider(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.ecsService.DeleteCapacityProvider)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2AllocateAddress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.AllocateAddress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReleaseAddress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.ReleaseAddress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AssociateAddress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.AssociateAddress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisassociateAddress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.DisassociateAddress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeAddresses(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.DescribeAddresses)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeAddressesAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.DescribeAddressesAttribute)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
// --- Cluster ---
|
||||
|
||||
func (d *Daemon) handleEKSCreateCluster(msg *nats.Msg) {
|
||||
handleNATSRequestWithPrincipal(msg, d.eksService.CreateCluster)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeCluster(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeCluster)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListClusters(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListClusters)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateClusterConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateClusterConfig)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateClusterVersion(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateClusterVersion)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDeleteCluster(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DeleteCluster)
|
||||
}
|
||||
|
||||
// --- Nodegroup ---
|
||||
|
||||
func (d *Daemon) handleEKSCreateNodegroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.CreateNodegroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeNodegroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeNodegroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListNodegroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListNodegroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateNodegroupConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateNodegroupConfig)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateNodegroupVersion(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateNodegroupVersion)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDeleteNodegroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DeleteNodegroup)
|
||||
}
|
||||
|
||||
// --- AccessEntry + AccessPolicy ---
|
||||
|
||||
func (d *Daemon) handleEKSCreateAccessEntry(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.CreateAccessEntry)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeAccessEntry(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeAccessEntry)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListAccessEntries(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListAccessEntries)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateAccessEntry(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateAccessEntry)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDeleteAccessEntry(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DeleteAccessEntry)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSAssociateAccessPolicy(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.AssociateAccessPolicy)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDisassociateAccessPolicy(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DisassociateAccessPolicy)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListAssociatedAccessPolicies(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListAssociatedAccessPolicies)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListAccessPolicies(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListAccessPolicies)
|
||||
}
|
||||
|
||||
// --- Addons ---
|
||||
|
||||
func (d *Daemon) handleEKSListAddons(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListAddons)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeAddonVersions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeAddonVersions)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSCreateAddon(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.CreateAddon)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDeleteAddon(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DeleteAddon)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeAddon(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeAddon)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUpdateAddon(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UpdateAddon)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListStagedAddonManifests(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListStagedAddonManifests)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSGetRecoveryDirective(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.GetRecoveryDirective)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSSetRecoveryDirective(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.SetRecoveryDirective)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSRestoreSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.RestoreSnapshot)
|
||||
}
|
||||
|
||||
// --- OIDC identity-provider configs ---
|
||||
|
||||
func (d *Daemon) handleEKSAssociateIdentityProviderConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.AssociateIdentityProviderConfig)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDescribeIdentityProviderConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DescribeIdentityProviderConfig)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListIdentityProviderConfigs(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListIdentityProviderConfigs)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSDisassociateIdentityProviderConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.DisassociateIdentityProviderConfig)
|
||||
}
|
||||
|
||||
// --- Tags ---
|
||||
|
||||
func (d *Daemon) handleEKSTagResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.TagResource)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSUntagResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.UntagResource)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEKSListTagsForResource(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eksService.ListTagsForResource)
|
||||
}
|
||||
@@ -71,39 +71,39 @@ func TestDaemonHandleEKS_AllHandlersDispatchToService(t *testing.T) {
|
||||
handler nats.MsgHandler
|
||||
wantCode string
|
||||
}{
|
||||
{"eks.UpdateClusterConfig", d.handleEKSUpdateClusterConfig, notImpl},
|
||||
{"eks.UpdateClusterVersion", d.handleEKSUpdateClusterVersion, notImpl},
|
||||
{"eks.CreateNodegroup", d.handleEKSCreateNodegroup, unavailable},
|
||||
{"eks.DescribeNodegroup", d.handleEKSDescribeNodegroup, invalid},
|
||||
{"eks.ListNodegroups", d.handleEKSListNodegroups, invalid},
|
||||
{"eks.UpdateNodegroupConfig", d.handleEKSUpdateNodegroupConfig, unavailable},
|
||||
{"eks.UpdateNodegroupVersion", d.handleEKSUpdateNodegroupVersion, notImpl},
|
||||
{"eks.DeleteNodegroup", d.handleEKSDeleteNodegroup, unavailable},
|
||||
{"eks.CreateAccessEntry", d.handleEKSCreateAccessEntry, invalid},
|
||||
{"eks.DescribeAccessEntry", d.handleEKSDescribeAccessEntry, invalid},
|
||||
{"eks.ListAccessEntries", d.handleEKSListAccessEntries, invalid},
|
||||
{"eks.UpdateAccessEntry", d.handleEKSUpdateAccessEntry, invalid},
|
||||
{"eks.DeleteAccessEntry", d.handleEKSDeleteAccessEntry, invalid},
|
||||
{"eks.AssociateAccessPolicy", d.handleEKSAssociateAccessPolicy, invalid},
|
||||
{"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, invalid},
|
||||
{"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, invalid},
|
||||
{"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, ""},
|
||||
{"eks.UpdateClusterConfig", handleNATSRequest(d.eksService.UpdateClusterConfig), notImpl},
|
||||
{"eks.UpdateClusterVersion", handleNATSRequest(d.eksService.UpdateClusterVersion), notImpl},
|
||||
{"eks.CreateNodegroup", handleNATSRequest(d.eksService.CreateNodegroup), unavailable},
|
||||
{"eks.DescribeNodegroup", handleNATSRequest(d.eksService.DescribeNodegroup), invalid},
|
||||
{"eks.ListNodegroups", handleNATSRequest(d.eksService.ListNodegroups), invalid},
|
||||
{"eks.UpdateNodegroupConfig", handleNATSRequest(d.eksService.UpdateNodegroupConfig), unavailable},
|
||||
{"eks.UpdateNodegroupVersion", handleNATSRequest(d.eksService.UpdateNodegroupVersion), notImpl},
|
||||
{"eks.DeleteNodegroup", handleNATSRequest(d.eksService.DeleteNodegroup), unavailable},
|
||||
{"eks.CreateAccessEntry", handleNATSRequest(d.eksService.CreateAccessEntry), invalid},
|
||||
{"eks.DescribeAccessEntry", handleNATSRequest(d.eksService.DescribeAccessEntry), invalid},
|
||||
{"eks.ListAccessEntries", handleNATSRequest(d.eksService.ListAccessEntries), invalid},
|
||||
{"eks.UpdateAccessEntry", handleNATSRequest(d.eksService.UpdateAccessEntry), invalid},
|
||||
{"eks.DeleteAccessEntry", handleNATSRequest(d.eksService.DeleteAccessEntry), invalid},
|
||||
{"eks.AssociateAccessPolicy", handleNATSRequest(d.eksService.AssociateAccessPolicy), invalid},
|
||||
{"eks.DisassociateAccessPolicy", handleNATSRequest(d.eksService.DisassociateAccessPolicy), invalid},
|
||||
{"eks.ListAssociatedAccessPolicies", handleNATSRequest(d.eksService.ListAssociatedAccessPolicies), invalid},
|
||||
{"eks.ListAccessPolicies", handleNATSRequest(d.eksService.ListAccessPolicies), ""},
|
||||
// Addon handlers are implemented (Sprint 6 P1): an empty body fails
|
||||
// cluster validation with InvalidParameterValue, except
|
||||
// DescribeAddonVersions which returns the static catalogue (success).
|
||||
{"eks.ListAddons", d.handleEKSListAddons, invalid},
|
||||
{"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, ""},
|
||||
{"eks.CreateAddon", d.handleEKSCreateAddon, invalid},
|
||||
{"eks.DeleteAddon", d.handleEKSDeleteAddon, invalid},
|
||||
{"eks.DescribeAddon", d.handleEKSDescribeAddon, invalid},
|
||||
{"eks.UpdateAddon", d.handleEKSUpdateAddon, invalid},
|
||||
{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, notImpl},
|
||||
{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, notImpl},
|
||||
{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, notImpl},
|
||||
{"eks.DisassociateIdentityProviderConfig", d.handleEKSDisassociateIdentityProviderConfig, notImpl},
|
||||
{"eks.TagResource", d.handleEKSTagResource, notImpl},
|
||||
{"eks.UntagResource", d.handleEKSUntagResource, notImpl},
|
||||
{"eks.ListTagsForResource", d.handleEKSListTagsForResource, notImpl},
|
||||
{"eks.ListAddons", handleNATSRequest(d.eksService.ListAddons), invalid},
|
||||
{"eks.DescribeAddonVersions", handleNATSRequest(d.eksService.DescribeAddonVersions), ""},
|
||||
{"eks.CreateAddon", handleNATSRequest(d.eksService.CreateAddon), invalid},
|
||||
{"eks.DeleteAddon", handleNATSRequest(d.eksService.DeleteAddon), invalid},
|
||||
{"eks.DescribeAddon", handleNATSRequest(d.eksService.DescribeAddon), invalid},
|
||||
{"eks.UpdateAddon", handleNATSRequest(d.eksService.UpdateAddon), invalid},
|
||||
{"eks.AssociateIdentityProviderConfig", handleNATSRequest(d.eksService.AssociateIdentityProviderConfig), notImpl},
|
||||
{"eks.DescribeIdentityProviderConfig", handleNATSRequest(d.eksService.DescribeIdentityProviderConfig), notImpl},
|
||||
{"eks.ListIdentityProviderConfigs", handleNATSRequest(d.eksService.ListIdentityProviderConfigs), notImpl},
|
||||
{"eks.DisassociateIdentityProviderConfig", handleNATSRequest(d.eksService.DisassociateIdentityProviderConfig), notImpl},
|
||||
{"eks.TagResource", handleNATSRequest(d.eksService.TagResource), notImpl},
|
||||
{"eks.UntagResource", handleNATSRequest(d.eksService.UntagResource), notImpl},
|
||||
{"eks.ListTagsForResource", handleNATSRequest(d.eksService.ListTagsForResource), notImpl},
|
||||
}
|
||||
require.Len(t, cases, 30, "expected exactly one handler per non-lifecycle AWS EKS action")
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleELBv2CreateLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeLoadBalancers(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancers)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2ModifyTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.ModifyTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2RegisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.RegisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeregisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeregisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetHealth(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetHealth)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2ModifyListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.ModifyListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateRule(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateRule)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2ModifyRule(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.ModifyRule)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteRule(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteRule)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeRules(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeRules)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2SetRulePriorities(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.SetRulePriorities)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2AddTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.AddTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2RemoveTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.RemoveTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2LBAgentHeartbeat(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.LBAgentHeartbeat)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2GetLBConfig(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.GetLBConfig)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2ModifyTargetGroupAttributes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.ModifyTargetGroupAttributes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetGroupAttributes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroupAttributes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2ModifyLoadBalancerAttributes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.ModifyLoadBalancerAttributes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeLoadBalancerAttributes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancerAttributes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2SetSecurityGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.SetSecurityGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2SetIpAddressType(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.SetIpAddressType)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2SetSubnets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.SetSubnets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2AddListenerCertificates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.AddListenerCertificates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2RemoveListenerCertificates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.RemoveListenerCertificates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeListenerCertificates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeListenerCertificates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeSSLPolicies(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeSSLPolicies)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
record, err := d.vpcService.GetENIRecord(accountID, eniID)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if record.Status == "in-use" && record.InstanceId != instance.ID {
|
||||
@@ -49,7 +49,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
attachmentID, err := d.vpcService.AttachENI(accountID, eniID, instance.ID, deviceIndex)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if err := d.vpcService.UpdateENI(accountID, eniID, func(r *handlers_ec2_vpc.ENIRecord) {
|
||||
@@ -117,7 +117,7 @@ func (d *Daemon) handleDetachNetworkInterface(ctx context.Context, msg *nats.Msg
|
||||
|
||||
record, err := d.vpcService.FindENIByAttachment(accountID, attachmentID)
|
||||
if err != nil {
|
||||
respondWithError(msg, errorCodeFor(err))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if record.InstanceId != instance.ID {
|
||||
@@ -191,15 +191,6 @@ func eniHotplugErrorCode(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// errorCodeFor passes through errors whose message is already a valid AWS
|
||||
// code (the convention VPCServiceImpl uses for its sentinel errors).
|
||||
func errorCodeFor(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return awserrors.ValidErrorCode(err.Error())
|
||||
}
|
||||
|
||||
// publishENIHotplugEvent emits the per-instance NATS event on best-effort
|
||||
// basis — observers (Phase 4 ecs-agent, future vpcd subscribers) read it
|
||||
// for fanout. A publish failure does not roll back the pipeline.
|
||||
|
||||
@@ -15,29 +15,8 @@ import (
|
||||
func (d *Daemon) handleAssociateIamInstanceProfile(ctx context.Context, msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
|
||||
result, err := d.instanceService.AssociateIamInstanceProfile(ctx, instance, command)
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
respondWithJSON(msg, result)
|
||||
}
|
||||
|
||||
// handleIamProfileDisassociate services the disassociate fan-out. The owning
|
||||
// daemon responds with the mutated association; non-owners respond with JSON
|
||||
// null so the gateway's expectedNodes collector can exit early.
|
||||
func (d *Daemon) handleIamProfileDisassociate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.DisassociateIamProfileAssociation)
|
||||
}
|
||||
|
||||
// handleIamProfileReplace services the replace fan-out. Same contract as
|
||||
// handleIamProfileDisassociate: every daemon responds (JSON null on
|
||||
// non-owners) so the gateway collector can exit early.
|
||||
func (d *Daemon) handleIamProfileReplace(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.ReplaceIamProfileAssociation)
|
||||
}
|
||||
|
||||
// handleIamProfileDescribe services the describe fan-out. An empty slice is a
|
||||
// valid response (no matches on this daemon) and counts toward the gateway's
|
||||
// expectedNodes collector; the gateway concatenates per-daemon slices.
|
||||
func (d *Daemon) handleIamProfileDescribe(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.DescribeIamProfileAssociations)
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.CreateInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DeleteInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeInternetGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DescribeInternetGateways)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AttachInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.AttachInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DetachInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.igwService.DetachInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateEgressOnlyInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.CreateEgressOnlyInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteEgressOnlyInternetGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.DeleteEgressOnlyInternetGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeEgressOnlyInternetGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eigwService.DescribeEgressOnlyInternetGateways)
|
||||
}
|
||||
@@ -14,34 +14,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2DescribeImages(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.DescribeImages)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeregisterImage(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.DeregisterImage)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2RegisterImage(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.RegisterImage)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CopyImage(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.CopyImage)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeImageAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.DescribeImageAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyImageAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.ModifyImageAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ResetImageAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.imageService.ResetImageAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleSpinifexPromoteImage(msg *nats.Msg) {
|
||||
promoteImage := func(_ context.Context, input *admin.PromoteImageOpts, _ string) (*admin.PromoteImageResult, error) {
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(
|
||||
@@ -52,7 +24,7 @@ func (d *Daemon) handleSpinifexPromoteImage(msg *nats.Msg) {
|
||||
)
|
||||
return admin.PromoteSystemImage(store, d.config.Predastore.Bucket, *input)
|
||||
}
|
||||
handleNATSRequest(msg, promoteImage)
|
||||
handleNATSRequest(promoteImage)(msg)
|
||||
}
|
||||
|
||||
// handleEC2CreateImage is a stateful handler that extracts instance context
|
||||
@@ -158,7 +130,7 @@ func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
|
||||
output, err := d.imageService.CreateImageFromInstance(params, accountID)
|
||||
if err != nil {
|
||||
slog.Error("CreateImage: service failed", "instanceId", instanceID, "err", err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
|
||||
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -72,7 +69,7 @@ func (d *Daemon) handleSetInstanceTags(ctx context.Context, msg *nats.Msg, comma
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
@@ -120,7 +117,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
if reservationID != "" {
|
||||
if it, ok := d.resourceMgr.instanceTypes[aws.StringValue(input.InstanceType)]; ok {
|
||||
if vErr := d.resourceMgr.ValidateReservationTarget(reservationID, accountID, it); vErr != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(vErr.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(vErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -130,7 +127,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
reservation, instances, instanceType, err := d.instanceService.PrepareRunInstances(ctx, input, accountID, reservationID)
|
||||
endOpSpan(prepSpan, err)
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,285 +206,6 @@ func capacityReservationTargetID(input *ec2.RunInstancesInput) string {
|
||||
return aws.StringValue(spec.CapacityReservationTarget.CapacityReservationId)
|
||||
}
|
||||
|
||||
// describeInstancesValidFilters defines the set of filter names accepted by DescribeInstances.
|
||||
var describeInstancesValidFilters = map[string]bool{
|
||||
"instance-state-name": true,
|
||||
"instance-id": true,
|
||||
"instance-type": true,
|
||||
"vpc-id": true,
|
||||
"subnet-id": true,
|
||||
"tag-key": true,
|
||||
"tag-value": true,
|
||||
}
|
||||
|
||||
// instanceMatchesFilters checks whether a VM + its built ec2.Instance copy satisfy all parsed filters.
|
||||
func instanceMatchesFilters(inst *vm.VM, ic *ec2.Instance, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
// tag:Key filters are handled after all field filters.
|
||||
continue
|
||||
}
|
||||
|
||||
var field string
|
||||
switch name {
|
||||
case "instance-state-name":
|
||||
if ic.State != nil && ic.State.Name != nil {
|
||||
field = *ic.State.Name
|
||||
}
|
||||
case "instance-id":
|
||||
field = inst.ID
|
||||
case "instance-type":
|
||||
field = inst.InstanceType
|
||||
case "vpc-id":
|
||||
if ic.VpcId != nil {
|
||||
field = *ic.VpcId
|
||||
}
|
||||
case "subnet-id":
|
||||
if ic.SubnetId != nil {
|
||||
field = *ic.SubnetId
|
||||
}
|
||||
case "tag-key":
|
||||
if !matchTagKey(ic.Tags, values) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
case "tag-value":
|
||||
if !matchTagValue(ic.Tags, values) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
default:
|
||||
// Filter name passed ParseFilters but has no case — treat as non-match
|
||||
// to avoid silently ignoring it.
|
||||
return false
|
||||
}
|
||||
|
||||
if !filterutil.MatchesAny(values, field) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag:Key filters via the instance's Tag slice.
|
||||
tags := filterutil.EC2TagsToMap(ic.Tags)
|
||||
return filterutil.MatchesTags(filters, tags)
|
||||
}
|
||||
|
||||
// matchTagKey returns true if any tag key on the resource matches any of the filter values.
|
||||
func matchTagKey(tags []*ec2.Tag, values []string) bool {
|
||||
for _, t := range tags {
|
||||
if t.Key != nil && filterutil.MatchesAny(values, *t.Key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchTagValue returns true if any tag value on the resource matches any of the filter values.
|
||||
func matchTagValue(tags []*ec2.Tag, values []string) bool {
|
||||
for _, t := range tags {
|
||||
if t.Value != nil && filterutil.MatchesAny(values, *t.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleEC2DescribeInstances responds with instances running on this node visible to the caller.
|
||||
func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
|
||||
slog.Debug("Received message", "subject", msg.Subject, "data", string(msg.Data))
|
||||
|
||||
// Extract account ID from NATS header for scoping
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
// Initialize describeInstancesInput before unmarshaling into it
|
||||
describeInstancesInput := &ec2.DescribeInstancesInput{}
|
||||
errResp := utils.UnmarshalJsonPayload(describeInstancesInput, msg.Data)
|
||||
|
||||
if errResp != nil {
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
slog.Error("Request does not match DescribeInstancesInput")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Processing DescribeInstances request from this node", "accountID", accountID)
|
||||
|
||||
// Validate and filter instances if specific instance IDs were requested
|
||||
instanceIDFilter := make(map[string]bool)
|
||||
if len(describeInstancesInput.InstanceIds) > 0 {
|
||||
for _, id := range describeInstancesInput.InstanceIds {
|
||||
if id != nil && *id != "" {
|
||||
if !strings.HasPrefix(*id, "i-") {
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDMalformed)
|
||||
return
|
||||
}
|
||||
instanceIDFilter[*id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse filters (returns error for unknown filter names)
|
||||
parsedFilters, err := filterutil.ParseFilters(describeInstancesInput.Filters, describeInstancesValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeInstances: invalid filter", "err", err)
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Group instances by reservation ID (AWS returns instances grouped by reservation)
|
||||
reservationMap := make(map[string]*ec2.Reservation)
|
||||
|
||||
// Iterate under the manager lock — VM fields (Status, Instance, Reservation,
|
||||
// PublicIP, PlacementGroupName) are mutated through manager-locked
|
||||
// Inspect/UpdateState elsewhere, so reading them lock-free would race.
|
||||
d.vmMgr.View(func(vms map[string]*vm.VM) {
|
||||
for _, instance := range vms {
|
||||
// Skip instances not owned by the caller's account.
|
||||
// Instances with an empty AccountID (legacy/migration data)
|
||||
// are only visible to root.
|
||||
if !isInstanceVisible(accountID, instance.AccountID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if filtering by instance IDs and this instance is not in the filter
|
||||
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use stored reservation metadata if available
|
||||
if instance.Reservation != nil && instance.Instance != nil {
|
||||
resID := ""
|
||||
if instance.Reservation.ReservationId != nil {
|
||||
resID = *instance.Reservation.ReservationId
|
||||
}
|
||||
|
||||
// Create reservation entry if it doesn't exist
|
||||
if _, exists := reservationMap[resID]; !exists {
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId(resID)
|
||||
if instance.Reservation.OwnerId != nil {
|
||||
reservation.SetOwnerId(*instance.Reservation.OwnerId)
|
||||
}
|
||||
reservation.Instances = []*ec2.Instance{}
|
||||
reservationMap[resID] = reservation
|
||||
}
|
||||
|
||||
// Update the instance state to current state
|
||||
instanceCopy := *instance.Instance
|
||||
instanceCopy.State = &ec2.InstanceState{}
|
||||
|
||||
// Populate PublicIpAddress from VM if stored
|
||||
if instance.PublicIP != "" && instanceCopy.PublicIpAddress == nil {
|
||||
instanceCopy.PublicIpAddress = aws.String(instance.PublicIP)
|
||||
}
|
||||
|
||||
// Project the AWS-shaped DNS names from the current IPs, the same
|
||||
// way PublicIpAddress/State are derived here. Mirrors the records
|
||||
// the control-plane writer publishes to northstar.
|
||||
publicDNS, privateDNS := handlers_dns.EC2DNSNames(
|
||||
d.config.Region, d.dnsBaseDomain, d.dnsInternalDomain,
|
||||
aws.StringValue(instanceCopy.PublicIpAddress), aws.StringValue(instanceCopy.PrivateIpAddress),
|
||||
)
|
||||
if publicDNS != "" {
|
||||
instanceCopy.PublicDnsName = aws.String(publicDNS)
|
||||
}
|
||||
if privateDNS != "" {
|
||||
instanceCopy.PrivateDnsName = aws.String(privateDNS)
|
||||
}
|
||||
|
||||
// Map internal status to AWS state, projecting Spinifex-only states
|
||||
// (e.g. error -> stopped) so SDK/UI clients see a valid label.
|
||||
if info, ok := vm.EC2APIState(instance.Status); ok {
|
||||
instanceCopy.State.SetCode(info.Code)
|
||||
instanceCopy.State.SetName(info.Name)
|
||||
} else {
|
||||
slog.Warn("Instance has unmapped status, reporting as pending",
|
||||
"instanceId", instance.ID, "status", string(instance.Status))
|
||||
instanceCopy.State.SetCode(0)
|
||||
instanceCopy.State.SetName("pending")
|
||||
}
|
||||
|
||||
// Project IamInstanceProfile from vm.VM (single source of truth
|
||||
// across Associate/Disassociate/Replace lifecycle). Id is left
|
||||
// nil — the gateway resolves it via IAMService post-aggregation
|
||||
// since daemons have no IAM access. Empty Arn clears any stale
|
||||
// reference left on stored instance.Instance (e.g. after
|
||||
// Disassociate or auto-clear on terminate).
|
||||
if instance.IamInstanceProfileArn != "" {
|
||||
instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{
|
||||
Arn: aws.String(instance.IamInstanceProfileArn),
|
||||
}
|
||||
} else {
|
||||
instanceCopy.IamInstanceProfile = nil
|
||||
}
|
||||
|
||||
// Populate Placement if instance belongs to a placement group
|
||||
if instance.PlacementGroupName != "" {
|
||||
instanceCopy.Placement = &ec2.Placement{
|
||||
GroupName: aws.String(instance.PlacementGroupName),
|
||||
AvailabilityZone: aws.String(d.config.AZ),
|
||||
}
|
||||
}
|
||||
|
||||
// Echo the consumed capacity reservation so targeted-launch
|
||||
// Terraform converges — without it the instance reports no
|
||||
// reservation and the plan never settles.
|
||||
if instance.CapacityReservationId != "" {
|
||||
instanceCopy.CapacityReservationId = aws.String(instance.CapacityReservationId)
|
||||
instanceCopy.CapacityReservationSpecification = &ec2.CapacityReservationSpecificationResponse{
|
||||
CapacityReservationPreference: aws.String(ec2.CapacityReservationPreferenceOpen),
|
||||
CapacityReservationTarget: &ec2.CapacityReservationTargetResponse{
|
||||
CapacityReservationId: aws.String(instance.CapacityReservationId),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Project spot lineage stamped by the post-launch write-back.
|
||||
// Both empty for on-demand, so the fields stay absent there.
|
||||
if instance.InstanceLifecycle != "" {
|
||||
instanceCopy.InstanceLifecycle = aws.String(instance.InstanceLifecycle)
|
||||
}
|
||||
if instance.SpotInstanceRequestId != "" {
|
||||
instanceCopy.SpotInstanceRequestId = aws.String(instance.SpotInstanceRequestId)
|
||||
}
|
||||
|
||||
// Apply filters against the fully-built instance copy
|
||||
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add instance to its reservation
|
||||
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Convert map to slice
|
||||
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
|
||||
for _, reservation := range reservationMap {
|
||||
reservations = append(reservations, reservation)
|
||||
}
|
||||
|
||||
// Create the response
|
||||
output := &ec2.DescribeInstancesOutput{
|
||||
Reservations: reservations,
|
||||
}
|
||||
|
||||
respondWithJSON(msg, output)
|
||||
slog.Info("handleEC2DescribeInstances completed", "count", len(reservations))
|
||||
}
|
||||
|
||||
// handleEC2DescribeInstanceTypes responds with instance types provisionable on this node.
|
||||
func (d *Daemon) handleEC2DescribeInstanceTypes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.DescribeInstanceTypes)
|
||||
}
|
||||
|
||||
// handleEC2DescribeInstanceStatus responds with status entries for VMs on this node visible to the caller.
|
||||
func (d *Daemon) handleEC2DescribeInstanceStatus(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.DescribeInstanceStatus)
|
||||
}
|
||||
|
||||
// handleEC2StartStoppedInstance handles the generic ec2.start queue-group topic.
|
||||
// It reads the stopped instance's LastNode from shared KV and forwards the
|
||||
// request to ec2.start.{lastNode} to keep instances on their original node.
|
||||
@@ -504,7 +222,7 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) {
|
||||
if err := json.Unmarshal(msg.Data, &peek); err != nil || peek.InstanceID == "" {
|
||||
// Can't determine target node — fall through to local start which will
|
||||
// return the appropriate error (missing parameter / unmarshal failure).
|
||||
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
|
||||
handleNATSRequest(d.instanceService.StartStoppedInstance)(msg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -550,150 +268,5 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) {
|
||||
}
|
||||
}
|
||||
|
||||
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
|
||||
}
|
||||
|
||||
// handleEC2StartStoppedInstanceDirect handles ec2.start.{nodeId} — the
|
||||
// node-targeted topic used by handleEC2StartStoppedInstance to forward start
|
||||
// requests back to the original node. Always starts locally; never forwards
|
||||
// further, preventing routing loops.
|
||||
func (d *Daemon) handleEC2StartStoppedInstanceDirect(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2TerminateStoppedInstance(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.TerminateStoppedInstance)
|
||||
}
|
||||
|
||||
// handleEC2DescribeStoppedInstances returns stopped instances from shared KV.
|
||||
func (d *Daemon) handleEC2DescribeStoppedInstances(msg *nats.Msg) {
|
||||
if d.stateStore == nil {
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
d.describeInstancesFromKV(msg, d.stateStore.ListStoppedInstances, 80, "stopped", "handleEC2DescribeStoppedInstances")
|
||||
}
|
||||
|
||||
// handleEC2DescribeTerminatedInstances returns terminated instances from the terminated KV bucket.
|
||||
func (d *Daemon) handleEC2DescribeTerminatedInstances(msg *nats.Msg) {
|
||||
if d.stateStore == nil {
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
d.describeInstancesFromKV(msg, d.stateStore.ListTerminatedInstances, 48, "terminated", "handleEC2DescribeTerminatedInstances")
|
||||
}
|
||||
|
||||
// describeInstancesFromKV is a shared helper for DescribeStopped/TerminatedInstances handlers.
|
||||
// It lists instances from a KV bucket, filters by account/instance ID, and responds with reservations.
|
||||
func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM, error), fallbackCode int64, fallbackName, handlerName string) {
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
describeInput := &ec2.DescribeInstancesInput{}
|
||||
if len(msg.Data) > 0 {
|
||||
if errResp := utils.UnmarshalJsonPayload(describeInput, msg.Data); errResp != nil {
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
instanceIDFilter := make(map[string]bool)
|
||||
for _, id := range describeInput.InstanceIds {
|
||||
if id != nil {
|
||||
instanceIDFilter[*id] = true
|
||||
}
|
||||
}
|
||||
|
||||
parsedFilters, filterErr := filterutil.ParseFilters(describeInput.Filters, describeInstancesValidFilters)
|
||||
if filterErr != nil {
|
||||
slog.Warn(handlerName+": invalid filter", "err", filterErr)
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := listFn()
|
||||
if err != nil {
|
||||
slog.Error(handlerName+": failed to list instances", "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
reservationMap := make(map[string]*ec2.Reservation)
|
||||
|
||||
for _, instance := range instances {
|
||||
if !isInstanceVisible(accountID, instance.AccountID) {
|
||||
continue
|
||||
}
|
||||
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
|
||||
continue
|
||||
}
|
||||
if instance.Reservation == nil || instance.Instance == nil {
|
||||
slog.Warn(handlerName+": skipping instance with nil Reservation/Instance (data integrity issue)",
|
||||
"instanceId", instance.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
resID := ""
|
||||
if instance.Reservation.ReservationId != nil {
|
||||
resID = *instance.Reservation.ReservationId
|
||||
}
|
||||
|
||||
if _, exists := reservationMap[resID]; !exists {
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId(resID)
|
||||
if instance.Reservation.OwnerId != nil {
|
||||
reservation.SetOwnerId(*instance.Reservation.OwnerId)
|
||||
}
|
||||
reservation.Instances = []*ec2.Instance{}
|
||||
reservationMap[resID] = reservation
|
||||
}
|
||||
|
||||
instanceCopy := *instance.Instance
|
||||
instanceCopy.State = &ec2.InstanceState{}
|
||||
if info, ok := vm.EC2APIState(instance.Status); ok {
|
||||
instanceCopy.State.SetCode(info.Code)
|
||||
instanceCopy.State.SetName(info.Name)
|
||||
} else {
|
||||
instanceCopy.State.SetCode(fallbackCode)
|
||||
instanceCopy.State.SetName(fallbackName)
|
||||
}
|
||||
|
||||
// Project IamInstanceProfile from vm.VM (cleared on terminate;
|
||||
// preserved across stop/start). Mirrors handleEC2DescribeInstances.
|
||||
if instance.IamInstanceProfileArn != "" {
|
||||
instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{
|
||||
Arn: aws.String(instance.IamInstanceProfileArn),
|
||||
}
|
||||
} else {
|
||||
instanceCopy.IamInstanceProfile = nil
|
||||
}
|
||||
|
||||
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
|
||||
}
|
||||
|
||||
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
|
||||
for _, reservation := range reservationMap {
|
||||
reservations = append(reservations, reservation)
|
||||
}
|
||||
|
||||
respondWithJSON(msg, &ec2.DescribeInstancesOutput{Reservations: reservations})
|
||||
slog.Info(handlerName+" completed", "count", len(reservations))
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.ModifyInstanceAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyInstanceMetadataOptions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.ModifyInstanceMetadataOptions)
|
||||
}
|
||||
|
||||
// handleEC2DescribeInstanceAttribute returns a single requested attribute for an instance.
|
||||
func (d *Daemon) handleEC2DescribeInstanceAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.instanceService.DescribeInstanceAttribute)
|
||||
handleNATSRequest(d.instanceService.StartStoppedInstance)(msg)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -54,11 +54,11 @@ func (m *memStoppedStore) DeleteStoppedInstance(id string) error {
|
||||
}
|
||||
|
||||
// UpdateStoppedInstance mimics the real CAS semantics: a missing record
|
||||
// returns nats.ErrKeyNotFound instead of resurrecting it.
|
||||
// returns jetstream.ErrKeyNotFound instead of resurrecting it.
|
||||
func (m *memStoppedStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (*vm.VM, error) {
|
||||
v, ok := m.instances[id]
|
||||
if !ok {
|
||||
return nil, nats.ErrKeyNotFound
|
||||
return nil, jetstream.ErrKeyNotFound
|
||||
}
|
||||
mutate(v)
|
||||
return v, nil
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -126,7 +127,7 @@ func (f *fakeStateStore) ClaimStoppedInstance(id string) (*vm.VM, error) {
|
||||
|
||||
// UpdateStoppedInstance mimics the real CAS semantics for tests: mutate runs
|
||||
// under the store lock against the stored value, and a missing record
|
||||
// returns nats.ErrKeyNotFound (matching JetStreamManager's createIfAbsent=false
|
||||
// returns jetstream.ErrKeyNotFound (matching JetStreamManager's createIfAbsent=false
|
||||
// behavior) rather than resurrecting it.
|
||||
func (f *fakeStateStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (*vm.VM, error) {
|
||||
f.mu.Lock()
|
||||
@@ -136,7 +137,7 @@ func (f *fakeStateStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (
|
||||
}
|
||||
v, ok := f.stopped[id]
|
||||
if !ok {
|
||||
return nil, nats.ErrKeyNotFound
|
||||
return nil, jetstream.ErrKeyNotFound
|
||||
}
|
||||
mutate(v)
|
||||
return v, nil
|
||||
@@ -391,7 +392,7 @@ func TestHandleEC2TerminateStoppedInstance_LoadError(t *testing.T) {
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-load-fail"})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test1", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test1", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -399,7 +400,7 @@ func TestHandleEC2TerminateStoppedInstance_StateStoreNil(t *testing.T) {
|
||||
d := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-no-store"})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test2", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test2", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -413,7 +414,7 @@ func TestHandleEC2TerminateStoppedInstance_WriteTerminatedFailureAborts(t *testi
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test3", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test3", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
|
||||
store.mu.Lock()
|
||||
@@ -436,7 +437,7 @@ func TestHandleEC2TerminateStoppedInstance_DeleteRetrySucceeds(t *testing.T) {
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test4", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test4", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
|
||||
var resp map[string]string
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &resp))
|
||||
@@ -463,7 +464,7 @@ func TestHandleEC2TerminateStoppedInstance_DeleteAlwaysFailsKeepsTerminated(t *t
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test5", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test5", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
|
||||
var resp map[string]string
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &resp))
|
||||
@@ -481,7 +482,7 @@ func TestHandleEC2TerminateStoppedInstance_CrossTenantRejected(t *testing.T) {
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-foreign-term"})
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test6", d.handleEC2TerminateStoppedInstance, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.terminate.test6", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, decodeError(t, reply.Data)["Code"])
|
||||
|
||||
store.mu.Lock()
|
||||
@@ -506,7 +507,7 @@ func TestHandleEC2ModifyInstanceAttribute_WriteFailureReturnsServerInternal(t *t
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test1", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test1", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -520,7 +521,7 @@ func TestHandleEC2ModifyInstanceAttribute_LoadFailureReturnsServerInternal(t *te
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test2", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test2", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -544,7 +545,7 @@ func TestHandleEC2ModifyInstanceAttribute_NilInstanceFieldGuard(t *testing.T) {
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test3", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test3", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -559,7 +560,7 @@ func TestHandleEC2ModifyInstanceAttribute_EmptyInstanceTypeRejected(t *testing.T
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("")},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test4", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test4", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceAttributeValue, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -573,7 +574,7 @@ func TestHandleEC2ModifyInstanceAttribute_CrossTenantRejected(t *testing.T) {
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test5", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test5", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -591,7 +592,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedFallback_LoadError(t *testing
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test1", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test1", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -608,7 +609,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedFallback_HitsKV(t *testing.T)
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test2", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test2", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &output))
|
||||
@@ -627,7 +628,7 @@ func TestHandleEC2DescribeInstanceAttribute_StateStoreNil(t *testing.T) {
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test3", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test3", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -638,7 +639,7 @@ func TestHandleEC2DescribeStoppedInstances_ListError(t *testing.T) {
|
||||
store.listStoppedErr = errors.New("list failed")
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test1", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test1", handleNATSRequest(d.instanceService.DescribeStoppedInstances), testAccountID, []byte("{}"))
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -647,21 +648,7 @@ func TestHandleEC2DescribeTerminatedInstances_ListError(t *testing.T) {
|
||||
store.listTerminatedErr = errors.New("list failed")
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test1", d.handleEC2DescribeTerminatedInstances, testAccountID, []byte("{}"))
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeStoppedInstances_StateStoreNil(t *testing.T) {
|
||||
d := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test2", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeTerminatedInstances_StateStoreNil(t *testing.T) {
|
||||
d := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test2", d.handleEC2DescribeTerminatedInstances, testAccountID, []byte("{}"))
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test1", handleNATSRequest(d.instanceService.DescribeTerminatedInstances), testAccountID, []byte("{}"))
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
|
||||
}
|
||||
|
||||
@@ -671,7 +658,7 @@ func TestHandleEC2DescribeStoppedInstances_CrossAccountIsolation(t *testing.T) {
|
||||
store.stopped["i-yours"] = stoppedVMFixture("i-yours", "999988887777")
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test3", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test3", handleNATSRequest(d.instanceService.DescribeStoppedInstances), testAccountID, []byte("{}"))
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &output))
|
||||
@@ -686,191 +673,3 @@ func TestHandleEC2DescribeStoppedInstances_CrossAccountIsolation(t *testing.T) {
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"i-mine"}, seen, "caller must only see their own instances")
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeStoppedInstances_EmptyReturnsValidShape(t *testing.T) {
|
||||
store := newFakeStateStore()
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test4", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &output))
|
||||
assert.Empty(t, output.Reservations, "empty store must produce empty reservation list")
|
||||
}
|
||||
|
||||
// Two instances sharing a ReservationId must collapse into a single
|
||||
// reservation with both Instances attached.
|
||||
func TestHandleEC2DescribeStoppedInstances_ReservationGrouping(t *testing.T) {
|
||||
store := newFakeStateStore()
|
||||
|
||||
a := stoppedVMFixture("i-grp-a", testAccountID)
|
||||
b := stoppedVMFixture("i-grp-b", testAccountID)
|
||||
a.Reservation.ReservationId = aws.String("r-shared")
|
||||
b.Reservation.ReservationId = aws.String("r-shared")
|
||||
store.stopped[a.ID] = a
|
||||
store.stopped[b.ID] = b
|
||||
|
||||
d := daemonWithFakeStateStore(t, store)
|
||||
|
||||
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test5", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &output))
|
||||
require.Len(t, output.Reservations, 1, "shared ReservationId must collapse into one reservation")
|
||||
require.NotNil(t, output.Reservations[0].ReservationId)
|
||||
assert.Equal(t, "r-shared", *output.Reservations[0].ReservationId)
|
||||
assert.Len(t, output.Reservations[0].Instances, 2)
|
||||
}
|
||||
|
||||
// DescribeInstances echoes the consumed capacity reservation so a targeted
|
||||
// launch reports a CapacityReservationId (without it, Terraform never converges).
|
||||
func TestHandleEC2DescribeInstances_CapacityReservationEcho(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId("r-cr-echo")
|
||||
reservation.SetOwnerId(testAccountID)
|
||||
instance := &ec2.Instance{}
|
||||
instance.SetInstanceId("i-cr-echo")
|
||||
instance.SetInstanceType("t3.micro")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: "i-cr-echo",
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
CapacityReservationId: "cr-0123456789abcdef0",
|
||||
Reservation: reservation,
|
||||
Instance: instance,
|
||||
})
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
var out ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &out))
|
||||
require.Len(t, out.Reservations, 1)
|
||||
require.Len(t, out.Reservations[0].Instances, 1)
|
||||
|
||||
got := out.Reservations[0].Instances[0]
|
||||
assert.Equal(t, "cr-0123456789abcdef0", aws.StringValue(got.CapacityReservationId))
|
||||
require.NotNil(t, got.CapacityReservationSpecification)
|
||||
assert.Equal(t, ec2.CapacityReservationPreferenceOpen,
|
||||
aws.StringValue(got.CapacityReservationSpecification.CapacityReservationPreference))
|
||||
require.NotNil(t, got.CapacityReservationSpecification.CapacityReservationTarget)
|
||||
assert.Equal(t, "cr-0123456789abcdef0",
|
||||
aws.StringValue(got.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationId))
|
||||
}
|
||||
|
||||
// An instance with no reservation reports no capacity-reservation fields.
|
||||
func TestHandleEC2DescribeInstances_NoCapacityReservationEcho(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId("r-plain")
|
||||
reservation.SetOwnerId(testAccountID)
|
||||
instance := &ec2.Instance{}
|
||||
instance.SetInstanceId("i-plain")
|
||||
instance.SetInstanceType("t3.micro")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: "i-plain",
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Reservation: reservation,
|
||||
Instance: instance,
|
||||
})
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
var out ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &out))
|
||||
require.Len(t, out.Reservations, 1)
|
||||
require.Len(t, out.Reservations[0].Instances, 1)
|
||||
|
||||
got := out.Reservations[0].Instances[0]
|
||||
assert.Empty(t, aws.StringValue(got.CapacityReservationId))
|
||||
assert.Nil(t, got.CapacityReservationSpecification)
|
||||
}
|
||||
|
||||
// A spot-launched instance projects InstanceLifecycle + SpotInstanceRequestId.
|
||||
func TestHandleEC2DescribeInstances_SpotLineageEcho(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId("r-spot-echo")
|
||||
reservation.SetOwnerId(testAccountID)
|
||||
instance := &ec2.Instance{}
|
||||
instance.SetInstanceId("i-spot-echo")
|
||||
instance.SetInstanceType("t3.micro")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: "i-spot-echo",
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
InstanceLifecycle: ec2.InstanceLifecycleTypeSpot,
|
||||
SpotInstanceRequestId: "sir-0123456789abcdef0",
|
||||
Reservation: reservation,
|
||||
Instance: instance,
|
||||
})
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
var out ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &out))
|
||||
require.Len(t, out.Reservations, 1)
|
||||
require.Len(t, out.Reservations[0].Instances, 1)
|
||||
|
||||
got := out.Reservations[0].Instances[0]
|
||||
assert.Equal(t, ec2.InstanceLifecycleTypeSpot, aws.StringValue(got.InstanceLifecycle))
|
||||
assert.Equal(t, "sir-0123456789abcdef0", aws.StringValue(got.SpotInstanceRequestId))
|
||||
}
|
||||
|
||||
// An on-demand instance reports no spot-lineage fields.
|
||||
func TestHandleEC2DescribeInstances_NoSpotLineageEcho(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
reservation := &ec2.Reservation{}
|
||||
reservation.SetReservationId("r-ondemand")
|
||||
reservation.SetOwnerId(testAccountID)
|
||||
instance := &ec2.Instance{}
|
||||
instance.SetInstanceId("i-ondemand")
|
||||
instance.SetInstanceType("t3.micro")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: "i-ondemand",
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Reservation: reservation,
|
||||
Instance: instance,
|
||||
})
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
var out ec2.DescribeInstancesOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &out))
|
||||
require.Len(t, out.Reservations, 1)
|
||||
require.Len(t, out.Reservations[0].Instances, 1)
|
||||
|
||||
got := out.Reservations[0].Instances[0]
|
||||
assert.Empty(t, aws.StringValue(got.InstanceLifecycle))
|
||||
assert.Empty(t, aws.StringValue(got.SpotInstanceRequestId))
|
||||
}
|
||||
|
||||
@@ -6,22 +6,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.CreateKeyPair)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.DeleteKeyPair)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeKeyPairs(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.DescribeKeyPairs)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ImportKeyPair(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.keyService.ImportKeyPair)
|
||||
}
|
||||
|
||||
// handleIMDSGetPublicKey answers the IMDS material-path RPC. The responder lives
|
||||
// here (not in vpcd) because the daemon holds the key service and its Predastore
|
||||
// credentials; vpcd carries only a nats.Conn.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2CreateLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateLaunchTemplateVersion(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplateVersion)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteLaunchTemplateVersions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplateVersions)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.ModifyLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeLaunchTemplates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeLaunchTemplateVersions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplateVersions)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateNatGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.CreateNatGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteNatGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.DeleteNatGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeNatGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.DescribeNatGateways)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2CreatePlacementGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.CreatePlacementGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeletePlacementGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.DeletePlacementGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribePlacementGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.DescribePlacementGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReserveSpreadNodes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.ReserveSpreadNodes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2FinalizeSpreadInstances(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.FinalizeSpreadInstances)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReleaseSpreadNodes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.ReleaseSpreadNodes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2RemoveInstanceFromPlacementGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.RemoveInstance)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReserveClusterNode(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.ReserveClusterNode)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2FinalizeClusterInstances(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.placementGroupService.FinalizeClusterInstances)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.CreateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DeleteRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeRouteTables(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DescribeRouteTables)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.CreateRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DeleteRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReplaceRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.ReplaceRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AssociateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.AssociateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisassociateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DisassociateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReplaceRouteTableAssociation(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.ReplaceRouteTableAssociation)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2CreateSecurityGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateSecurityGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSecurityGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteSecurityGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSecurityGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeSecurityGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSecurityGroupRules(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeSecurityGroupRules)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AuthorizeSecurityGroupIngress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.AuthorizeSecurityGroupIngress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AuthorizeSecurityGroupEgress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.AuthorizeSecurityGroupEgress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2RevokeSecurityGroupIngress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.RevokeSecurityGroupIngress)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2RevokeSecurityGroupEgress(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.RevokeSecurityGroupEgress)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CreateSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSnapshots(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DescribeSnapshots)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DeleteSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CopySnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CopySnapshot)
|
||||
}
|
||||
@@ -11,18 +11,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2PutSpotInstanceRequests(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.spotInstanceService.PutSpotInstanceRequests)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSpotInstanceRequests(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.spotInstanceService.DescribeSpotInstanceRequests)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CancelSpotInstanceRequests(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.spotInstanceService.CancelSpotInstanceRequests)
|
||||
}
|
||||
|
||||
// handleSetSpotLineage stamps the spot lineage (InstanceLifecycle=spot +
|
||||
// SpotInstanceRequestId) onto a launched VM. Dispatched from handleEC2Events,
|
||||
// which already verified ownership; the SIR id is the only datum on the wire as
|
||||
@@ -39,7 +27,7 @@ func (d *Daemon) handleSetSpotLineage(ctx context.Context, msg *nats.Msg, comman
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
|
||||
@@ -21,18 +21,6 @@ import (
|
||||
// only a partitioned-but-subscribed owner pays the full timeout.
|
||||
const instanceTagCommandTimeout = 5 * time.Second
|
||||
|
||||
func (d *Daemon) handleEC2CreateTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.createTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.deleteTags)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.DescribeTags)
|
||||
}
|
||||
|
||||
// recordTagMirror is implemented by every service whose resource records
|
||||
// mirror the central tag store, so tag-filtered describes observe tags
|
||||
// written through create-tags/delete-tags.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -131,7 +133,7 @@ func initAccountServiceForTest(t *testing.T, daemon *Daemon) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
svc, err := handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(nil, nc)
|
||||
svc, err := handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.accountService = svc
|
||||
}
|
||||
@@ -157,9 +159,7 @@ func TestHandleNATSRequest_ValidRequest(t *testing.T) {
|
||||
return &testOutput{Greeting: "hello " + in.Name}, nil
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe("test.greet", func(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, serviceFn)
|
||||
})
|
||||
sub, err := nc.Subscribe("test.greet", handleNATSRequest(serviceFn))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -184,9 +184,7 @@ func TestHandleNATSRequest_MalformedJSON(t *testing.T) {
|
||||
return &testOutput{Greeting: "hello"}, nil
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe("test.malformed", func(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, serviceFn)
|
||||
})
|
||||
sub, err := nc.Subscribe("test.malformed", handleNATSRequest(serviceFn))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -210,9 +208,7 @@ func TestHandleNATSRequest_ServiceError(t *testing.T) {
|
||||
return nil, fmt.Errorf("something went wrong")
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe("test.err", func(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, serviceFn)
|
||||
})
|
||||
sub, err := nc.Subscribe("test.err", handleNATSRequest(serviceFn))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -228,6 +224,30 @@ func TestHandleNATSRequest_ServiceError(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, errResp["Code"])
|
||||
}
|
||||
|
||||
func TestHandleNATSRequest_WrappedAWSServiceError(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
serviceFn := func(_ context.Context, _ *testInput, _ string) (*testOutput, error) {
|
||||
cause := errors.New(awserrors.ErrorInsufficientAddressCapacity)
|
||||
return nil, fmt.Errorf("allocate public address: %w", cause)
|
||||
}
|
||||
|
||||
sub, err := nc.Subscribe("test.err.wrapped", handleNATSRequest(serviceFn))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(testInput{Name: "world"})
|
||||
require.NoError(t, err)
|
||||
reply, err := nc.Request("test.err.wrapped", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var errResp map[string]any
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &errResp))
|
||||
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, errResp["Code"])
|
||||
}
|
||||
|
||||
// --- Handler wrapper tests (representative set via NATS round-trip) ---
|
||||
|
||||
func TestHandleEC2CreateKeyPair_RoundTrip(t *testing.T) {
|
||||
@@ -235,7 +255,7 @@ func TestHandleEC2CreateKeyPair_RoundTrip(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemon(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateKeyPair", "spinifex-workers", daemon.handleEC2CreateKeyPair)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateKeyPair", "spinifex-workers", handleNATSRequest(daemon.keyService.CreateKeyPair))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1226,7 +1246,7 @@ func TestHandleEC2DescribeStoppedInstances_ReturnsStoppedInstances(t *testing.T)
|
||||
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", daemon.handleEC2DescribeStoppedInstances)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeStoppedInstances))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1280,7 +1300,7 @@ func TestHandleEC2DescribeStoppedInstances_WithFilter(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", daemon.handleEC2DescribeStoppedInstances)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeStoppedInstances))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1510,7 +1530,7 @@ func TestHandleEC2ModifyInstanceAttribute_WrapperRoundTrip(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", daemon.handleEC2ModifyInstanceAttribute)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", handleNATSRequest(daemon.instanceService.ModifyInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1552,7 +1572,7 @@ func TestHandleEC2DescribeInstanceAttribute_RunningInstance_InstanceType(t *test
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1594,7 +1614,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedInstance_InstanceType(t *test
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1634,7 +1654,7 @@ func TestHandleEC2DescribeInstanceAttribute_UserData(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1676,7 +1696,7 @@ func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_DisableApiTerminati
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1720,7 +1740,7 @@ func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_ShutdownBehavior(t
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1758,7 +1778,7 @@ func TestHandleEC2DescribeInstanceAttribute_GroupSet_WithSecurityGroups(t *testi
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1804,7 +1824,7 @@ func TestHandleEC2DescribeInstanceAttribute_GroupSet_NilInstance(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1841,7 +1861,7 @@ func TestHandleEC2DescribeInstanceAttribute_InstanceNotFound(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1864,7 +1884,7 @@ func TestHandleEC2DescribeInstanceAttribute_UnsupportedAttribute(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1900,7 +1920,7 @@ func TestHandleEC2DescribeInstanceAttribute_InvalidJSON(t *testing.T) {
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1941,68 +1961,68 @@ func TestDelegateHandlers_RoundTrip(t *testing.T) {
|
||||
{
|
||||
name: "DeleteKeyPair",
|
||||
topic: "ec2.test.DeleteKeyPair",
|
||||
handler: daemon.handleEC2DeleteKeyPair,
|
||||
handler: handleNATSRequest(daemon.keyService.DeleteKeyPair),
|
||||
input: &ec2.DeleteKeyPairInput{KeyName: aws.String("nonexistent-key")},
|
||||
// Idempotent delete: missing key returns a success response.
|
||||
},
|
||||
{
|
||||
name: "ImportKeyPair",
|
||||
topic: "ec2.test.ImportKeyPair",
|
||||
handler: daemon.handleEC2ImportKeyPair,
|
||||
handler: handleNATSRequest(daemon.keyService.ImportKeyPair),
|
||||
input: &ec2.ImportKeyPairInput{KeyName: aws.String("imported-key"), PublicKeyMaterial: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@test")},
|
||||
expectedCode: awserrors.ErrorInvalidKeyFormat,
|
||||
},
|
||||
{
|
||||
name: "CreateVolume",
|
||||
topic: "ec2.test.CreateVolume",
|
||||
handler: daemon.handleEC2CreateVolume,
|
||||
handler: handleNATSRequest(daemon.volumeService.CreateVolume),
|
||||
input: &ec2.CreateVolumeInput{AvailabilityZone: aws.String("us-east-1a"), Size: aws.Int64(10)},
|
||||
expectedCode: awserrors.ErrorInvalidAvailabilityZone,
|
||||
},
|
||||
{
|
||||
name: "DescribeVolumeStatus",
|
||||
topic: "ec2.test.DescribeVolumeStatus",
|
||||
handler: daemon.handleEC2DescribeVolumeStatus,
|
||||
handler: handleNATSRequest(daemon.volumeService.DescribeVolumeStatus),
|
||||
input: &ec2.DescribeVolumeStatusInput{},
|
||||
},
|
||||
{
|
||||
name: "DeleteVolume",
|
||||
topic: "ec2.test.DeleteVolume",
|
||||
handler: daemon.handleEC2DeleteVolume,
|
||||
handler: handleNATSRequest(daemon.volumeService.DeleteVolume),
|
||||
input: &ec2.DeleteVolumeInput{VolumeId: aws.String("vol-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidVolumeNotFound,
|
||||
},
|
||||
{
|
||||
name: "CreateSnapshot",
|
||||
topic: "ec2.test.CreateSnapshot",
|
||||
handler: daemon.handleEC2CreateSnapshot,
|
||||
handler: handleNATSRequest(daemon.snapshotService.CreateSnapshot),
|
||||
input: &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidVolumeNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeSnapshots",
|
||||
topic: "ec2.test.DescribeSnapshots",
|
||||
handler: daemon.handleEC2DescribeSnapshots,
|
||||
handler: handleNATSRequest(daemon.snapshotService.DescribeSnapshots),
|
||||
input: &ec2.DescribeSnapshotsInput{},
|
||||
},
|
||||
{
|
||||
name: "DeleteSnapshot",
|
||||
topic: "ec2.test.DeleteSnapshot",
|
||||
handler: daemon.handleEC2DeleteSnapshot,
|
||||
handler: handleNATSRequest(daemon.snapshotService.DeleteSnapshot),
|
||||
input: &ec2.DeleteSnapshotInput{SnapshotId: aws.String("snap-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidSnapshotNotFound,
|
||||
},
|
||||
{
|
||||
name: "CopySnapshot",
|
||||
topic: "ec2.test.CopySnapshot",
|
||||
handler: daemon.handleEC2CopySnapshot,
|
||||
handler: handleNATSRequest(daemon.snapshotService.CopySnapshot),
|
||||
input: &ec2.CopySnapshotInput{SourceRegion: aws.String("us-east-1"), SourceSnapshotId: aws.String("snap-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidSnapshotNotFound,
|
||||
},
|
||||
{
|
||||
name: "DeleteTags",
|
||||
topic: "ec2.test.DeleteTags",
|
||||
handler: daemon.handleEC2DeleteTags,
|
||||
handler: handleNATSRequest(daemon.deleteTags),
|
||||
// Instance IDs route to the owning daemon; with no owner
|
||||
// subscribed the mutation is rejected rather than written blind.
|
||||
input: &ec2.DeleteTagsInput{Resources: []*string{aws.String("i-12345678")}},
|
||||
@@ -2011,7 +2031,7 @@ func TestDelegateHandlers_RoundTrip(t *testing.T) {
|
||||
{
|
||||
name: "DescribeTags",
|
||||
topic: "ec2.test.DescribeTags",
|
||||
handler: daemon.handleEC2DescribeTags,
|
||||
handler: handleNATSRequest(daemon.tagsService.DescribeTags),
|
||||
input: &ec2.DescribeTagsInput{},
|
||||
},
|
||||
}
|
||||
@@ -2276,11 +2296,11 @@ func createVPCTestDaemon(t *testing.T) *Daemon {
|
||||
|
||||
testutil.StubVpcdSGResponder(t, nc)
|
||||
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(daemon.config, nc)
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(t.Context(), daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.vpcService = vpcSvc
|
||||
|
||||
igwSvc, err := handlers_ec2_igw.NewIGWServiceImplWithNATS(daemon.config, nc)
|
||||
igwSvc, err := handlers_ec2_igw.NewIGWServiceImplWithNATS(t.Context(), daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.igwService = igwSvc
|
||||
|
||||
@@ -2301,26 +2321,26 @@ func TestDelegateHandlers_VPC(t *testing.T) {
|
||||
{
|
||||
name: "CreateVpc",
|
||||
topic: "ec2.test.CreateVpc",
|
||||
handler: daemon.handleEC2CreateVpc,
|
||||
handler: handleNATSRequest(daemon.vpcService.CreateVpc),
|
||||
input: &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "DeleteVpc",
|
||||
topic: "ec2.test.DeleteVpc",
|
||||
handler: daemon.handleEC2DeleteVpc,
|
||||
handler: handleNATSRequest(daemon.vpcService.DeleteVpc),
|
||||
input: &ec2.DeleteVpcInput{VpcId: aws.String("vpc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeVpcs",
|
||||
topic: "ec2.test.DescribeVpcs",
|
||||
handler: daemon.handleEC2DescribeVpcs,
|
||||
handler: handleNATSRequest(daemon.vpcService.DescribeVpcs),
|
||||
input: &ec2.DescribeVpcsInput{},
|
||||
},
|
||||
{
|
||||
name: "CreateSubnet",
|
||||
topic: "ec2.test.CreateSubnet",
|
||||
handler: daemon.handleEC2CreateSubnet,
|
||||
handler: handleNATSRequest(daemon.vpcService.CreateSubnet),
|
||||
input: &ec2.CreateSubnetInput{
|
||||
VpcId: aws.String("vpc-nonexistent"),
|
||||
CidrBlock: aws.String("10.0.1.0/24"),
|
||||
@@ -2330,34 +2350,34 @@ func TestDelegateHandlers_VPC(t *testing.T) {
|
||||
{
|
||||
name: "DeleteSubnet",
|
||||
topic: "ec2.test.DeleteSubnet",
|
||||
handler: daemon.handleEC2DeleteSubnet,
|
||||
handler: handleNATSRequest(daemon.vpcService.DeleteSubnet),
|
||||
input: &ec2.DeleteSubnetInput{SubnetId: aws.String("subnet-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeSubnets",
|
||||
topic: "ec2.test.DescribeSubnets",
|
||||
handler: daemon.handleEC2DescribeSubnets,
|
||||
handler: handleNATSRequest(daemon.vpcService.DescribeSubnets),
|
||||
input: &ec2.DescribeSubnetsInput{},
|
||||
},
|
||||
{
|
||||
name: "CreateNetworkInterface",
|
||||
topic: "ec2.test.CreateNetworkInterface",
|
||||
handler: daemon.handleEC2CreateNetworkInterface,
|
||||
handler: handleNATSRequest(daemon.vpcService.CreateNetworkInterface),
|
||||
input: &ec2.CreateNetworkInterfaceInput{SubnetId: aws.String("subnet-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DeleteNetworkInterface",
|
||||
topic: "ec2.test.DeleteNetworkInterface",
|
||||
handler: daemon.handleEC2DeleteNetworkInterface,
|
||||
handler: handleNATSRequest(daemon.vpcService.DeleteNetworkInterface),
|
||||
input: &ec2.DeleteNetworkInterfaceInput{NetworkInterfaceId: aws.String("eni-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidNetworkInterfaceIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeNetworkInterfaces",
|
||||
topic: "ec2.test.DescribeNetworkInterfaces",
|
||||
handler: daemon.handleEC2DescribeNetworkInterfaces,
|
||||
handler: handleNATSRequest(daemon.vpcService.DescribeNetworkInterfaces),
|
||||
input: &ec2.DescribeNetworkInterfacesInput{},
|
||||
},
|
||||
}
|
||||
@@ -2394,26 +2414,26 @@ func TestDelegateHandlers_IGW(t *testing.T) {
|
||||
{
|
||||
name: "CreateInternetGateway",
|
||||
topic: "ec2.test.CreateInternetGateway",
|
||||
handler: daemon.handleEC2CreateInternetGateway,
|
||||
handler: handleNATSRequest(daemon.igwService.CreateInternetGateway),
|
||||
input: &ec2.CreateInternetGatewayInput{},
|
||||
},
|
||||
{
|
||||
name: "DeleteInternetGateway",
|
||||
topic: "ec2.test.DeleteInternetGateway",
|
||||
handler: daemon.handleEC2DeleteInternetGateway,
|
||||
handler: handleNATSRequest(daemon.igwService.DeleteInternetGateway),
|
||||
input: &ec2.DeleteInternetGatewayInput{InternetGatewayId: aws.String("igw-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidInternetGatewayIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeInternetGateways",
|
||||
topic: "ec2.test.DescribeInternetGateways",
|
||||
handler: daemon.handleEC2DescribeInternetGateways,
|
||||
handler: handleNATSRequest(daemon.igwService.DescribeInternetGateways),
|
||||
input: &ec2.DescribeInternetGatewaysInput{},
|
||||
},
|
||||
{
|
||||
name: "AttachInternetGateway",
|
||||
topic: "ec2.test.AttachInternetGateway",
|
||||
handler: daemon.handleEC2AttachInternetGateway,
|
||||
handler: handleNATSRequest(daemon.igwService.AttachInternetGateway),
|
||||
input: &ec2.AttachInternetGatewayInput{
|
||||
InternetGatewayId: aws.String("igw-nonexistent"),
|
||||
VpcId: aws.String("vpc-nonexistent"),
|
||||
@@ -2423,7 +2443,7 @@ func TestDelegateHandlers_IGW(t *testing.T) {
|
||||
{
|
||||
name: "DetachInternetGateway",
|
||||
topic: "ec2.test.DetachInternetGateway",
|
||||
handler: daemon.handleEC2DetachInternetGateway,
|
||||
handler: handleNATSRequest(daemon.igwService.DetachInternetGateway),
|
||||
input: &ec2.DetachInternetGatewayInput{
|
||||
InternetGatewayId: aws.String("igw-nonexistent"),
|
||||
VpcId: aws.String("vpc-nonexistent"),
|
||||
@@ -2453,7 +2473,7 @@ func TestDelegateHandlers_IGW(t *testing.T) {
|
||||
func TestHandleEC2CreateVpc_SuccessPath(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -2474,11 +2494,11 @@ func TestHandleEC2CreateVpc_SuccessPath(t *testing.T) {
|
||||
func TestHandleEC2CreateAndDescribeVpc_RoundTrip(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
createSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
createSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
|
||||
require.NoError(t, err)
|
||||
defer createSub.Unsubscribe()
|
||||
|
||||
describeSub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeVpcs", "spinifex-workers", daemon.handleEC2DescribeVpcs)
|
||||
describeSub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeVpcs", "spinifex-workers", handleNATSRequest(daemon.vpcService.DescribeVpcs))
|
||||
require.NoError(t, err)
|
||||
defer describeSub.Unsubscribe()
|
||||
|
||||
@@ -2514,7 +2534,7 @@ func TestHandleEC2CreateAndDescribeVpc_RoundTrip(t *testing.T) {
|
||||
func TestHandleEC2CreateInternetGateway_SuccessPath(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateInternetGateway", "spinifex-workers", daemon.handleEC2CreateInternetGateway)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateInternetGateway", "spinifex-workers", handleNATSRequest(daemon.igwService.CreateInternetGateway))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -2534,11 +2554,11 @@ func TestHandleEC2CreateInternetGateway_SuccessPath(t *testing.T) {
|
||||
func TestHandleEC2CreateSubnet_SuccessPath(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
|
||||
require.NoError(t, err)
|
||||
defer createVpcSub.Unsubscribe()
|
||||
|
||||
createSubnetSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateSubnet", "spinifex-workers", daemon.handleEC2CreateSubnet)
|
||||
createSubnetSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateSubnet", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateSubnet))
|
||||
require.NoError(t, err)
|
||||
defer createSubnetSub.Unsubscribe()
|
||||
|
||||
@@ -2592,7 +2612,7 @@ func TestDelegateHandlers_EIGW(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
eigwSvc, err := handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(daemon.config, nc)
|
||||
eigwSvc, err := handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(t.Context(), daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.eigwService = eigwSvc
|
||||
|
||||
@@ -2607,21 +2627,21 @@ func TestDelegateHandlers_EIGW(t *testing.T) {
|
||||
{
|
||||
name: "CreateEgressOnlyInternetGateway",
|
||||
topic: "ec2.test.CreateEgressOnlyIGW",
|
||||
handler: daemon.handleEC2CreateEgressOnlyInternetGateway,
|
||||
handler: handleNATSRequest(daemon.eigwService.CreateEgressOnlyInternetGateway),
|
||||
input: &ec2.CreateEgressOnlyInternetGatewayInput{VpcId: aws.String("vpc-123")},
|
||||
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DeleteEgressOnlyInternetGateway",
|
||||
topic: "ec2.test.DeleteEgressOnlyIGW",
|
||||
handler: daemon.handleEC2DeleteEgressOnlyInternetGateway,
|
||||
handler: handleNATSRequest(daemon.eigwService.DeleteEgressOnlyInternetGateway),
|
||||
input: &ec2.DeleteEgressOnlyInternetGatewayInput{EgressOnlyInternetGatewayId: aws.String("eigw-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidEgressOnlyInternetGatewayIdNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeEgressOnlyInternetGateways",
|
||||
topic: "ec2.test.DescribeEgressOnlyIGWs",
|
||||
handler: daemon.handleEC2DescribeEgressOnlyInternetGateways,
|
||||
handler: handleNATSRequest(daemon.eigwService.DescribeEgressOnlyInternetGateways),
|
||||
input: &ec2.DescribeEgressOnlyInternetGatewaysInput{},
|
||||
},
|
||||
}
|
||||
@@ -2704,7 +2724,7 @@ func TestHandleEC2ModifyVolume_Success(t *testing.T) {
|
||||
func TestHandleEC2DescribeInstanceTypes_CapacityFilter(t *testing.T) {
|
||||
daemon := createFullTestDaemon(t, sharedNATSURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes", "spinifex-workers", daemon.handleEC2DescribeInstanceTypes)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -2731,7 +2751,7 @@ func TestHandleEC2DescribeInstanceTypes_CapacityFilter(t *testing.T) {
|
||||
func TestHandleEC2DescribeInstanceTypes_NoFilter(t *testing.T) {
|
||||
daemon := createFullTestDaemon(t, sharedNATSURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes.nofilter", "spinifex-workers", daemon.handleEC2DescribeInstanceTypes)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes.nofilter", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3321,7 +3341,7 @@ func TestHandleEC2RunInstances_MalformedInput(t *testing.T) {
|
||||
func TestHandleEC2DescribeInstances_MalformedInstanceID(t *testing.T) {
|
||||
daemon := createFullTestDaemon(t, sharedNATSURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances.malformed", daemon.handleEC2DescribeInstances)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances.malformed", handleNATSRequest(daemon.instanceService.DescribeInstances))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3353,7 +3373,7 @@ func TestHandleEC2DescribeInstanceStatus_RoundTrip(t *testing.T) {
|
||||
daemon.vmMgr.Insert(runningVM)
|
||||
t.Cleanup(func() { daemon.vmMgr.Delete(runningVM.ID) })
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.rt", daemon.handleEC2DescribeInstanceStatus)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.rt", handleNATSRequest(daemon.instanceService.DescribeInstanceStatus))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3379,7 +3399,7 @@ func TestHandleEC2DescribeInstanceStatus_RoundTrip(t *testing.T) {
|
||||
func TestHandleEC2DescribeInstanceStatus_MalformedInstanceID(t *testing.T) {
|
||||
daemon := createFullTestDaemon(t, sharedNATSURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.malformed", daemon.handleEC2DescribeInstanceStatus)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.malformed", handleNATSRequest(daemon.instanceService.DescribeInstanceStatus))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3414,7 +3434,7 @@ func TestHandleEC2DescribeTerminatedInstances_ReturnsTerminatedInstances(t *test
|
||||
err := daemon.jsManager.WriteTerminatedInstance(terminatedVM.ID, terminatedVM)
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", daemon.handleEC2DescribeTerminatedInstances)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeTerminatedInstances))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3463,7 +3483,7 @@ func TestHandleEC2DescribeTerminatedInstances_WithFilter(t *testing.T) {
|
||||
require.NoError(t, daemon.jsManager.WriteTerminatedInstance(v.ID, v))
|
||||
}
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", daemon.handleEC2DescribeTerminatedInstances)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeTerminatedInstances))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3499,7 +3519,7 @@ func TestHandleEC2TerminateStoppedInstance_WritesToTerminatedKV(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM))
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.terminate", "spinifex-workers", daemon.handleEC2TerminateStoppedInstance)
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.terminate", "spinifex-workers", handleNATSRequest(daemon.instanceService.TerminateStoppedInstance))
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -3544,14 +3564,14 @@ func TestDelegateHandlers_EIP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
|
||||
{Name: "test-pool", RangeStart: "192.168.100.2", RangeEnd: "192.168.100.254", Gateway: "192.168.100.1", PrefixLen: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(nc, ipam, daemon.vpcService)
|
||||
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(t.Context(), nc, ipam, daemon.vpcService)
|
||||
require.NoError(t, err)
|
||||
daemon.eipService = eipSvc
|
||||
|
||||
@@ -3566,34 +3586,34 @@ func TestDelegateHandlers_EIP(t *testing.T) {
|
||||
{
|
||||
name: "AllocateAddress",
|
||||
topic: "ec2.test.AllocateAddress",
|
||||
handler: daemon.handleEC2AllocateAddress,
|
||||
handler: handleNATSRequest(daemon.eipService.AllocateAddress),
|
||||
input: &ec2.AllocateAddressInput{},
|
||||
},
|
||||
{
|
||||
name: "ReleaseAddress",
|
||||
topic: "ec2.test.ReleaseAddress",
|
||||
handler: daemon.handleEC2ReleaseAddress,
|
||||
handler: handleNATSRequest(daemon.eipService.ReleaseAddress),
|
||||
input: &ec2.ReleaseAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidAllocationIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "AssociateAddress",
|
||||
topic: "ec2.test.AssociateAddress",
|
||||
handler: daemon.handleEC2AssociateAddress,
|
||||
handler: handleNATSRequest(daemon.eipService.AssociateAddress),
|
||||
input: &ec2.AssociateAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidAllocationIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DisassociateAddress",
|
||||
topic: "ec2.test.DisassociateAddress",
|
||||
handler: daemon.handleEC2DisassociateAddress,
|
||||
handler: handleNATSRequest(daemon.eipService.DisassociateAddress),
|
||||
input: &ec2.DisassociateAddressInput{AssociationId: aws.String("eipassoc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidAssociationIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeAddresses",
|
||||
topic: "ec2.test.DescribeAddresses",
|
||||
handler: daemon.handleEC2DescribeAddresses,
|
||||
handler: handleNATSRequest(daemon.eipService.DescribeAddresses),
|
||||
input: &ec2.DescribeAddressesInput{},
|
||||
},
|
||||
}
|
||||
@@ -3622,7 +3642,7 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
// Create a VPC first so SG operations have a valid target
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
|
||||
require.NoError(t, err)
|
||||
defer createVpcSub.Unsubscribe()
|
||||
|
||||
@@ -3646,7 +3666,7 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
|
||||
{
|
||||
name: "CreateSecurityGroup",
|
||||
topic: "ec2.test.CreateSecurityGroup",
|
||||
handler: daemon.handleEC2CreateSecurityGroup,
|
||||
handler: handleNATSRequest(daemon.vpcService.CreateSecurityGroup),
|
||||
input: &ec2.CreateSecurityGroupInput{
|
||||
GroupName: aws.String("test-sg"),
|
||||
Description: aws.String("test security group"),
|
||||
@@ -3656,41 +3676,41 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
|
||||
{
|
||||
name: "DescribeSecurityGroups",
|
||||
topic: "ec2.test.DescribeSecurityGroups",
|
||||
handler: daemon.handleEC2DescribeSecurityGroups,
|
||||
handler: handleNATSRequest(daemon.vpcService.DescribeSecurityGroups),
|
||||
input: &ec2.DescribeSecurityGroupsInput{},
|
||||
},
|
||||
{
|
||||
name: "AuthorizeSecurityGroupIngress",
|
||||
topic: "ec2.test.AuthorizeSecurityGroupIngress",
|
||||
handler: daemon.handleEC2AuthorizeSecurityGroupIngress,
|
||||
handler: handleNATSRequest(daemon.vpcService.AuthorizeSecurityGroupIngress),
|
||||
input: &ec2.AuthorizeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidGroupNotFound,
|
||||
},
|
||||
{
|
||||
name: "AuthorizeSecurityGroupEgress",
|
||||
topic: "ec2.test.AuthorizeSecurityGroupEgress",
|
||||
handler: daemon.handleEC2AuthorizeSecurityGroupEgress,
|
||||
handler: handleNATSRequest(daemon.vpcService.AuthorizeSecurityGroupEgress),
|
||||
input: &ec2.AuthorizeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidGroupNotFound,
|
||||
},
|
||||
{
|
||||
name: "RevokeSecurityGroupIngress",
|
||||
topic: "ec2.test.RevokeSecurityGroupIngress",
|
||||
handler: daemon.handleEC2RevokeSecurityGroupIngress,
|
||||
handler: handleNATSRequest(daemon.vpcService.RevokeSecurityGroupIngress),
|
||||
input: &ec2.RevokeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidGroupNotFound,
|
||||
},
|
||||
{
|
||||
name: "RevokeSecurityGroupEgress",
|
||||
topic: "ec2.test.RevokeSecurityGroupEgress",
|
||||
handler: daemon.handleEC2RevokeSecurityGroupEgress,
|
||||
handler: handleNATSRequest(daemon.vpcService.RevokeSecurityGroupEgress),
|
||||
input: &ec2.RevokeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidGroupNotFound,
|
||||
},
|
||||
{
|
||||
name: "DeleteSecurityGroup",
|
||||
topic: "ec2.test.DeleteSecurityGroup",
|
||||
handler: daemon.handleEC2DeleteSecurityGroup,
|
||||
handler: handleNATSRequest(daemon.vpcService.DeleteSecurityGroup),
|
||||
input: &ec2.DeleteSecurityGroupInput{GroupId: aws.String("sg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidGroupNotFound,
|
||||
},
|
||||
@@ -3737,7 +3757,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(daemon.config, nc)
|
||||
rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(t.Context(), daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.routeTableService = rtbSvc
|
||||
|
||||
@@ -3752,27 +3772,27 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
{
|
||||
name: "CreateRouteTable",
|
||||
topic: "ec2.test.CreateRouteTable",
|
||||
handler: daemon.handleEC2CreateRouteTable,
|
||||
handler: handleNATSRequest(daemon.routeTableService.CreateRouteTable),
|
||||
input: &ec2.CreateRouteTableInput{VpcId: aws.String("vpc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DeleteRouteTable",
|
||||
topic: "ec2.test.DeleteRouteTable",
|
||||
handler: daemon.handleEC2DeleteRouteTable,
|
||||
handler: handleNATSRequest(daemon.routeTableService.DeleteRouteTable),
|
||||
input: &ec2.DeleteRouteTableInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidRouteTableIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "DescribeRouteTables",
|
||||
topic: "ec2.test.DescribeRouteTables",
|
||||
handler: daemon.handleEC2DescribeRouteTables,
|
||||
handler: handleNATSRequest(daemon.routeTableService.DescribeRouteTables),
|
||||
input: &ec2.DescribeRouteTablesInput{},
|
||||
},
|
||||
{
|
||||
name: "CreateRoute",
|
||||
topic: "ec2.test.CreateRoute",
|
||||
handler: daemon.handleEC2CreateRoute,
|
||||
handler: handleNATSRequest(daemon.routeTableService.CreateRoute),
|
||||
input: &ec2.CreateRouteInput{
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||||
@@ -3783,7 +3803,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
{
|
||||
name: "DeleteRoute",
|
||||
topic: "ec2.test.DeleteRoute",
|
||||
handler: daemon.handleEC2DeleteRoute,
|
||||
handler: handleNATSRequest(daemon.routeTableService.DeleteRoute),
|
||||
input: &ec2.DeleteRouteInput{
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||||
@@ -3793,7 +3813,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
{
|
||||
name: "ReplaceRoute",
|
||||
topic: "ec2.test.ReplaceRoute",
|
||||
handler: daemon.handleEC2ReplaceRoute,
|
||||
handler: handleNATSRequest(daemon.routeTableService.ReplaceRoute),
|
||||
input: &ec2.ReplaceRouteInput{
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||||
@@ -3804,7 +3824,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
{
|
||||
name: "AssociateRouteTable",
|
||||
topic: "ec2.test.AssociateRouteTable",
|
||||
handler: daemon.handleEC2AssociateRouteTable,
|
||||
handler: handleNATSRequest(daemon.routeTableService.AssociateRouteTable),
|
||||
input: &ec2.AssociateRouteTableInput{
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
SubnetId: aws.String("subnet-nonexistent"),
|
||||
@@ -3814,14 +3834,14 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
{
|
||||
name: "DisassociateRouteTable",
|
||||
topic: "ec2.test.DisassociateRouteTable",
|
||||
handler: daemon.handleEC2DisassociateRouteTable,
|
||||
handler: handleNATSRequest(daemon.routeTableService.DisassociateRouteTable),
|
||||
input: &ec2.DisassociateRouteTableInput{AssociationId: aws.String("rtbassoc-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidAssociationIDNotFound,
|
||||
},
|
||||
{
|
||||
name: "ReplaceRouteTableAssociation",
|
||||
topic: "ec2.test.ReplaceRouteTableAssociation",
|
||||
handler: daemon.handleEC2ReplaceRouteTableAssociation,
|
||||
handler: handleNATSRequest(daemon.routeTableService.ReplaceRouteTableAssociation),
|
||||
input: &ec2.ReplaceRouteTableAssociationInput{
|
||||
AssociationId: aws.String("rtbassoc-nonexistent"),
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
@@ -3870,7 +3890,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
pgSvc, err := handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(daemon.config, nc)
|
||||
pgSvc, err := handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.placementGroupService = pgSvc
|
||||
|
||||
@@ -3885,7 +3905,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "CreatePlacementGroup",
|
||||
topic: "ec2.test.CreatePlacementGroup",
|
||||
handler: daemon.handleEC2CreatePlacementGroup,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.CreatePlacementGroup),
|
||||
input: &ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("test-pg"),
|
||||
Strategy: aws.String("spread"),
|
||||
@@ -3894,20 +3914,20 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "DescribePlacementGroups",
|
||||
topic: "ec2.test.DescribePlacementGroups",
|
||||
handler: daemon.handleEC2DescribePlacementGroups,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.DescribePlacementGroups),
|
||||
input: &ec2.DescribePlacementGroupsInput{},
|
||||
},
|
||||
{
|
||||
name: "DeletePlacementGroup",
|
||||
topic: "ec2.test.DeletePlacementGroup",
|
||||
handler: daemon.handleEC2DeletePlacementGroup,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.DeletePlacementGroup),
|
||||
input: &ec2.DeletePlacementGroupInput{GroupName: aws.String("pg-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidPlacementGroupUnknown,
|
||||
},
|
||||
{
|
||||
name: "ReserveSpreadNodes",
|
||||
topic: "ec2.test.ReserveSpreadNodes",
|
||||
handler: daemon.handleEC2ReserveSpreadNodes,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.ReserveSpreadNodes),
|
||||
input: &handlers_ec2_placementgroup.ReserveSpreadNodesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
EligibleNodes: []string{"node-1"},
|
||||
@@ -3919,7 +3939,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "FinalizeSpreadInstances",
|
||||
topic: "ec2.test.FinalizeSpreadInstances",
|
||||
handler: daemon.handleEC2FinalizeSpreadInstances,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.FinalizeSpreadInstances),
|
||||
input: &handlers_ec2_placementgroup.FinalizeSpreadInstancesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeInstances: map[string][]string{"node-1": {"i-123"}},
|
||||
@@ -3929,7 +3949,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "ReleaseSpreadNodes",
|
||||
topic: "ec2.test.ReleaseSpreadNodes",
|
||||
handler: daemon.handleEC2ReleaseSpreadNodes,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.ReleaseSpreadNodes),
|
||||
input: &handlers_ec2_placementgroup.ReleaseSpreadNodesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
Nodes: []string{"node-1"},
|
||||
@@ -3939,7 +3959,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "RemoveInstanceFromPlacementGroup",
|
||||
topic: "ec2.test.RemoveInstanceFromPlacementGroup",
|
||||
handler: daemon.handleEC2RemoveInstanceFromPlacementGroup,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.RemoveInstance),
|
||||
input: &handlers_ec2_placementgroup.RemoveInstanceInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeName: "node-1",
|
||||
@@ -3952,7 +3972,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "ReserveClusterNode",
|
||||
topic: "ec2.test.ReserveClusterNode",
|
||||
handler: daemon.handleEC2ReserveClusterNode,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.ReserveClusterNode),
|
||||
input: &handlers_ec2_placementgroup.ReserveClusterNodeInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
EligibleNodes: []string{"node-1"},
|
||||
@@ -3962,7 +3982,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
{
|
||||
name: "FinalizeClusterInstances",
|
||||
topic: "ec2.test.FinalizeClusterInstances",
|
||||
handler: daemon.handleEC2FinalizeClusterInstances,
|
||||
handler: handleNATSRequest(daemon.placementGroupService.FinalizeClusterInstances),
|
||||
input: &handlers_ec2_placementgroup.FinalizeClusterInstancesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeInstances: map[string][]string{"node-1": {"i-123"}},
|
||||
@@ -3995,7 +4015,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
// Create a VPC first
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
|
||||
require.NoError(t, err)
|
||||
defer createVpcSub.Unsubscribe()
|
||||
|
||||
@@ -4019,7 +4039,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
|
||||
{
|
||||
name: "ModifySubnetAttribute",
|
||||
topic: "ec2.test.ModifySubnetAttribute",
|
||||
handler: daemon.handleEC2ModifySubnetAttribute,
|
||||
handler: handleNATSRequest(daemon.vpcService.ModifySubnetAttribute),
|
||||
input: &ec2.ModifySubnetAttributeInput{SubnetId: aws.String("subnet-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
|
||||
},
|
||||
@@ -4029,14 +4049,14 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
|
||||
// the validation contract.
|
||||
name: "ModifyVpcAttribute",
|
||||
topic: "ec2.test.ModifyVpcAttribute",
|
||||
handler: daemon.handleEC2ModifyVpcAttribute,
|
||||
handler: handleNATSRequest(daemon.vpcService.ModifyVpcAttribute),
|
||||
input: &ec2.ModifyVpcAttributeInput{VpcId: aws.String(vpcID)},
|
||||
expectedCode: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
{
|
||||
name: "DescribeVpcAttribute",
|
||||
topic: "ec2.test.DescribeVpcAttribute",
|
||||
handler: daemon.handleEC2DescribeVpcAttribute,
|
||||
handler: handleNATSRequest(daemon.vpcService.DescribeVpcAttribute),
|
||||
input: &ec2.DescribeVpcAttributeInput{
|
||||
VpcId: aws.String(vpcID),
|
||||
Attribute: aws.String("enableDnsSupport"),
|
||||
@@ -4047,7 +4067,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
|
||||
// "exactly one attribute" guard before the ENI lookup runs.
|
||||
name: "ModifyNetworkInterfaceAttribute",
|
||||
topic: "ec2.test.ModifyNetworkInterfaceAttribute",
|
||||
handler: daemon.handleEC2ModifyNetworkInterfaceAttribute,
|
||||
handler: handleNATSRequest(daemon.vpcService.ModifyNetworkInterfaceAttribute),
|
||||
input: &ec2.ModifyNetworkInterfaceAttributeInput{NetworkInterfaceId: aws.String("eni-nonexistent")},
|
||||
expectedCode: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
@@ -139,6 +140,51 @@ func (d *Daemon) handleDetachVolume(ctx context.Context, msg *nats.Msg, command
|
||||
d.respondWithVolumeAttachment(msg, command.DetachVolumeData.VolumeID, command.ID, deviceName, "detaching")
|
||||
}
|
||||
|
||||
// handleDrainVolume flushes the volume's in-flight writes to S3 by dialing the
|
||||
// drain socket the local NBD plugin serves. ec2.CreateSnapshot is answered by
|
||||
// any node in the cluster, but only the node hosting the instance subscribes
|
||||
// ec2.cmd.{instanceID}, so routing the drain here lands it where the writes are.
|
||||
//
|
||||
// Runs on its own goroutine (see handleEC2Events) so a long flush cannot hold
|
||||
// the instance's command subscription.
|
||||
func (d *Daemon) handleDrainVolume(ctx context.Context, msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
|
||||
ctx, span := startOpSpan(ctx, "ec2.DrainVolume", command.ID)
|
||||
var err error
|
||||
defer func() { endOpSpan(span, err) }()
|
||||
|
||||
if command.DrainVolumeData == nil || command.DrainVolumeData.VolumeID == "" {
|
||||
slog.ErrorContext(ctx, "DrainVolume: missing drain volume data", "instanceId", command.ID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
volumeID := command.DrainVolumeData.VolumeID
|
||||
|
||||
// Only stable post-teardown states prove the volume was sealed. Transitional
|
||||
// states still have a live or shutting-down writer, so they must drain or
|
||||
// fail rather than acknowledge an older checkpoint as current.
|
||||
status := d.vmMgr.Status(instance)
|
||||
if status == vm.StateStopped || status == vm.StateTerminated {
|
||||
slog.InfoContext(ctx, "DrainVolume: instance teardown is complete, nothing to drain",
|
||||
"volumeId", volumeID, "instanceId", command.ID, "status", status)
|
||||
respondWithJSON(msg, types.DrainVolumeResponse{VolumeID: volumeID, Status: types.DrainVolumeStatusNotRunning})
|
||||
return
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Draining volume before snapshot", "volumeId", volumeID, "instanceId", command.ID)
|
||||
|
||||
// A missing or unresponsive socket under a running instance means the writes
|
||||
// cannot be made current, so the caller must fail its snapshot rather than
|
||||
// read a stale checkpoint.
|
||||
if err = handlers_ec2_snapshot.DrainVolumeSocket(d.config.DataDir, volumeID); err != nil {
|
||||
slog.ErrorContext(ctx, "DrainVolume: drain failed", "volumeId", volumeID, "instanceId", command.ID, "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithJSON(msg, types.DrainVolumeResponse{VolumeID: volumeID, Status: types.DrainVolumeStatusDrained})
|
||||
}
|
||||
|
||||
// attachDetachErrorCode maps a vm.Manager error returned by AttachVolume
|
||||
// or DetachVolume to the AWS API error code that the SDK expects.
|
||||
func attachDetachErrorCode(err error) string {
|
||||
@@ -160,22 +206,6 @@ func attachDetachErrorCode(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateVolume(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.CreateVolume)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVolumes(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DescribeVolumes)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVolumeStatus(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DescribeVolumeStatus)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVolumesModifications(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DescribeVolumesModifications)
|
||||
}
|
||||
|
||||
// handleEC2ModifyVolume processes incoming EC2 ModifyVolume requests.
|
||||
func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
@@ -205,7 +235,7 @@ func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "handleEC2ModifyVolume service.ModifyVolume failed", "err", err)
|
||||
utils.MarkSpanError(span, err)
|
||||
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
|
||||
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,7 +257,3 @@ func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
|
||||
|
||||
slog.InfoContext(ctx, "handleEC2ModifyVolume completed", "volumeId", modifyVolumeInput.VolumeId)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteVolume(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DeleteVolume)
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/qmp"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
// TestAttachDetachErrorCode locks down the manager-error → AWS-API-code
|
||||
@@ -314,3 +318,254 @@ func TestAttachVolume_IdempotentSameInstance_DeviceMismatch(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(resp.Data), awserrors.ErrorVolumeInUse)
|
||||
}
|
||||
|
||||
// drainCommandFor builds the drain command a snapshot addresses to the node
|
||||
// hosting instanceID.
|
||||
func drainCommandFor(t *testing.T, instanceID, volumeID string) []byte {
|
||||
t.Helper()
|
||||
command := types.EC2InstanceCommand{
|
||||
ID: instanceID,
|
||||
Attributes: types.EC2CommandAttributes{DrainVolume: true},
|
||||
DrainVolumeData: &types.DrainVolumeData{VolumeID: volumeID},
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
// drainTestDaemon returns a daemon owning a running instance, with a data dir
|
||||
// short enough to hold the drain socket path.
|
||||
func drainTestDaemon(t *testing.T, instanceID string) *Daemon {
|
||||
t.Helper()
|
||||
return drainTestDaemonInState(t, instanceID, vm.StateRunning)
|
||||
}
|
||||
|
||||
// drainTestDaemonInState is drainTestDaemon for an instance the node still
|
||||
// holds in some other state.
|
||||
func drainTestDaemonInState(t *testing.T, instanceID string, status vm.InstanceState) *Daemon {
|
||||
t.Helper()
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
daemon.config.DataDir = testutil.SocketTempDir(t)
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: instanceID,
|
||||
Status: status,
|
||||
AccountID: testAccountID,
|
||||
InstanceType: getTestInstanceType(t),
|
||||
Instance: &ec2.Instance{},
|
||||
})
|
||||
return daemon
|
||||
}
|
||||
|
||||
// drainRequest issues a drain on the instance's command subject, returning the
|
||||
// raw reply.
|
||||
func drainRequest(t *testing.T, daemon *Daemon, instanceID, volumeID string) *nats.Msg {
|
||||
t.Helper()
|
||||
reply, err := sendDrainCommand(daemon, instanceID, drainCommandFor(t, instanceID, volumeID))
|
||||
require.NoError(t, err)
|
||||
return reply
|
||||
}
|
||||
|
||||
// sendDrainCommand is drainRequest without the assertions, so a test can issue
|
||||
// one from a goroutine and assert on the result back on the test goroutine.
|
||||
func sendDrainCommand(daemon *Daemon, instanceID string, command []byte) (*nats.Msg, error) {
|
||||
msg := nats.NewMsg("ec2.cmd." + instanceID)
|
||||
msg.Data = command
|
||||
msg.Header.Set(utils.AccountIDHeader, testAccountID)
|
||||
return daemon.natsConn.RequestMsg(msg, 5*time.Second)
|
||||
}
|
||||
|
||||
// subscribeEC2Commands wires the daemon's real dispatcher onto the per-instance
|
||||
// command subject, so tests exercise the same serial delivery production has.
|
||||
func subscribeEC2Commands(t *testing.T, daemon *Daemon, instanceID string) {
|
||||
t.Helper()
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.cmd."+instanceID, daemon.handleEC2Events)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
|
||||
// A drain routed to the node hosting the instance reaches the local socket and
|
||||
// acks, which is what lets a snapshot answered elsewhere read a current
|
||||
// checkpoint.
|
||||
func TestDrainVolume_HostNodeAcksLocalSocket(t *testing.T) {
|
||||
const instanceID, volumeID = "i-drain-ok", "vol-drain-ok"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
var ack types.DrainVolumeResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Data, &ack))
|
||||
assert.Equal(t, volumeID, ack.VolumeID)
|
||||
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
|
||||
}
|
||||
|
||||
// The owning node having no socket for the volume means the writes cannot be
|
||||
// flushed. That must reach the caller as an error, never as a silent success:
|
||||
// the caller would otherwise snapshot a stale checkpoint.
|
||||
func TestDrainVolume_HostNodeWithoutSocketFails(t *testing.T) {
|
||||
const instanceID, volumeID = "i-drain-nosock", "vol-drain-nosock"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
|
||||
}
|
||||
|
||||
// A plugin that refuses the drain (ERR rather than OK) is a failure, not an ack.
|
||||
func TestDrainVolume_HostNodeRelaysSocketFailure(t *testing.T) {
|
||||
const instanceID, volumeID = "i-drain-err", "vol-drain-err"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "ERR\n")
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
|
||||
}
|
||||
|
||||
// A drain command with no volume is a caller error, not a server fault.
|
||||
func TestDrainVolume_MissingVolumeDataIsInvalidParameter(t *testing.T) {
|
||||
const instanceID = "i-drain-novol"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
|
||||
command := types.EC2InstanceCommand{
|
||||
ID: instanceID,
|
||||
Attributes: types.EC2CommandAttributes{DrainVolume: true},
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, data)
|
||||
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, replyErrCode(t, resp.Data))
|
||||
}
|
||||
|
||||
// Only the instance's owner may drain its volume: the command carries the same
|
||||
// ownership gate as every other per-instance command.
|
||||
func TestDrainVolume_ForeignAccountRejected(t *testing.T) {
|
||||
const instanceID, volumeID = "i-drain-foreign", "vol-drain-foreign"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
"999988887777", drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, replyErrCode(t, resp.Data))
|
||||
}
|
||||
|
||||
// Only stable post-teardown states prove the volume is sealed. These states
|
||||
// acknowledge not-running without touching the absent plugin socket.
|
||||
func TestDrainVolume_CompletedTeardownAcksNotRunning(t *testing.T) {
|
||||
for _, status := range []vm.InstanceState{vm.StateStopped, vm.StateTerminated} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
instanceID := "i-drain-" + string(status)
|
||||
volumeID := "vol-drain-" + string(status)
|
||||
daemon := drainTestDaemonInState(t, instanceID, status)
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
var ack types.DrainVolumeResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Data, &ack))
|
||||
assert.Equal(t, volumeID, ack.VolumeID)
|
||||
assert.Equal(t, types.DrainVolumeStatusNotRunning, ack.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Transitional and uncertain states are not proof of a sealed checkpoint. An
|
||||
// absent socket in any of them must fail rather than silently snapshot stale
|
||||
// data while launch or teardown may still own the volume.
|
||||
func TestDrainVolume_UnsealedStatesRequireDrain(t *testing.T) {
|
||||
statuses := []vm.InstanceState{
|
||||
vm.StateRunning,
|
||||
vm.StateStopping,
|
||||
vm.StateShuttingDown,
|
||||
vm.StatePending,
|
||||
vm.StateProvisioning,
|
||||
vm.StateError,
|
||||
}
|
||||
for _, status := range statuses {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
instanceID := "i-drain-" + string(status)
|
||||
volumeID := "vol-drain-" + string(status)
|
||||
daemon := drainTestDaemonInState(t, instanceID, status)
|
||||
|
||||
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
|
||||
testAccountID, drainCommandFor(t, instanceID, volumeID))
|
||||
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nats.go delivers a subscription's messages serially, so a drain run inline
|
||||
// would hold ec2.cmd.{instanceID} for the length of the flush — stalling stop,
|
||||
// terminate and hot-plug for that instance. A drain still flushing must not
|
||||
// keep the next command waiting.
|
||||
func TestDrainVolume_SlowDrainDoesNotBlockTheCommandSubject(t *testing.T) {
|
||||
const instanceID = "i-drain-parallel"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
accepted, release := testutil.StartBlockingDrainSocket(t, daemon.config.DataDir, "vol-drain-slow", "OK\n")
|
||||
testutil.StartDrainSocket(t, daemon.config.DataDir, "vol-drain-fast", "OK\n")
|
||||
subscribeEC2Commands(t, daemon, instanceID)
|
||||
|
||||
type reply struct {
|
||||
msg *nats.Msg
|
||||
err error
|
||||
}
|
||||
slowCommand := drainCommandFor(t, instanceID, "vol-drain-slow")
|
||||
slow := make(chan reply, 1)
|
||||
go func() {
|
||||
msg, err := sendDrainCommand(daemon, instanceID, slowCommand)
|
||||
slow <- reply{msg, err}
|
||||
}()
|
||||
|
||||
// Only assert once the slow drain is genuinely mid-flush.
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the slow drain never reached its socket")
|
||||
}
|
||||
|
||||
fast := drainRequest(t, daemon, instanceID, "vol-drain-fast")
|
||||
var ack types.DrainVolumeResponse
|
||||
require.NoError(t, json.Unmarshal(fast.Data, &ack))
|
||||
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
|
||||
|
||||
release()
|
||||
select {
|
||||
case got := <-slow:
|
||||
require.NoError(t, got.err)
|
||||
require.NoError(t, json.Unmarshal(got.msg.Data, &ack))
|
||||
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the released drain never replied")
|
||||
}
|
||||
}
|
||||
|
||||
// Running the drain off the delivery goroutine must not leak it: the goroutine
|
||||
// has to be gone once the ack is on the wire, or a snapshot loop against a
|
||||
// wedged plugin accumulates them.
|
||||
func TestDrainVolume_DispatchedGoroutineDoesNotLeak(t *testing.T) {
|
||||
const instanceID, volumeID = "i-drain-leak", "vol-drain-leak"
|
||||
daemon := drainTestDaemon(t, instanceID)
|
||||
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
|
||||
subscribeEC2Commands(t, daemon, instanceID)
|
||||
|
||||
// Take the baseline after one full round trip: the first request is what
|
||||
// makes nats.go stand up its long-lived response-inbox goroutine, which
|
||||
// would otherwise read as the leak.
|
||||
var ack types.DrainVolumeResponse
|
||||
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
|
||||
require.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
|
||||
ignoreExisting := goleak.IgnoreCurrent()
|
||||
|
||||
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
|
||||
require.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
|
||||
|
||||
goleak.VerifyNone(t, ignoreExisting)
|
||||
}
|
||||
|
||||
@@ -12,58 +12,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateVpc(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateVpc)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteVpc(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteVpc)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVpcs(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeVpcs)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateSubnet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateSubnet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSubnet(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteSubnet)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSubnets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeSubnets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifySubnetAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifySubnetAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyVpcAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifyVpcAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVpcAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeVpcAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateNetworkInterface)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyNetworkInterfaceAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifyNetworkInterfaceAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
|
||||
}
|
||||
|
||||
// handleAccountCreated creates a default VPC for a newly created account.
|
||||
func (d *Daemon) handleAccountCreated(msg *nats.Msg) {
|
||||
ctx, span := utils.StartConsumerSpan(msg)
|
||||
|
||||
@@ -1,27 +1,107 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// MgmtIPAllocator manages static IP allocation for management NICs on system instances.
|
||||
// IPs are derived from the host's management bridge subnet (.10–.249 range).
|
||||
// Thread-safe via its own mutex — LaunchSystemInstance does not hold a global lock.
|
||||
// mgmtIPAMKeyPrefix namespaces mgmt-IP allocation records within the shared
|
||||
// spinifex-cluster-state KV bucket (see JetStreamManager.UpdateMgmtIPAM). One
|
||||
// record per management subnet, so clusters running distinct br-mgmt CIDRs
|
||||
// never contend on the same key.
|
||||
const mgmtIPAMKeyPrefix = "mgmt-ipam."
|
||||
|
||||
// mgmtCASOuterRetries bounds the outer retry loop Allocate/Release/Rebuild
|
||||
// wrap around UpdateMgmtIPAM. casUpdate already retries a revision conflict
|
||||
// internally (maxCASRetries), but that window only absorbs one overlapping
|
||||
// writer per call; a burst of many nodes or VMs allocating on the same
|
||||
// subnet at once (e.g. several system instances launched together at
|
||||
// cluster bootstrap) can exhaust it. Retrying the whole read-mutate-write
|
||||
// cycle from a fresh Get here converges on success under that contention
|
||||
// instead of surfacing a transient conflict as a hard allocation failure.
|
||||
const mgmtCASOuterRetries = 20
|
||||
|
||||
// updateMgmtIPAMWithRetry wraps jsManager.UpdateMgmtIPAM with the outer
|
||||
// retry described above. Only a revision-conflict exhaustion is retried —
|
||||
// any other error (KV not initialized, context errors, etc.) is returned
|
||||
// immediately.
|
||||
func updateMgmtIPAMWithRetry(jsManager *JetStreamManager, subnet string, mutate func(*MgmtIPRecord), createIfAbsent bool) (*MgmtIPRecord, error) {
|
||||
var lastErr error
|
||||
for range mgmtCASOuterRetries {
|
||||
record, err := jsManager.UpdateMgmtIPAM(subnet, mutate, createIfAbsent)
|
||||
if err == nil {
|
||||
return record, nil
|
||||
}
|
||||
if !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("mgmt IPAM update for subnet %s exhausted %d outer retries under contention: %w",
|
||||
subnet, mgmtCASOuterRetries, lastErr)
|
||||
}
|
||||
|
||||
// MgmtIPEntry records one management IP allocation: the address, the
|
||||
// instance it belongs to, and the node that made the allocation. Node is
|
||||
// carried for audit and for a future reclaim sweep against dead nodes'
|
||||
// heartbeats — it plays no part in allocation itself, which must consider
|
||||
// every entry regardless of which node owns it.
|
||||
type MgmtIPEntry struct {
|
||||
IP string `json:"ip"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
|
||||
// MgmtIPRecord is the KV-CAS record for one management subnet. br-mgmt is
|
||||
// trunked onto a shared physical NIC on the tofu/ansible/baremetal cluster
|
||||
// paths, so the /24 it lives on is one flat L2 segment across every host —
|
||||
// every node allocating on that subnet reads and writes this same record.
|
||||
type MgmtIPRecord struct {
|
||||
Subnet string `json:"subnet"` // e.g. "10.28.8.0/24"
|
||||
Allocated []MgmtIPEntry `json:"allocated"`
|
||||
}
|
||||
|
||||
// MgmtIPAllocator manages static IP allocation for management NICs on
|
||||
// system instances. IPs are derived from the host's management bridge
|
||||
// subnet (.10-.249 range).
|
||||
//
|
||||
// Allocation is backed by a cluster-wide NATS KV compare-and-swap record
|
||||
// once BindKV has been called, so nodes sharing the same br-mgmt L2 segment
|
||||
// never hand out the same address (mulga-f3j2x): every node independently
|
||||
// scanning from .10 was the bug, since br-mgmt is one flat subnet across the
|
||||
// whole cluster, not a per-host range.
|
||||
//
|
||||
// allocated is a write-through local cache mirroring this node's view of the
|
||||
// KV record, so AllocatedCount and lookups for an already-known instance ID
|
||||
// keep working even when KV is unreachable. Allocate refuses to mint a NEW
|
||||
// address in that case — this node's view of the cluster-wide record may be
|
||||
// stale — but a cached instance ID and Release both still work locally.
|
||||
type MgmtIPAllocator struct {
|
||||
mu sync.Mutex
|
||||
allocated map[string]string // instanceID → IP
|
||||
allocated map[string]string // instanceID -> IP (write-through cache)
|
||||
baseIP net.IP // network base (e.g. 10.15.8.0)
|
||||
subnet string // "10.15.8.0/24", the CAS record's key suffix
|
||||
rangeMin byte // first host byte (10)
|
||||
rangeMax byte // last host byte (249)
|
||||
|
||||
// jsManager / node are bound by BindKV once the daemon reaches
|
||||
// startCluster. Both stay nil/empty through startLocal — the DDIL
|
||||
// boundary there (daemon.go's assertNoClusterServicesInitialised) means
|
||||
// startLocal must never touch JetStream KV, so the allocator constructed
|
||||
// there is local-cache-only until BindKV is called.
|
||||
jsManager *JetStreamManager
|
||||
node string
|
||||
}
|
||||
|
||||
// NewMgmtIPAllocator creates an allocator for the /24 subnet of bridgeIP.
|
||||
// Allocates from .10 to .249 (240 addresses).
|
||||
// Allocates from .10 to .249 (240 addresses). The allocator is KV-less until
|
||||
// BindKV is called; see the DDIL boundary note above.
|
||||
func NewMgmtIPAllocator(bridgeIP string) (*MgmtIPAllocator, error) {
|
||||
ip := net.ParseIP(bridgeIP)
|
||||
if ip == nil {
|
||||
@@ -40,70 +120,205 @@ func NewMgmtIPAllocator(bridgeIP string) (*MgmtIPAllocator, error) {
|
||||
return &MgmtIPAllocator{
|
||||
allocated: make(map[string]string),
|
||||
baseIP: base,
|
||||
subnet: fmt.Sprintf("%d.%d.%d.0/24", base[0], base[1], base[2]),
|
||||
rangeMin: 10,
|
||||
rangeMax: 249,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Allocate assigns a management IP to the given instance.
|
||||
// Returns the allocated IP string (e.g. "10.15.8.10").
|
||||
func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
|
||||
// BindKV attaches the cluster-wide KV backing store. Called once from
|
||||
// daemon.startCluster after JetStream is up — never from startLocal (DDIL
|
||||
// §1e-audit). node identifies this node's entries in the shared record for
|
||||
// audit; it plays no part in allocation, which considers every entry in the
|
||||
// record regardless of which node owns it.
|
||||
func (a *MgmtIPAllocator) BindKV(jsManager *JetStreamManager, node string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.jsManager = jsManager
|
||||
a.node = node
|
||||
}
|
||||
|
||||
// Check if already allocated
|
||||
// Allocate assigns a management IP to the given instance.
|
||||
// Returns the allocated IP string (e.g. "10.15.8.10").
|
||||
//
|
||||
// A known instance ID is served from the local cache without touching KV, so
|
||||
// a node that already owns an address can always answer for it. A brand new
|
||||
// allocation requires a healthy cluster KV — this node's cache cannot prove
|
||||
// an address is free cluster-wide on its own — and is refused otherwise.
|
||||
func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
|
||||
a.mu.Lock()
|
||||
if ip, ok := a.allocated[instanceID]; ok {
|
||||
a.mu.Unlock()
|
||||
return ip, nil
|
||||
}
|
||||
jsManager, node, subnet := a.jsManager, a.node, a.subnet
|
||||
a.mu.Unlock()
|
||||
|
||||
// Build set of used IPs for fast lookup
|
||||
used := make(map[string]struct{}, len(a.allocated))
|
||||
for _, ip := range a.allocated {
|
||||
used[ip] = struct{}{}
|
||||
if jsManager == nil || !jsManager.KVHealthy() {
|
||||
return "", fmt.Errorf("mgmt IP allocation unavailable: cluster state KV unreachable for subnet %s", subnet)
|
||||
}
|
||||
|
||||
// Find first free IP in range
|
||||
for i := a.rangeMin; i <= a.rangeMax; i++ {
|
||||
candidate := fmt.Sprintf("%d.%d.%d.%d", a.baseIP[0], a.baseIP[1], a.baseIP[2], i)
|
||||
// mutate has no error return (casUpdate's contract), so exhaustion and
|
||||
// idempotent hits are threaded back out through these closed-over
|
||||
// locals. On either path mutate leaves the record unchanged, so the
|
||||
// CAS write that follows is a harmless no-op against the same revision.
|
||||
var allocatedIP string
|
||||
var mutateErr error
|
||||
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
|
||||
if r.Subnet == "" {
|
||||
r.Subnet = subnet
|
||||
}
|
||||
|
||||
// Idempotent: an instance that already holds an address (allocated
|
||||
// by this node earlier, or by another node before a restart lost
|
||||
// the local cache) keeps it rather than taking a second one.
|
||||
for _, e := range r.Allocated {
|
||||
if e.InstanceID == instanceID {
|
||||
allocatedIP = e.IP
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip, findErr := nextFreeIP(a.baseIP, a.rangeMin, a.rangeMax, r.Allocated)
|
||||
if findErr != nil {
|
||||
mutateErr = findErr
|
||||
return
|
||||
}
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: ip, InstanceID: instanceID, Node: node})
|
||||
allocatedIP = ip
|
||||
}, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mgmt IP CAS update for subnet %s: %w", subnet, err)
|
||||
}
|
||||
if mutateErr != nil {
|
||||
return "", mutateErr
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.allocated[instanceID] = allocatedIP
|
||||
a.mu.Unlock()
|
||||
|
||||
slog.Debug("Management IP allocated", "instance", instanceID, "ip", allocatedIP, "node", node)
|
||||
return allocatedIP, nil
|
||||
}
|
||||
|
||||
// nextFreeIP scans rangeMin-rangeMax on the /24 rooted at baseIP, skipping
|
||||
// every IP already present in allocated regardless of which entry's Node it
|
||||
// belongs to — that is the whole fix for mulga-f3j2x: the range is shared
|
||||
// across the cluster, not partitioned per host.
|
||||
func nextFreeIP(baseIP net.IP, rangeMin, rangeMax byte, allocated []MgmtIPEntry) (string, error) {
|
||||
used := make(map[string]struct{}, len(allocated))
|
||||
for _, e := range allocated {
|
||||
used[e.IP] = struct{}{}
|
||||
}
|
||||
|
||||
for i := rangeMin; i <= rangeMax; i++ {
|
||||
candidate := fmt.Sprintf("%d.%d.%d.%d", baseIP[0], baseIP[1], baseIP[2], i)
|
||||
if _, taken := used[candidate]; !taken {
|
||||
a.allocated[instanceID] = candidate
|
||||
slog.Debug("Management IP allocated", "instance", instanceID, "ip", candidate)
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("management IP range exhausted (%d.%d.%d.%d–%d.%d.%d.%d)",
|
||||
a.baseIP[0], a.baseIP[1], a.baseIP[2], a.rangeMin,
|
||||
a.baseIP[0], a.baseIP[1], a.baseIP[2], a.rangeMax)
|
||||
return "", fmt.Errorf("management IP range exhausted across the cluster (%d.%d.%d.%d-%d.%d.%d.%d, %d addresses)",
|
||||
baseIP[0], baseIP[1], baseIP[2], rangeMin,
|
||||
baseIP[0], baseIP[1], baseIP[2], rangeMax,
|
||||
int(rangeMax-rangeMin)+1)
|
||||
}
|
||||
|
||||
// Release frees the management IP for the given instance.
|
||||
// Release frees the management IP for the given instance, cluster-wide.
|
||||
// Best-effort against KV: the local cache is always updated so this node
|
||||
// never reoffers the address, but a KV write failure is logged rather than
|
||||
// returned — callers (LaunchSystemInstance rollback, CleanupMgmtNetwork)
|
||||
// cannot act on a Release error, matching the pre-existing signature.
|
||||
func (a *MgmtIPAllocator) Release(instanceID string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
ip, hadLocal := a.allocated[instanceID]
|
||||
delete(a.allocated, instanceID)
|
||||
jsManager, subnet := a.jsManager, a.subnet
|
||||
a.mu.Unlock()
|
||||
|
||||
if ip, ok := a.allocated[instanceID]; ok {
|
||||
delete(a.allocated, instanceID)
|
||||
slog.Debug("Management IP released", "instance", instanceID, "ip", ip)
|
||||
if hadLocal {
|
||||
slog.Debug("Management IP released locally", "instance", instanceID, "ip", ip)
|
||||
}
|
||||
|
||||
if jsManager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// createIfAbsent=false: releasing an instance this node never allocated
|
||||
// (or that no node has a record for) must not conjure an empty record
|
||||
// into existence — jetstream.ErrKeyNotFound just means there was nothing to
|
||||
// release.
|
||||
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
|
||||
for i, e := range r.Allocated {
|
||||
if e.InstanceID == instanceID {
|
||||
r.Allocated = append(r.Allocated[:i], r.Allocated[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}, false)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
slog.Warn("Failed to release mgmt IP in cluster KV; address remains reserved until reconciled",
|
||||
"instance", instanceID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild reconstructs the allocated set from existing VMs.
|
||||
// Called on daemon startup after loading VMs from KV store.
|
||||
// Rebuild reconciles the allocator against vms (this node's own VM
|
||||
// snapshot). It always refreshes the local write-through cache first, then
|
||||
// — once KV is bound (see BindKV) and healthy — CAS-inserts an entry for
|
||||
// every local VM carrying a MgmtIP, idempotent by instance ID. This never
|
||||
// removes another node's entries: reclaiming addresses of a dead node is a
|
||||
// separate concern (tracked apart from mulga-f3j2x).
|
||||
//
|
||||
// Called on daemon startup (startLocal, cache-only since KV isn't bound yet)
|
||||
// and again after cluster bootstrap (startCluster, once BindKV has run) so
|
||||
// already-allocated IPs are never handed out a second time.
|
||||
func (a *MgmtIPAllocator) Rebuild(vms map[string]*vm.VM) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for id, v := range vms {
|
||||
if v.MgmtIP != "" {
|
||||
a.allocated[id] = v.MgmtIP
|
||||
}
|
||||
}
|
||||
count := len(a.allocated)
|
||||
jsManager, node, subnet := a.jsManager, a.node, a.subnet
|
||||
a.mu.Unlock()
|
||||
|
||||
slog.Info("Management IP allocator rebuilt", "count", len(a.allocated))
|
||||
slog.Info("Management IP allocator rebuilt", "count", count)
|
||||
|
||||
if jsManager == nil {
|
||||
// startLocal: no cluster KV yet, cache-only rebuild.
|
||||
return
|
||||
}
|
||||
if !jsManager.KVHealthy() {
|
||||
slog.Warn("Skipping mgmt IP KV reconcile: cluster state KV unreachable", "subnet", subnet)
|
||||
return
|
||||
}
|
||||
|
||||
for id, v := range vms {
|
||||
if v.MgmtIP == "" {
|
||||
continue
|
||||
}
|
||||
mgmtIP := v.MgmtIP
|
||||
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
|
||||
if r.Subnet == "" {
|
||||
r.Subnet = subnet
|
||||
}
|
||||
for _, e := range r.Allocated {
|
||||
if e.InstanceID == id {
|
||||
return // already reconciled
|
||||
}
|
||||
}
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: mgmtIP, InstanceID: id, Node: node})
|
||||
}, true)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to reconcile mgmt IP into cluster KV", "instance", id, "ip", mgmtIP, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AllocatedCount returns the number of currently allocated IPs.
|
||||
// AllocatedCount returns the number of currently allocated IPs known to this
|
||||
// node's local cache.
|
||||
func (a *MgmtIPAllocator) AllocatedCount() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// newTestMgmtJSM returns a JetStreamManager backed by the shared per-package
|
||||
// JetStream test server, with the cluster-state bucket initialised — the
|
||||
// same bucket UpdateMgmtIPAM writes into (namespaced under "mgmt-ipam.").
|
||||
func newTestMgmtJSM(t *testing.T) *JetStreamManager {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect NATS: %v", err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("new JetStreamManager: %v", err)
|
||||
}
|
||||
if err := jsm.InitClusterStateBucket(); err != nil {
|
||||
t.Fatalf("init cluster state bucket: %v", err)
|
||||
}
|
||||
return jsm
|
||||
}
|
||||
|
||||
// cleanupMgmtIPAM deletes the mgmt-ipam record for a's subnet at test end.
|
||||
// The cluster-state bucket is shared by the whole package's test binary, so
|
||||
// without this a record left behind by one test (or one -count repetition)
|
||||
// would leak into the next test/run that reuses the same bridge subnet.
|
||||
func cleanupMgmtIPAM(t *testing.T, jsm *JetStreamManager, a *MgmtIPAllocator) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
_ = jsm.clusterKV.Delete(context.Background(), mgmtIPAMKeyPrefix+a.subnet)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewMgmtIPAllocator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -40,6 +75,10 @@ func TestNewMgmtIPAllocator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Allocate exercises the same scan-from-.10 contract as
|
||||
// before the KV rewrite, but now through a bound cluster KV — Allocate
|
||||
// refuses new addresses without one (see fail-closed tests below), so every
|
||||
// allocator under test here binds one first.
|
||||
func TestMgmtIPAllocator_Allocate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -52,10 +91,13 @@ func TestMgmtIPAllocator_Allocate(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
a, err := NewMgmtIPAllocator(tt.bridgeIP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-a")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
ip, err := a.Allocate("i-first")
|
||||
if err != nil {
|
||||
@@ -90,10 +132,13 @@ func TestMgmtIPAllocator_Allocate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMgmtIPAllocator_Release(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.15.8.1")
|
||||
jsm := newTestMgmtJSM(t)
|
||||
a, err := NewMgmtIPAllocator("10.16.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-a")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
a.Allocate("i-one")
|
||||
a.Allocate("i-two")
|
||||
@@ -108,11 +153,14 @@ func TestMgmtIPAllocator_Release(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip != "10.15.8.10" {
|
||||
t.Errorf("reused IP = %q, want 10.15.8.10", ip)
|
||||
if ip != "10.16.8.10" {
|
||||
t.Errorf("reused IP = %q, want 10.16.8.10", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_ReleaseNonexistent asserts Release never panics or
|
||||
// blocks on an instance ID with no allocation — including with no KV bound
|
||||
// at all, the shape startLocal constructs.
|
||||
func TestMgmtIPAllocator_ReleaseNonexistent(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.15.8.1")
|
||||
if err != nil {
|
||||
@@ -123,12 +171,15 @@ func TestMgmtIPAllocator_ReleaseNonexistent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.15.8.1")
|
||||
jsm := newTestMgmtJSM(t)
|
||||
a, err := NewMgmtIPAllocator("10.17.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-a")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
// Fill all 240 slots (.10–.249)
|
||||
// Fill all 240 slots (.10-.249)
|
||||
for i := range 240 {
|
||||
_, err := a.Allocate(fmt.Sprintf("i-%d", i))
|
||||
if err != nil {
|
||||
@@ -136,11 +187,14 @@ func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Next should fail
|
||||
// Next should fail, naming the cluster rather than a host-local limit.
|
||||
_, err = a.Allocate("i-overflow")
|
||||
if err == nil {
|
||||
t.Fatal("expected exhaustion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exhausted across the cluster") {
|
||||
t.Errorf("exhaustion error = %q, want it to name the cluster", err.Error())
|
||||
}
|
||||
|
||||
// Release one, then it should work
|
||||
a.Release("i-0")
|
||||
@@ -148,22 +202,28 @@ func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("after release: %v", err)
|
||||
}
|
||||
if ip != "10.15.8.10" {
|
||||
t.Errorf("reused IP = %q, want 10.15.8.10", ip)
|
||||
if ip != "10.17.8.10" {
|
||||
t.Errorf("reused IP = %q, want 10.17.8.10", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Rebuild binds KV before Rebuild so it exercises the
|
||||
// reconcile path (CAS-inserting local VMs into the shared record), not just
|
||||
// the local-cache refresh that runs unbound in startLocal.
|
||||
func TestMgmtIPAllocator_Rebuild(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.15.8.1")
|
||||
jsm := newTestMgmtJSM(t)
|
||||
a, err := NewMgmtIPAllocator("10.18.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-a")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
vms := map[string]*vm.VM{
|
||||
"i-a": {MgmtIP: "10.15.8.10"},
|
||||
"i-b": {MgmtIP: "10.15.8.15"},
|
||||
"i-a": {MgmtIP: "10.18.8.10"},
|
||||
"i-b": {MgmtIP: "10.18.8.15"},
|
||||
"i-c": {MgmtIP: ""}, // no mgmt NIC
|
||||
"i-d": {MgmtIP: "10.15.8.20"},
|
||||
"i-d": {MgmtIP: "10.18.8.20"},
|
||||
}
|
||||
|
||||
a.Rebuild(vms)
|
||||
@@ -177,16 +237,46 @@ func TestMgmtIPAllocator_Rebuild(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip != "10.15.8.11" {
|
||||
t.Errorf("next IP after rebuild = %q, want 10.15.8.11", ip)
|
||||
if ip != "10.18.8.11" {
|
||||
t.Errorf("next IP after rebuild = %q, want 10.18.8.11", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Rebuild_UnboundIsCacheOnly is the startLocal shape:
|
||||
// Rebuild with no KV bound must only refresh the local cache and must never
|
||||
// panic or block waiting on a cluster that doesn't exist yet.
|
||||
func TestMgmtIPAllocator_Rebuild_UnboundIsCacheOnly(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.19.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vms := map[string]*vm.VM{
|
||||
"i-a": {MgmtIP: "10.19.8.10"},
|
||||
}
|
||||
a.Rebuild(vms)
|
||||
|
||||
if a.AllocatedCount() != 1 {
|
||||
t.Errorf("count after unbound rebuild = %d, want 1", a.AllocatedCount())
|
||||
}
|
||||
// A cached ID still resolves without KV.
|
||||
ip, err := a.Allocate("i-a")
|
||||
if err != nil {
|
||||
t.Fatalf("cached instance should resolve without KV: %v", err)
|
||||
}
|
||||
if ip != "10.19.8.10" {
|
||||
t.Errorf("cached IP = %q, want 10.19.8.10", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMgmtIPAllocator_Concurrent(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.15.8.1")
|
||||
jsm := newTestMgmtJSM(t)
|
||||
a, err := NewMgmtIPAllocator("10.20.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-a")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make(map[string]string)
|
||||
@@ -226,3 +316,304 @@ func TestMgmtIPAllocator_Concurrent(t *testing.T) {
|
||||
seen[ip] = true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cluster-safety coverage (mulga-f3j2x) ---
|
||||
|
||||
// TestMgmtIPAllocator_SharedKV_NoDuplicates is the core regression test:
|
||||
// two allocators standing in for two nodes, sharing one KV, must never both
|
||||
// hand out the same address even though each scans from .10 independently.
|
||||
func TestMgmtIPAllocator_SharedKV_NoDuplicates(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
node1, err := NewMgmtIPAllocator("10.21.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, node1)
|
||||
|
||||
node2, err := NewMgmtIPAllocator("10.21.8.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node2.BindKV(jsm, "node-2")
|
||||
|
||||
seen := make(map[string]string) // ip -> owning instance
|
||||
allocate := func(a *MgmtIPAllocator, id string) {
|
||||
ip, err := a.Allocate(id)
|
||||
if err != nil {
|
||||
t.Fatalf("allocate %s: %v", id, err)
|
||||
}
|
||||
if owner, dup := seen[ip]; dup {
|
||||
t.Fatalf("duplicate address %s: held by %s and %s", ip, owner, id)
|
||||
}
|
||||
seen[ip] = id
|
||||
}
|
||||
|
||||
// Alternate allocation across the two "nodes" — each locally believes it
|
||||
// is scanning a fresh range from .10, but the shared KV record must
|
||||
// still serialise them onto distinct addresses.
|
||||
for i := range 10 {
|
||||
allocate(node1, fmt.Sprintf("node1-i-%d", i))
|
||||
allocate(node2, fmt.Sprintf("node2-i-%d", i))
|
||||
}
|
||||
|
||||
if len(seen) != 20 {
|
||||
t.Errorf("got %d unique addresses, want 20", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_ConcurrentAcrossNodes fires concurrent allocations
|
||||
// from three simulated nodes against the same KV record, forcing genuine
|
||||
// CAS conflicts (not just the happy path) as their Get/mutate/Update
|
||||
// windows overlap. Every address handed out across all three must be
|
||||
// unique — this is the exact shape of CI run 29912697556, where independent
|
||||
// per-node allocation raced onto the same br-mgmt address.
|
||||
func TestMgmtIPAllocator_ConcurrentAcrossNodes(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
const nodeCount = 3
|
||||
const perNode = 20
|
||||
nodes := make([]*MgmtIPAllocator, nodeCount)
|
||||
for n := range nodeCount {
|
||||
a, err := NewMgmtIPAllocator("10.22.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, fmt.Sprintf("node-%d", n))
|
||||
if n == 0 {
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
}
|
||||
nodes[n] = a
|
||||
}
|
||||
|
||||
type result struct {
|
||||
id string
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, nodeCount*perNode)
|
||||
var wg sync.WaitGroup
|
||||
for n := range nodeCount {
|
||||
for i := range perNode {
|
||||
wg.Add(1)
|
||||
go func(n, i int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("node%d-i-%d", n, i)
|
||||
ip, err := nodes[n].Allocate(id)
|
||||
results <- result{id: id, ip: ip, err: err}
|
||||
}(n, i)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
seen := make(map[string]string)
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
t.Fatalf("allocate %s: %v", r.id, r.err)
|
||||
}
|
||||
if owner, dup := seen[r.ip]; dup {
|
||||
t.Fatalf("duplicate address %s: held by %s and %s", r.ip, owner, r.id)
|
||||
}
|
||||
seen[r.ip] = r.id
|
||||
}
|
||||
if len(seen) != nodeCount*perNode {
|
||||
t.Errorf("got %d unique addresses, want %d", len(seen), nodeCount*perNode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Allocate_IdempotentAcrossNodes proves idempotency is a
|
||||
// property of the shared KV record, not of one allocator's local cache: a
|
||||
// second allocator (standing in for the instance's owning node restarting
|
||||
// inside a partition and losing its cache) re-requesting a known instance ID
|
||||
// gets back the same address rather than a new one.
|
||||
func TestMgmtIPAllocator_Allocate_IdempotentAcrossNodes(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
node1, err := NewMgmtIPAllocator("10.23.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, node1)
|
||||
|
||||
ip, err := node1.Allocate("i-reattach")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A second allocator, simulating a fresh process on the same node after
|
||||
// a restart with an empty local cache.
|
||||
node1Restarted, err := NewMgmtIPAllocator("10.23.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1Restarted.BindKV(jsm, "node-1")
|
||||
|
||||
ip2, err := node1Restarted.Allocate("i-reattach")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip2 != ip {
|
||||
t.Errorf("re-allocate from fresh allocator = %q, want %q (idempotent by instance ID)", ip2, ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Release_FreesAddressClusterWide proves Release's
|
||||
// effect is visible cluster-wide: a second allocator, not the one that
|
||||
// released the address, can immediately take it.
|
||||
func TestMgmtIPAllocator_Release_FreesAddressClusterWide(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
node1, err := NewMgmtIPAllocator("10.24.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, node1)
|
||||
|
||||
node2, err := NewMgmtIPAllocator("10.24.8.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node2.BindKV(jsm, "node-2")
|
||||
|
||||
ip, err := node1.Allocate("i-node1-only")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1.Release("i-node1-only")
|
||||
|
||||
// node2 must now be able to take the address node1 just released.
|
||||
ip2, err := node2.Allocate("i-node2-new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip2 != ip {
|
||||
t.Errorf("node2 got %q, want the released address %q", ip2, ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Exhaustion_ClusterWide proves the exhaustion check is
|
||||
// against the cluster-wide record, not this allocator's own local cache:
|
||||
// two allocators together hold all 240 addresses (120 each, so neither
|
||||
// locally believes the range is full) and a third allocation from either
|
||||
// must still fail.
|
||||
func TestMgmtIPAllocator_Exhaustion_ClusterWide(t *testing.T) {
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
node1, err := NewMgmtIPAllocator("10.25.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node1.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, node1)
|
||||
|
||||
node2, err := NewMgmtIPAllocator("10.25.8.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node2.BindKV(jsm, "node-2")
|
||||
|
||||
for i := range 120 {
|
||||
if _, err := node1.Allocate(fmt.Sprintf("node1-i-%d", i)); err != nil {
|
||||
t.Fatalf("node1 allocation %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
for i := range 120 {
|
||||
if _, err := node2.Allocate(fmt.Sprintf("node2-i-%d", i)); err != nil {
|
||||
t.Fatalf("node2 allocation %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Each allocator's local cache only knows about its own 120 — but the
|
||||
// cluster-wide record holds all 240, so the next allocation from either
|
||||
// must fail.
|
||||
if node1.AllocatedCount() != 120 {
|
||||
t.Fatalf("node1 local cache = %d, want 120 (proves the check isn't local)", node1.AllocatedCount())
|
||||
}
|
||||
if _, err := node1.Allocate("i-overflow-from-node1"); err == nil {
|
||||
t.Fatal("expected exhaustion error from node1")
|
||||
}
|
||||
if _, err := node2.Allocate("i-overflow-from-node2"); err == nil {
|
||||
t.Fatal("expected exhaustion error from node2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Allocate_RefusedWhenKVUnhealthy is the fail-closed
|
||||
// contract: once KV is unreachable, a brand new allocation is refused, but
|
||||
// an instance ID already resolved (and cached) before the outage keeps
|
||||
// resolving from the local cache without touching KV at all.
|
||||
func TestMgmtIPAllocator_Allocate_RefusedWhenKVUnhealthy(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jsm.InitClusterStateBucket(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, err := NewMgmtIPAllocator("10.26.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-1")
|
||||
|
||||
ip, err := a.Allocate("i-known")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sever the connection backing this JetStreamManager: KVHealthy's
|
||||
// AccountInfo round-trip will now fail, simulating a partition.
|
||||
nc.Close()
|
||||
|
||||
if jsm.KVHealthy() {
|
||||
t.Fatal("expected KVHealthy to report false after closing the connection")
|
||||
}
|
||||
|
||||
// A known instance ID still resolves — served entirely from the local
|
||||
// cache, no KV round-trip required.
|
||||
cachedIP, err := a.Allocate("i-known")
|
||||
if err != nil {
|
||||
t.Fatalf("cached instance should resolve without KV: %v", err)
|
||||
}
|
||||
if cachedIP != ip {
|
||||
t.Errorf("cached IP = %q, want %q", cachedIP, ip)
|
||||
}
|
||||
|
||||
// A brand new allocation must be refused rather than risk a duplicate
|
||||
// against a cluster-wide record this node can no longer trust.
|
||||
if _, err := a.Allocate("i-new-during-partition"); err == nil {
|
||||
t.Fatal("expected allocation to be refused while KV is unhealthy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Allocate_RefusedWhenKVNeverBound is the startLocal
|
||||
// shape: an allocator with no KV bound at all (as constructed before
|
||||
// startCluster runs) must refuse new allocations exactly like an unhealthy
|
||||
// bound KV, while still serving whatever Rebuild already cached.
|
||||
func TestMgmtIPAllocator_Allocate_RefusedWhenKVNeverBound(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.27.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Rebuild(map[string]*vm.VM{"i-known": {MgmtIP: "10.27.8.10"}})
|
||||
|
||||
ip, err := a.Allocate("i-known")
|
||||
if err != nil {
|
||||
t.Fatalf("cached instance should resolve without KV: %v", err)
|
||||
}
|
||||
if ip != "10.27.8.10" {
|
||||
t.Errorf("cached IP = %q, want 10.27.8.10", ip)
|
||||
}
|
||||
|
||||
if _, err := a.Allocate("i-brand-new"); err == nil {
|
||||
t.Fatal("expected allocation to be refused with no KV bound")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,11 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
|
||||
// right NIC for the Ec2 datasource on its own. The instance is owned by
|
||||
// input.AccountID (the account its pre-created ENI lives in) and tagged
|
||||
// input.ManagedBy so customer listings hide it.
|
||||
//
|
||||
// Sharing the Prepare/Launch pair with RunInstances means a GPU instance type
|
||||
// gets the same admission, GPU claim and teardown-time release as a customer
|
||||
// VM: PrepareRunInstances gates on a free GPU slot, LaunchRunInstances claims
|
||||
// the device, and vm.Manager's cleanup chain releases it.
|
||||
func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error) {
|
||||
if d.instanceService == nil {
|
||||
return nil, errors.New("sysinstance: instance service not initialized")
|
||||
@@ -499,6 +504,10 @@ func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput)
|
||||
|
||||
d.instanceService.LaunchRunInstances(context.Background(), instances, runInput, instanceType)
|
||||
|
||||
if err := d.verifySystemInstanceLaunched(inst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
privateIP := input.ENIIP
|
||||
if privateIP == "" && inst.Instance != nil {
|
||||
privateIP = aws.StringValue(inst.Instance.PrivateIpAddress)
|
||||
@@ -517,6 +526,21 @@ func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifySystemInstanceLaunched checks that inst actually reached running state
|
||||
// after LaunchRunInstances. That call is a best-effort batch launcher: volume
|
||||
// preparation, GPU claim and vmMgr.Run failures each mark the instance failed
|
||||
// and return nothing, so the outcome is only visible in the VM's state. Without
|
||||
// this check a system-instance caller (EKS control plane, ELB, Bedrock serving
|
||||
// VM) receives an instance ID for a VM already being torn down and waits on an
|
||||
// endpoint that will never answer. Cleanup is left to the MarkFailed chain
|
||||
// LaunchRunInstances already ran.
|
||||
func (d *Daemon) verifySystemInstanceLaunched(inst *vm.VM) error {
|
||||
if status := d.vmMgr.Status(inst); status != vm.StateRunning {
|
||||
return fmt.Errorf("sysinstance: instance %s did not reach running state (status %q)", inst.ID, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachSystemMgmtNIC allocates a management-bridge IP + MAC for a system VM and
|
||||
// creates the host-side tap on br-mgmt. The mgmt NIC is mandatory for system
|
||||
// instances that boot from an AMI: their VPC ENI has no route off the tenant
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -84,6 +86,50 @@ func TestWaitForSystemInstance_Timeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verifySystemInstanceLaunched
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVerifySystemInstanceLaunched_Running(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-up", Status: vm.StateRunning}
|
||||
d := newDaemonWithVMs(inst)
|
||||
|
||||
require.NoError(t, d.verifySystemInstanceLaunched(inst))
|
||||
}
|
||||
|
||||
// LaunchRunInstances swallows launch errors and leaves the instance in a
|
||||
// cleanup state, so every non-running state must surface as a launch failure —
|
||||
// a GPU claim failure marks the VM shutting-down via MarkFailed.
|
||||
func TestVerifySystemInstanceLaunched_NonRunningStatesFail(t *testing.T) {
|
||||
for _, status := range []vm.InstanceState{
|
||||
vm.StateShuttingDown,
|
||||
vm.StateError,
|
||||
vm.StateTerminated,
|
||||
vm.StatePending,
|
||||
vm.StateProvisioning,
|
||||
} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-down", Status: status}
|
||||
d := newDaemonWithVMs(inst)
|
||||
|
||||
err := d.verifySystemInstanceLaunched(inst)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "i-down")
|
||||
assert.Contains(t, err.Error(), string(status))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MarkFailed transitions synchronously before running its cleanup goroutine, so
|
||||
// the check sees the failure without racing the teardown.
|
||||
func TestVerifySystemInstanceLaunched_AfterMarkFailedIsAnError(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-gpu-claim-failed", Status: vm.StateProvisioning}
|
||||
d := newDaemonWithVMs(inst)
|
||||
d.vmMgr.MarkFailed(context.Background(), inst, "gpu_claim_failed")
|
||||
|
||||
require.Error(t, d.verifySystemInstanceLaunched(inst))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildNetcfgBlob
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -592,9 +638,9 @@ func TestRefreshSystemInstanceState_RewritesBlobs(t *testing.T) {
|
||||
d.elbv2Service.GatewayURL = "https://10.0.0.1:9999"
|
||||
d.elbv2Service.CACert = "-----BEGIN CERTIFICATE-----\nfake-ca\n-----END CERTIFICATE-----\n"
|
||||
|
||||
seedStore, err := handlers_elbv2.NewStore(nc)
|
||||
seedStore, err := handlers_elbv2.NewStore(t.Context(), nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, seedStore.PutLoadBalancer(&handlers_elbv2.LoadBalancerRecord{
|
||||
require.NoError(t, seedStore.PutLoadBalancer(t.Context(), &handlers_elbv2.LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-recover",
|
||||
Name: "recover-alb",
|
||||
Scheme: handlers_elbv2.SchemeInternal,
|
||||
@@ -638,9 +684,9 @@ func TestRefreshSystemInstanceState_RewritesBlobs(t *testing.T) {
|
||||
func TestRefreshSystemInstanceState_WriteErrorPropagates(t *testing.T) {
|
||||
d, nc, tmpDir := newRefreshTestDaemon(t)
|
||||
|
||||
seedStore, err := handlers_elbv2.NewStore(nc)
|
||||
seedStore, err := handlers_elbv2.NewStore(t.Context(), nc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, seedStore.PutLoadBalancer(&handlers_elbv2.LoadBalancerRecord{
|
||||
require.NoError(t, seedStore.PutLoadBalancer(t.Context(), &handlers_elbv2.LoadBalancerRecord{
|
||||
LoadBalancerID: "lb-werr",
|
||||
Name: "werr-alb",
|
||||
Scheme: handlers_elbv2.SchemeInternal,
|
||||
@@ -702,9 +748,9 @@ func TestLaunchSystemInstance_NATFailureRollsBackPublicIP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { jsNC.Close() })
|
||||
|
||||
js, err := jsNC.JetStream()
|
||||
js, err := jetstream.New(jsNC)
|
||||
require.NoError(t, err)
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
|
||||
{Name: "wan-test", RangeStart: "203.0.113.10", RangeEnd: "203.0.113.20", Gateway: "203.0.113.1", PrefixLen: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -824,15 +870,15 @@ func TestReleaseSystemInstanceEIP_ReleasesEipServiceAllocation(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { jsNC.Close() })
|
||||
|
||||
js, err := jsNC.JetStream()
|
||||
js, err := jetstream.New(jsNC)
|
||||
require.NoError(t, err)
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
|
||||
{Name: "wan-test", RangeStart: "203.0.113.10", RangeEnd: "203.0.113.20", Gateway: "203.0.113.1", PrefixLen: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
d.externalIPAM = ipam
|
||||
|
||||
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(jsNC, ipam, d.vpcService)
|
||||
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(t.Context(), jsNC, ipam, d.vpcService)
|
||||
require.NoError(t, err)
|
||||
d.eipService = eipSvc
|
||||
|
||||
|
||||
+169
-226
@@ -2,6 +2,7 @@ package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -51,7 +52,9 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -384,7 +387,7 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
|
||||
daemon := createTestDaemon(t, natsURL)
|
||||
|
||||
// Subscribe to DescribeInstanceTypes (no queue group for fan-out)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceTypes", daemon.handleEC2DescribeInstanceTypes)
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceTypes", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
|
||||
require.NoError(t, err, "Failed to subscribe to ec2.DescribeInstanceTypes")
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
@@ -1018,197 +1021,6 @@ func TestAllocate_NoOvercommitUnderContention(t *testing.T) {
|
||||
assert.InDelta(t, memGB*float64(capacityFor), finalMem, 0.001, "allocated memory must not exceed schedulable pool")
|
||||
}
|
||||
|
||||
// TestDescribeInstances_ReservationGrouping tests that instances are grouped by reservation ID.
|
||||
func TestDescribeInstances_ReservationGrouping(t *testing.T) {
|
||||
natsURL := sharedNATSURL
|
||||
|
||||
daemon := createTestDaemon(t, natsURL)
|
||||
|
||||
// Create instances with shared reservation (simulating --count 3)
|
||||
reservation1 := &ec2.Reservation{}
|
||||
reservation1.SetReservationId("r-shared-001")
|
||||
reservation1.SetOwnerId("123456789012")
|
||||
|
||||
// Add 3 instances with same reservation ID
|
||||
for i := 1; i <= 3; i++ {
|
||||
instanceID := fmt.Sprintf("i-group1-%03d", i)
|
||||
ec2Instance := &ec2.Instance{}
|
||||
ec2Instance.SetInstanceId(instanceID)
|
||||
ec2Instance.SetInstanceType("t3.micro")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Reservation: reservation1,
|
||||
Instance: ec2Instance,
|
||||
})
|
||||
}
|
||||
|
||||
// Create another reservation with 2 instances
|
||||
reservation2 := &ec2.Reservation{}
|
||||
reservation2.SetReservationId("r-shared-002")
|
||||
reservation2.SetOwnerId("123456789012")
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
instanceID := fmt.Sprintf("i-group2-%03d", i)
|
||||
ec2Instance := &ec2.Instance{}
|
||||
ec2Instance.SetInstanceId(instanceID)
|
||||
ec2Instance.SetInstanceType("t3.small")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
AccountID: testAccountID,
|
||||
Reservation: reservation2,
|
||||
Instance: ec2Instance,
|
||||
})
|
||||
}
|
||||
|
||||
// Create a single-instance reservation
|
||||
reservation3 := &ec2.Reservation{}
|
||||
reservation3.SetReservationId("r-single-003")
|
||||
reservation3.SetOwnerId("123456789012")
|
||||
|
||||
ec2Instance := &ec2.Instance{}
|
||||
ec2Instance.SetInstanceId("i-single-001")
|
||||
ec2Instance.SetInstanceType("t3.large")
|
||||
|
||||
daemon.vmMgr.Insert(&vm.VM{
|
||||
ID: "i-single-001",
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
Reservation: reservation3,
|
||||
Instance: ec2Instance,
|
||||
})
|
||||
|
||||
// Subscribe to handle DescribeInstances
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
t.Run("GroupsInstancesByReservationID", func(t *testing.T) {
|
||||
input := &ec2.DescribeInstancesInput{}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
err = json.Unmarshal(resp.Data, &output)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have exactly 3 reservations
|
||||
assert.Len(t, output.Reservations, 3, "Should have 3 reservations")
|
||||
|
||||
// Build a map of reservation ID -> instance count
|
||||
resMap := make(map[string]int)
|
||||
for _, res := range output.Reservations {
|
||||
resID := *res.ReservationId
|
||||
resMap[resID] = len(res.Instances)
|
||||
t.Logf("Reservation %s has %d instances", resID, len(res.Instances))
|
||||
}
|
||||
|
||||
assert.Equal(t, 3, resMap["r-shared-001"], "r-shared-001 should have 3 instances")
|
||||
assert.Equal(t, 2, resMap["r-shared-002"], "r-shared-002 should have 2 instances")
|
||||
assert.Equal(t, 1, resMap["r-single-003"], "r-single-003 should have 1 instance")
|
||||
})
|
||||
|
||||
t.Run("FilterByInstanceID_PreservesReservation", func(t *testing.T) {
|
||||
// Request only one instance from a multi-instance reservation
|
||||
input := &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String("i-group1-001")},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
err = json.Unmarshal(resp.Data, &output)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 1 reservation with 1 instance
|
||||
require.Len(t, output.Reservations, 1)
|
||||
assert.Equal(t, "r-shared-001", *output.Reservations[0].ReservationId)
|
||||
assert.Len(t, output.Reservations[0].Instances, 1)
|
||||
assert.Equal(t, "i-group1-001", *output.Reservations[0].Instances[0].InstanceId)
|
||||
})
|
||||
|
||||
t.Run("FilterMultipleInstances_SameReservation", func(t *testing.T) {
|
||||
// Request 2 instances from the same reservation
|
||||
input := &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{
|
||||
aws.String("i-group1-001"),
|
||||
aws.String("i-group1-003"),
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
err = json.Unmarshal(resp.Data, &output)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 1 reservation with 2 instances
|
||||
require.Len(t, output.Reservations, 1)
|
||||
assert.Equal(t, "r-shared-001", *output.Reservations[0].ReservationId)
|
||||
assert.Len(t, output.Reservations[0].Instances, 2)
|
||||
})
|
||||
|
||||
t.Run("FilterMultipleInstances_DifferentReservations", func(t *testing.T) {
|
||||
// Request instances from different reservations
|
||||
input := &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{
|
||||
aws.String("i-group1-001"),
|
||||
aws.String("i-group2-001"),
|
||||
aws.String("i-single-001"),
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
err = json.Unmarshal(resp.Data, &output)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 3 reservations, each with 1 instance
|
||||
assert.Len(t, output.Reservations, 3)
|
||||
for _, res := range output.Reservations {
|
||||
assert.Len(t, res.Instances, 1, "Each reservation should have 1 instance when filtered")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InstanceStates_AreCorrect", func(t *testing.T) {
|
||||
input := &ec2.DescribeInstancesInput{}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstancesOutput
|
||||
err = json.Unmarshal(resp.Data, &output)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the stopped instance and verify its state
|
||||
for _, res := range output.Reservations {
|
||||
for _, inst := range res.Instances {
|
||||
if *inst.InstanceId == "i-single-001" {
|
||||
assert.Equal(t, int64(80), *inst.State.Code, "Stopped instance should have code 80")
|
||||
assert.Equal(t, "stopped", *inst.State.Name)
|
||||
} else {
|
||||
assert.Equal(t, int64(16), *inst.State.Code, "Running instance should have code 16")
|
||||
assert.Equal(t, "running", *inst.State.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunInstances_CountValidation tests MinCount/MaxCount validation scenarios.
|
||||
func TestRunInstances_CountValidation(t *testing.T) {
|
||||
natsURL := sharedNATSURL
|
||||
@@ -1738,6 +1550,171 @@ func TestInstanceCleanerAdapter_DeleteVolumes_DeleteOnTermination_False(t *testi
|
||||
assert.False(t, ebsDeletedVolumes["vol-keep"], "Root volume with DeleteOnTermination=false should NOT be deleted")
|
||||
}
|
||||
|
||||
// TestInstanceCleanerAdapter_DeleteVolumes_BootVolumeDeletedAfterAttachmentClear
|
||||
// locks the mulga-1dzx9 fix on the running-instance terminate path: a Boot,
|
||||
// DeleteOnTermination root volume left attached (terminateCleanup's
|
||||
// shutdownAndUnmount runs Unmount, which deliberately never clears a Boot
|
||||
// volume's AttachedInstance) previously hit DeleteVolume's in-use guard
|
||||
// directly. DeleteVolumes must now call DeleteVolumeOnTerminate, which clears
|
||||
// the stale attachment first, so the delete actually succeeds.
|
||||
func TestInstanceCleanerAdapter_DeleteVolumes_BootVolumeDeletedAfterAttachmentClear(t *testing.T) {
|
||||
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
|
||||
|
||||
// DeleteVolume's snapshot-reference guard requires a non-nil snapshotKV;
|
||||
// wire a JetStream-backed one (no snapshot refs recorded) so the delete
|
||||
// reaches the S3 cleanup step instead of failing closed on a nil KV.
|
||||
jsConn, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { jsConn.Close() })
|
||||
js, err := jetstream.New(jsConn)
|
||||
require.NoError(t, err)
|
||||
snapKV, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
|
||||
Bucket: "snap-kv-" + strings.ReplaceAll(t.Name(), "/", "-"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
daemon.volumeService = handlers_ec2_volume.NewVolumeServiceImplWithStore(daemon.config, store, daemon.natsConn, snapKV)
|
||||
|
||||
volumeID := "vol-root-attached"
|
||||
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
|
||||
VolumeID: volumeID,
|
||||
TenantID: testAccountID,
|
||||
SizeGiB: 10,
|
||||
State: "available",
|
||||
AttachedInstance: "i-test-boot-delete", // stale: never cleared by Stop/shutdownAndUnmount's Boot carve-out
|
||||
})
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-boot-delete",
|
||||
AccountID: testAccountID,
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: volumeID, Boot: true, DeleteOnTermination: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaner := newInstanceCleanerAdapter(daemon)
|
||||
err = cleaner.DeleteVolumes(instance)
|
||||
require.NoError(t, err, "the still-attached boot volume must be deleted, not rejected as VolumeInUse")
|
||||
|
||||
_, err = daemon.volumeService.GetVolumeConfig(volumeID)
|
||||
require.Error(t, err, "the volume must actually be deleted")
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
// TestInstanceCleanerAdapter_DeleteVolumes_NonDoTBootVolumeDetachedNotDeleted
|
||||
// locks the running-path half of the terminate-implies-detach fix for a
|
||||
// DeleteOnTermination=false root volume: shutdownAndUnmount's Unmount
|
||||
// (daemon/vm_adapters.go) deliberately never clears a Boot volume's
|
||||
// attachment, so without this it would strand attached to the now-terminated
|
||||
// instance forever. Terminate must still detach it (AWS semantics: it
|
||||
// survives as available, it just isn't deleted).
|
||||
func TestInstanceCleanerAdapter_DeleteVolumes_NonDoTBootVolumeDetachedNotDeleted(t *testing.T) {
|
||||
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
|
||||
|
||||
volumeID := "vol-root-nondot-attached"
|
||||
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
|
||||
VolumeID: volumeID,
|
||||
TenantID: testAccountID,
|
||||
SizeGiB: 10,
|
||||
State: "available",
|
||||
AttachedInstance: "i-test-boot-detach", // stale: never cleared by Unmount's Boot carve-out
|
||||
})
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-boot-detach",
|
||||
AccountID: testAccountID,
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: volumeID, Boot: true, DeleteOnTermination: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cleaner := newInstanceCleanerAdapter(daemon)
|
||||
err := cleaner.DeleteVolumes(instance)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := daemon.volumeService.GetVolumeConfig(volumeID)
|
||||
require.NoError(t, err, "a DeleteOnTermination=false volume must survive terminate")
|
||||
assert.Equal(t, "available", cfg.VolumeMetadata.State)
|
||||
assert.Empty(t, cfg.VolumeMetadata.AttachedInstance, "terminate must still detach it")
|
||||
}
|
||||
|
||||
// TestTerminatedTeardownReaper_SelfHealsFailedVolumeTeardown proves the
|
||||
// go-forward self-heal: a stopped-terminate that stamped
|
||||
// Teardown[volumes]=failed on a transient error (deleteInstanceVolumes,
|
||||
// handlers/ec2/instance/service_impl.go) is retried by
|
||||
// TerminatedTeardownReaper.Sweep (vm/teardown_reaper.go) through the real
|
||||
// instanceCleanerAdapter, which stage 1 rerouted through
|
||||
// DeleteVolumeOnTerminate. The retry clears the stale attachment, the delete
|
||||
// now succeeds, and the mark flips to done — without abandoning the record.
|
||||
func TestTerminatedTeardownReaper_SelfHealsFailedVolumeTeardown(t *testing.T) {
|
||||
daemon, store := createFullTestDaemonWithStore(t, sharedJSNATSURL)
|
||||
|
||||
// A real terminated-instance KV, so Sweep can list/update/(not yet)purge
|
||||
// the record exactly as production does.
|
||||
jsManager, err := NewJetStreamManager(daemon.natsConn, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsManager.InitTerminatedInstanceBucket())
|
||||
daemon.stateStore = newStateStoreAdapter(jsManager)
|
||||
|
||||
// DeleteVolume's snapshot-reference guard requires a non-nil snapshotKV.
|
||||
js, err := jetstream.New(daemon.natsConn)
|
||||
require.NoError(t, err)
|
||||
snapKV, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
|
||||
Bucket: "snap-kv-" + strings.ReplaceAll(t.Name(), "/", "-"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
daemon.volumeService = handlers_ec2_volume.NewVolumeServiceImplWithStore(daemon.config, store, daemon.natsConn, snapKV)
|
||||
|
||||
volumeID := "vol-root-self-heal"
|
||||
instanceID := "i-self-heal"
|
||||
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
|
||||
VolumeID: volumeID,
|
||||
TenantID: testAccountID,
|
||||
SizeGiB: 10,
|
||||
State: "available",
|
||||
AttachedInstance: instanceID, // stale, left by the earlier failed terminate
|
||||
})
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
AccountID: testAccountID,
|
||||
Status: vm.StateTerminated,
|
||||
LastNode: daemon.node,
|
||||
TerminatedAt: time.Now(), // inside the visibility window: Sweep must not purge yet
|
||||
Teardown: map[string]string{
|
||||
vm.TeardownVolumes: string(vm.TeardownFailed),
|
||||
},
|
||||
EBSRequests: types.EBSRequests{
|
||||
Requests: []types.EBSRequest{
|
||||
{Name: volumeID, Boot: true, DeleteOnTermination: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, daemon.stateStore.WriteTerminatedInstance(instance.ID, instance))
|
||||
|
||||
reaper := vm.NewManagerWithDeps(vm.Deps{
|
||||
NodeID: daemon.node,
|
||||
StateStore: daemon.stateStore,
|
||||
InstanceCleaner: newInstanceCleanerAdapter(daemon),
|
||||
}).NewTerminatedTeardownReaper()
|
||||
|
||||
_, err = reaper.Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = daemon.volumeService.GetVolumeConfig(volumeID)
|
||||
require.Error(t, err, "the retried delete must actually remove the volume")
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
|
||||
|
||||
remaining, err := daemon.stateStore.ListTerminatedInstances()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1, "still within the visibility window: not purged yet")
|
||||
assert.Equal(t, string(vm.TeardownDone), remaining[0].Teardown[vm.TeardownVolumes],
|
||||
"a retry that now succeeds must flip the mark from failed to done")
|
||||
}
|
||||
|
||||
// TestHandleEC2Events_AttachVolume tests the attach-volume handler in handleEC2Events.
|
||||
func TestHandleEC2Events_AttachVolume(t *testing.T) {
|
||||
natsURL := sharedNATSURL
|
||||
@@ -3519,40 +3496,6 @@ func TestVolumeMounterAdapter_Mount_RollbackFailurePropagates(t *testing.T) {
|
||||
"underlying unmount error must be wrapped in")
|
||||
}
|
||||
|
||||
// TestDescribeInstances_InvalidInstanceIDMalformed verifies that DescribeInstances
|
||||
// returns InvalidInstanceID.Malformed when given instance IDs without the i- prefix.
|
||||
func TestDescribeInstances_InvalidInstanceIDMalformed(t *testing.T) {
|
||||
natsURL := sharedNATSURL
|
||||
daemon := createTestDaemon(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
t.Run("MalformedInstanceID", func(t *testing.T) {
|
||||
input := &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String("bad-instance-id")},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(resp.Data), "InvalidInstanceID.Malformed")
|
||||
})
|
||||
|
||||
t.Run("ValidInstanceIDPassesValidation", func(t *testing.T) {
|
||||
input := &ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String("i-nonexistent")},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
|
||||
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
// Should not contain a malformed error — returns empty results instead
|
||||
assert.NotContains(t, string(resp.Data), "InvalidInstanceID.Malformed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStopTerminate_IncorrectInstanceState verifies that stopping an already-stopped
|
||||
// instance or terminating an already-terminated instance returns IncorrectInstanceState
|
||||
// instead of ServerInternal.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -261,6 +262,10 @@ func newSubscribeTestDaemon(t *testing.T, nc *nats.Conn, gatewayURL, accessKey s
|
||||
natsSubscriptions: make(map[string]*nats.Subscription),
|
||||
elbv2Service: svc,
|
||||
systemAccessKey: accessKey,
|
||||
// subscribeAll now binds d.eipService.Method eagerly; production always
|
||||
// sets the disabled stub when EIP is unconfigured, so mirror that here to
|
||||
// avoid a nil-interface method-value panic while building the table.
|
||||
eipService: handlers_ec2_eip.NewDisabledEIPService(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func (d *Daemon) buildECSServiceDeps() handlers_ecs.Deps {
|
||||
if d.clusterConfig != nil {
|
||||
clusterSize = len(d.clusterConfig.Nodes)
|
||||
}
|
||||
if iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.natsConn, masterKey, clusterSize); iamErr != nil {
|
||||
if iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize); iamErr != nil {
|
||||
slog.Warn("ECS: IAM service init failed; capacity provisioning disabled", "err", iamErr)
|
||||
} else {
|
||||
deps.IAM = iamSvc
|
||||
|
||||
@@ -37,7 +37,7 @@ func (d *Daemon) systemRoleEnsurer() handlers_iam.SystemInstanceRoleEnsurer {
|
||||
if d.clusterConfig != nil {
|
||||
clusterSize = len(d.clusterConfig.Nodes)
|
||||
}
|
||||
iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.natsConn, masterKey, clusterSize)
|
||||
iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize)
|
||||
if iamErr != nil {
|
||||
slog.Warn("System role ensurer: IAM service init failed (retried on next launch); system VMs fall back to baked static creds",
|
||||
"err", iamErr)
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestHeartbeatKVContract(t *testing.T) {
|
||||
|
||||
// Verify the KV key prefix and the JSON tag names directly. Reading back
|
||||
// via ReadHeartbeat alone would also pass against a typo'd tag.
|
||||
entry, err := jsm.clusterKV.Get("heartbeat." + h.Node)
|
||||
entry, err := jsm.clusterKV.Get(t.Context(), "heartbeat."+h.Node)
|
||||
require.NoError(t, err)
|
||||
|
||||
var raw map[string]any
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstanceMatchesFilters_NoFilters(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-123", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-123"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, nil))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_SingleFilter_Match(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{
|
||||
"instance-state-name": {"running"},
|
||||
}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_SingleFilter_NoMatch(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{
|
||||
"instance-state-name": {"stopped"},
|
||||
}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_MultipleValues_ORLogic(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{
|
||||
"instance-state-name": {"running", "stopped"},
|
||||
}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_MultipleFilters_ANDLogic(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
VpcId: aws.String("vpc-111"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
// Both must match
|
||||
filters := map[string][]string{
|
||||
"instance-state-name": {"running"},
|
||||
"vpc-id": {"vpc-111"},
|
||||
}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
// One doesn't match
|
||||
filters["vpc-id"] = []string{"vpc-999"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_InstanceId(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{"instance-id": {"i-abc"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["instance-id"] = []string{"i-other"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_InstanceType(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "m5.large"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("m5.large"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{"instance-type": {"m5.large"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_SubnetId(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
SubnetId: aws.String("subnet-123"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{"subnet-id": {"subnet-123"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["subnet-id"] = []string{"subnet-999"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_Wildcard(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
filters := map[string][]string{"instance-type": {"t3.*"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["instance-type"] = []string{"m5.*"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_TagFilter(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Environment"), Value: aws.String("prod")},
|
||||
{Key: aws.String("Team"), Value: aws.String("backend")},
|
||||
},
|
||||
}
|
||||
|
||||
filters := map[string][]string{"tag:Environment": {"prod"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["tag:Environment"] = []string{"staging"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_TagKey(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Environment"), Value: aws.String("prod")},
|
||||
},
|
||||
}
|
||||
filters := map[string][]string{"tag-key": {"Environment"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["tag-key"] = []string{"MissingKey"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_TagValue(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Environment"), Value: aws.String("prod")},
|
||||
},
|
||||
}
|
||||
filters := map[string][]string{"tag-value": {"prod"}}
|
||||
assert.True(t, instanceMatchesFilters(inst, ic, filters))
|
||||
|
||||
filters["tag-value"] = []string{"staging"}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestInstanceMatchesFilters_EmptyResults(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
|
||||
ic := &ec2.Instance{
|
||||
InstanceId: aws.String("i-abc"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
State: &ec2.InstanceState{Name: aws.String("running")},
|
||||
}
|
||||
// Filter that matches nothing
|
||||
filters := map[string][]string{"instance-type": {"c5.4xlarge"}}
|
||||
assert.False(t, instanceMatchesFilters(inst, ic, filters))
|
||||
}
|
||||
|
||||
func TestParseFilters_UnknownFilterName(t *testing.T) {
|
||||
filters := []*ec2.Filter{
|
||||
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
|
||||
}
|
||||
_, err := filterutil.ParseFilters(filters, describeInstancesValidFilters)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "InvalidParameterValue")
|
||||
}
|
||||
|
||||
func TestEC2TagsToMap(t *testing.T) {
|
||||
tags := []*ec2.Tag{
|
||||
{Key: aws.String("a"), Value: aws.String("1")},
|
||||
{Key: aws.String("b"), Value: aws.String("2")},
|
||||
}
|
||||
m := filterutil.EC2TagsToMap(tags)
|
||||
assert.Equal(t, "1", m["a"])
|
||||
assert.Equal(t, "2", m["b"])
|
||||
}
|
||||
|
||||
func TestEC2TagsToMap_Nil(t *testing.T) {
|
||||
assert.Nil(t, filterutil.EC2TagsToMap(nil))
|
||||
}
|
||||
+172
-152
@@ -1,6 +1,7 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -46,10 +48,10 @@ type KVSyncObserver interface {
|
||||
|
||||
// JetStreamManager manages JetStream KV store operations for instance state.
|
||||
type JetStreamManager struct {
|
||||
js nats.JetStreamContext
|
||||
kv nats.KeyValue // spinifex-instance-state
|
||||
clusterKV nats.KeyValue // spinifex-cluster-state
|
||||
terminatedKV nats.KeyValue // spinifex-terminated-instances
|
||||
js jetstream.JetStream
|
||||
kv jetstream.KeyValue // spinifex-instance-state
|
||||
clusterKV jetstream.KeyValue // spinifex-cluster-state
|
||||
terminatedKV jetstream.KeyValue // spinifex-terminated-instances
|
||||
replicas int
|
||||
kvMu sync.Mutex // protects kv during recovery
|
||||
obs KVSyncObserver
|
||||
@@ -68,14 +70,14 @@ func (m *JetStreamManager) KVHealthy() bool {
|
||||
if m.js == nil {
|
||||
return false
|
||||
}
|
||||
_, err := m.js.AccountInfo()
|
||||
_, err := m.js.AccountInfo(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// NewJetStreamManager creates a new JetStreamManager from a NATS connection.
|
||||
// replicas specifies the number of replicas for the KV bucket (typically matches cluster node count).
|
||||
func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error) {
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,13 +90,14 @@ func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error)
|
||||
|
||||
// InitKVBucket initializes the KV bucket, creating it if it doesn't exist.
|
||||
func (m *JetStreamManager) InitKVBucket() error {
|
||||
ctx := context.Background()
|
||||
// Try to get the existing bucket first
|
||||
kv, err := m.js.KeyValue(InstanceStateBucket)
|
||||
kv, err := m.js.KeyValue(ctx, InstanceStateBucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
// Bucket doesn't exist, create it
|
||||
slog.Debug("Creating JetStream KV bucket", "bucket", InstanceStateBucket, "replicas", m.replicas)
|
||||
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: InstanceStateBucket,
|
||||
Description: "Spinifex instance state storage",
|
||||
History: 1, // Only keep latest value
|
||||
@@ -111,7 +114,7 @@ func (m *JetStreamManager) InitKVBucket() error {
|
||||
}
|
||||
|
||||
m.kv = kv
|
||||
if err := migrate.DefaultRegistry.RunKV(InstanceStateBucket, kv, InstanceStateBucketVersion); err != nil {
|
||||
if err := migrate.DefaultRegistry.RunKV(ctx, InstanceStateBucket, kv, InstanceStateBucketVersion); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", InstanceStateBucket, err)
|
||||
}
|
||||
return nil
|
||||
@@ -119,11 +122,12 @@ func (m *JetStreamManager) InitKVBucket() error {
|
||||
|
||||
// InitClusterStateBucket initializes the cluster-state KV bucket, creating it if it doesn't exist.
|
||||
func (m *JetStreamManager) InitClusterStateBucket() error {
|
||||
kv, err := m.js.KeyValue(ClusterStateBucket)
|
||||
ctx := context.Background()
|
||||
kv, err := m.js.KeyValue(ctx, ClusterStateBucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
slog.Debug("Creating JetStream KV bucket", "bucket", ClusterStateBucket, "replicas", m.replicas)
|
||||
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: ClusterStateBucket,
|
||||
Description: "Spinifex cluster state (heartbeats, shutdown markers, service maps)",
|
||||
History: 1,
|
||||
@@ -141,7 +145,7 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
|
||||
}
|
||||
|
||||
m.clusterKV = kv
|
||||
if err := migrate.DefaultRegistry.RunKV(ClusterStateBucket, kv, ClusterStateBucketVersion); err != nil {
|
||||
if err := migrate.DefaultRegistry.RunKV(ctx, ClusterStateBucket, kv, ClusterStateBucketVersion); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", ClusterStateBucket, err)
|
||||
}
|
||||
return nil
|
||||
@@ -150,11 +154,12 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
|
||||
// InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL.
|
||||
// JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances.
|
||||
func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
|
||||
kv, err := m.js.KeyValue(TerminatedInstanceBucket)
|
||||
ctx := context.Background()
|
||||
kv, err := m.js.KeyValue(ctx, TerminatedInstanceBucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
slog.Debug("Creating JetStream KV bucket", "bucket", TerminatedInstanceBucket, "replicas", m.replicas)
|
||||
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: TerminatedInstanceBucket,
|
||||
Description: "Terminated instances (auto-expire after 1 hour)",
|
||||
History: 1,
|
||||
@@ -172,7 +177,7 @@ func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
|
||||
}
|
||||
|
||||
m.terminatedKV = kv
|
||||
if err := migrate.DefaultRegistry.RunKV(TerminatedInstanceBucket, kv, TerminatedInstanceBucketVersion); err != nil {
|
||||
if err := migrate.DefaultRegistry.RunKV(ctx, TerminatedInstanceBucket, kv, TerminatedInstanceBucketVersion); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", TerminatedInstanceBucket, err)
|
||||
}
|
||||
return nil
|
||||
@@ -189,8 +194,8 @@ func isStreamUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrStreamNotFound) ||
|
||||
errors.Is(err, nats.ErrNoStreamResponse) ||
|
||||
if errors.Is(err, jetstream.ErrStreamNotFound) ||
|
||||
errors.Is(err, jetstream.ErrNoStreamResponse) ||
|
||||
errors.Is(err, nats.ErrNoResponders) {
|
||||
return true
|
||||
}
|
||||
@@ -201,19 +206,19 @@ func isStreamUnavailable(err error) bool {
|
||||
// underlying JetStream stream was lost during cluster formation.
|
||||
// Returns the recovered KV handle directly so callers avoid a racy re-read.
|
||||
// When a bucket is recreated, the schema version is re-stamped.
|
||||
func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.KeyValue, version int) (nats.KeyValue, error) {
|
||||
func (m *JetStreamManager) recoverBucket(ctx context.Context, cfg jetstream.KeyValueConfig, field *jetstream.KeyValue, version int) (jetstream.KeyValue, error) {
|
||||
m.kvMu.Lock()
|
||||
defer m.kvMu.Unlock()
|
||||
|
||||
// Try to reconnect to existing bucket first (another goroutine may have recovered it)
|
||||
kv, err := m.js.KeyValue(cfg.Bucket)
|
||||
kv, err := m.js.KeyValue(ctx, cfg.Bucket)
|
||||
if err == nil {
|
||||
*field = kv
|
||||
slog.Info("Reconnected to KV bucket", "bucket", cfg.Bucket)
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, nats.ErrBucketNotFound) && !isStreamUnavailable(err) {
|
||||
if !errors.Is(err, jetstream.ErrBucketNotFound) && !isStreamUnavailable(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -221,13 +226,13 @@ func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.K
|
||||
slog.Warn("KV bucket stream lost, recreating", "bucket", cfg.Bucket, "replicas", m.replicas)
|
||||
cfg.History = 1
|
||||
cfg.Replicas = m.replicas
|
||||
kv, err = m.js.CreateKeyValue(cfg)
|
||||
kv, err = m.js.CreateKeyValue(ctx, cfg)
|
||||
if err != nil {
|
||||
slog.Error("Failed to recreate KV bucket", "bucket", cfg.Bucket, "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := migrate.DefaultRegistry.RunKV(cfg.Bucket, kv, version); err != nil {
|
||||
if err := migrate.DefaultRegistry.RunKV(ctx, cfg.Bucket, kv, version); err != nil {
|
||||
slog.Error("Failed to run migrations on recreated bucket", "bucket", cfg.Bucket, "err", err)
|
||||
return nil, fmt.Errorf("migrate recreated bucket %s: %w", cfg.Bucket, err)
|
||||
}
|
||||
@@ -237,15 +242,15 @@ func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.K
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (m *JetStreamManager) recoverKVBucket() (nats.KeyValue, error) {
|
||||
return m.recoverBucket(&nats.KeyValueConfig{
|
||||
func (m *JetStreamManager) recoverKVBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
return m.recoverBucket(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: InstanceStateBucket,
|
||||
Description: "Spinifex instance state storage",
|
||||
}, &m.kv, InstanceStateBucketVersion)
|
||||
}
|
||||
|
||||
func (m *JetStreamManager) recoverTerminatedKVBucket() (nats.KeyValue, error) {
|
||||
return m.recoverBucket(&nats.KeyValueConfig{
|
||||
func (m *JetStreamManager) recoverTerminatedKVBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
return m.recoverBucket(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: TerminatedInstanceBucket,
|
||||
Description: "Terminated instances (auto-expire after 1 hour)",
|
||||
TTL: 1 * time.Hour,
|
||||
@@ -260,10 +265,10 @@ const maxCASRetries = 5
|
||||
// isRevisionConflict reports whether err is the nats.go "wrong last
|
||||
// sequence" error that KeyValue.Update/Create return when the key's
|
||||
// revision no longer matches what the caller expected, i.e. a concurrent
|
||||
// writer won the race. nats.go surfaces this as nats.ErrKeyExists for both
|
||||
// Update (stale revision) and Create (key already present).
|
||||
// writer won the race. The jetstream package surfaces this as
|
||||
// jetstream.ErrKeyExists for both Update (stale revision) and Create (key already present).
|
||||
func isRevisionConflict(err error) bool {
|
||||
return errors.Is(err, nats.ErrKeyExists)
|
||||
return errors.Is(err, jetstream.ErrKeyExists)
|
||||
}
|
||||
|
||||
// casUpdate performs an optimistic-concurrency read-modify-write against kv:
|
||||
@@ -272,20 +277,20 @@ func isRevisionConflict(err error) bool {
|
||||
// Update/Create using the observed revision. A concurrent writer that changes
|
||||
// the key between Get and Update/Create is detected via a revision conflict
|
||||
// and retried, bounded by maxCASRetries.
|
||||
func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbsent bool) (*T, error) {
|
||||
func casUpdate[T any](ctx context.Context, kv jetstream.KeyValue, key string, mutate func(*T), createIfAbsent bool) (*T, error) {
|
||||
var lastErr error
|
||||
for range maxCASRetries {
|
||||
var value T
|
||||
var revision uint64
|
||||
|
||||
entry, err := kv.Get(key)
|
||||
entry, err := kv.Get(ctx, key)
|
||||
switch {
|
||||
case err == nil:
|
||||
if uerr := json.Unmarshal(entry.Value(), &value); uerr != nil {
|
||||
return nil, uerr
|
||||
}
|
||||
revision = entry.Revision()
|
||||
case errors.Is(err, nats.ErrKeyNotFound) && createIfAbsent:
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound) && createIfAbsent:
|
||||
revision = 0
|
||||
default:
|
||||
return nil, err
|
||||
@@ -299,9 +304,9 @@ func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbs
|
||||
}
|
||||
|
||||
if revision == 0 {
|
||||
_, err = kv.Create(key, data)
|
||||
_, err = kv.Create(ctx, key, data)
|
||||
} else {
|
||||
_, err = kv.Update(key, data, revision)
|
||||
_, err = kv.Update(ctx, key, data, revision)
|
||||
}
|
||||
if err == nil {
|
||||
return &value, nil
|
||||
@@ -322,25 +327,25 @@ func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbs
|
||||
// counterpart to casUpdate, for values (like vm.VM, which embeds a
|
||||
// sync.Mutex) that cannot be safely decoded into and copied out of a shared
|
||||
// struct value.
|
||||
func casPut(kv nats.KeyValue, key string, data []byte, createIfAbsent bool) error {
|
||||
func casPut(ctx context.Context, kv jetstream.KeyValue, key string, data []byte, createIfAbsent bool) error {
|
||||
var lastErr error
|
||||
for range maxCASRetries {
|
||||
var revision uint64
|
||||
|
||||
entry, err := kv.Get(key)
|
||||
entry, err := kv.Get(ctx, key)
|
||||
switch {
|
||||
case err == nil:
|
||||
revision = entry.Revision()
|
||||
case errors.Is(err, nats.ErrKeyNotFound) && createIfAbsent:
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound) && createIfAbsent:
|
||||
revision = 0
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
||||
if revision == 0 {
|
||||
_, err = kv.Create(key, data)
|
||||
_, err = kv.Create(ctx, key, data)
|
||||
} else {
|
||||
_, err = kv.Update(key, data, revision)
|
||||
_, err = kv.Update(ctx, key, data, revision)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -363,12 +368,12 @@ func casPut(kv nats.KeyValue, key string, data []byte, createIfAbsent bool) erro
|
||||
// detected via a revision conflict and retried, bounded by maxCASRetries,
|
||||
// so a losing racer only fails outright when the key is truly gone
|
||||
// (notFound) or retries are exhausted.
|
||||
func casClaim[T any](kv nats.KeyValue, key string) (value *T, notFound bool, err error) {
|
||||
func casClaim[T any](ctx context.Context, kv jetstream.KeyValue, key string) (value *T, notFound bool, err error) {
|
||||
var lastErr error
|
||||
for range maxCASRetries {
|
||||
entry, gerr := kv.Get(key)
|
||||
entry, gerr := kv.Get(ctx, key)
|
||||
if gerr != nil {
|
||||
if errors.Is(gerr, nats.ErrKeyNotFound) {
|
||||
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, false, gerr
|
||||
@@ -379,7 +384,7 @@ func casClaim[T any](kv nats.KeyValue, key string) (value *T, notFound bool, err
|
||||
return nil, false, uerr
|
||||
}
|
||||
|
||||
if derr := kv.Delete(key, nats.LastRevision(entry.Revision())); derr != nil {
|
||||
if derr := kv.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); derr != nil {
|
||||
if isRevisionConflict(derr) {
|
||||
lastErr = derr
|
||||
continue
|
||||
@@ -422,7 +427,7 @@ func (m *JetStreamManager) WriteHeartbeat(h *Heartbeat) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.clusterKV.Put("heartbeat."+h.Node, data)
|
||||
_, err = m.clusterKV.Put(context.Background(), "heartbeat."+h.Node, data)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -431,7 +436,7 @@ func (m *JetStreamManager) ReadHeartbeat(nodeID string) (*Heartbeat, error) {
|
||||
if m.clusterKV == nil {
|
||||
return nil, errors.New("cluster state KV not initialized")
|
||||
}
|
||||
entry, err := m.clusterKV.Get("heartbeat." + nodeID)
|
||||
entry, err := m.clusterKV.Get(context.Background(), "heartbeat."+nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -461,7 +466,7 @@ func (m *JetStreamManager) WriteClusterShutdown(state *ClusterShutdownState) err
|
||||
if m.clusterKV == nil {
|
||||
return errors.New("cluster state KV not initialized")
|
||||
}
|
||||
_, err := casUpdate(m.clusterKV, "cluster.shutdown", func(s *ClusterShutdownState) {
|
||||
_, err := casUpdate(context.Background(), m.clusterKV, "cluster.shutdown", func(s *ClusterShutdownState) {
|
||||
*s = *state
|
||||
}, true)
|
||||
return err
|
||||
@@ -470,12 +475,28 @@ func (m *JetStreamManager) WriteClusterShutdown(state *ClusterShutdownState) err
|
||||
// UpdateClusterShutdown atomically applies mutate to the current cluster
|
||||
// shutdown state (e.g. merging a node's ack into NodesAcked) and writes it
|
||||
// back with optimistic concurrency, retrying on a concurrent writer's
|
||||
// revision conflict. Returns nats.ErrKeyNotFound if no shutdown is in progress.
|
||||
// revision conflict. Returns jetstream.ErrKeyNotFound if no shutdown is in progress.
|
||||
func (m *JetStreamManager) UpdateClusterShutdown(mutate func(*ClusterShutdownState)) (*ClusterShutdownState, error) {
|
||||
if m.clusterKV == nil {
|
||||
return nil, errors.New("cluster state KV not initialized")
|
||||
}
|
||||
return casUpdate(m.clusterKV, "cluster.shutdown", mutate, false)
|
||||
return casUpdate(context.Background(), m.clusterKV, "cluster.shutdown", mutate, false)
|
||||
}
|
||||
|
||||
// UpdateMgmtIPAM atomically applies mutate to the mgmt-ipam record for
|
||||
// subnet and writes it back with optimistic concurrency, retrying on a
|
||||
// concurrent writer's revision conflict. createIfAbsent lets the first
|
||||
// allocation for a subnet create its record; releases pass false so a
|
||||
// release of an instance nobody holds an address for does not conjure an
|
||||
// empty record into existence. Every node sharing the management bridge's
|
||||
// L2 segment reads and writes the same record, keyed by subnet, which is
|
||||
// what makes mgmt IP allocation safe across the whole cluster rather than
|
||||
// just this process (mulga-f3j2x).
|
||||
func (m *JetStreamManager) UpdateMgmtIPAM(subnet string, mutate func(*MgmtIPRecord), createIfAbsent bool) (*MgmtIPRecord, error) {
|
||||
if m.clusterKV == nil {
|
||||
return nil, errors.New("cluster state KV not initialized")
|
||||
}
|
||||
return casUpdate(context.Background(), m.clusterKV, mgmtIPAMKeyPrefix+subnet, mutate, createIfAbsent)
|
||||
}
|
||||
|
||||
// ReadClusterShutdown reads the cluster shutdown state from KV.
|
||||
@@ -483,7 +504,7 @@ func (m *JetStreamManager) ReadClusterShutdown() (*ClusterShutdownState, error)
|
||||
if m.clusterKV == nil {
|
||||
return nil, errors.New("cluster state KV not initialized")
|
||||
}
|
||||
entry, err := m.clusterKV.Get("cluster.shutdown")
|
||||
entry, err := m.clusterKV.Get(context.Background(), "cluster.shutdown")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -499,8 +520,8 @@ func (m *JetStreamManager) DeleteClusterShutdown() error {
|
||||
if m.clusterKV == nil {
|
||||
return errors.New("cluster state KV not initialized")
|
||||
}
|
||||
err := m.clusterKV.Delete("cluster.shutdown")
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
err := m.clusterKV.Delete(context.Background(), "cluster.shutdown")
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -518,7 +539,7 @@ func (m *JetStreamManager) WriteShutdownMarker(nodeID string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal shutdown marker: %w", err)
|
||||
}
|
||||
_, err = m.clusterKV.Put("shutdown."+nodeID, data)
|
||||
_, err = m.clusterKV.Put(context.Background(), "shutdown."+nodeID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -527,8 +548,8 @@ func (m *JetStreamManager) ReadShutdownMarker(nodeID string) (bool, error) {
|
||||
if m.clusterKV == nil {
|
||||
return false, errors.New("cluster state KV not initialized")
|
||||
}
|
||||
_, err := m.clusterKV.Get("shutdown." + nodeID)
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
_, err := m.clusterKV.Get(context.Background(), "shutdown."+nodeID)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -542,8 +563,8 @@ func (m *JetStreamManager) DeleteShutdownMarker(nodeID string) error {
|
||||
if m.clusterKV == nil {
|
||||
return errors.New("cluster state KV not initialized")
|
||||
}
|
||||
err := m.clusterKV.Delete("shutdown." + nodeID)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
err := m.clusterKV.Delete(context.Background(), "shutdown."+nodeID)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -564,7 +585,7 @@ func (m *JetStreamManager) WriteServiceManifest(nodeID string, services []string
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal service manifest: %w", err)
|
||||
}
|
||||
_, err = m.clusterKV.Put("node."+nodeID+".services", data)
|
||||
_, err = m.clusterKV.Put(context.Background(), "node."+nodeID+".services", data)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -581,15 +602,15 @@ func (m *JetStreamManager) WriteState(nodeID string, vms map[string]*vm.VM) erro
|
||||
}
|
||||
|
||||
key := InstanceStatePrefix + nodeID
|
||||
_, err = m.kv.Put(key, jsonData)
|
||||
_, err = m.kv.Put(context.Background(), key, jsonData)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if _, retryErr := kv.Put(key, jsonData); retryErr != nil {
|
||||
if _, retryErr := kv.Put(context.Background(), key, jsonData); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote state to JetStream KV (after recovery)", "key", key, "instances", len(vms))
|
||||
@@ -607,43 +628,32 @@ func (m *JetStreamManager) WriteState(nodeID string, vms map[string]*vm.VM) erro
|
||||
// blocks the caller past `timeout` and never returns an error. Used when the
|
||||
// local state file is the source of truth and KV is a best-effort cache; hot
|
||||
// paths marshal under a short-lived lock and commit lock-free.
|
||||
//
|
||||
// Note: kv.Put has no context API. On timeout, the in-flight Put goroutine
|
||||
// continues and completes (or fails) on its own. This leaks at most one
|
||||
// goroutine per write; under sustained partition the leak is bounded by
|
||||
// WriteState call cadence (per-state-transition, not a tight loop).
|
||||
func (m *JetStreamManager) WriteStateBytesBestEffort(nodeID string, jsonData []byte, timeout time.Duration) {
|
||||
if m.kv == nil {
|
||||
slog.Debug("KV bucket not initialized, skipping cluster sync", "node", nodeID)
|
||||
return
|
||||
}
|
||||
|
||||
key := InstanceStatePrefix + nodeID
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, putErr := m.kv.Put(key, jsonData)
|
||||
done <- putErr
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case putErr := <-done:
|
||||
if putErr != nil {
|
||||
if m.obs != nil {
|
||||
m.obs.RecordKVSyncFailure(InstanceStateBucket, putErr)
|
||||
}
|
||||
slog.Warn("KV sync failed (best-effort)", "key", key, "err", putErr)
|
||||
key := InstanceStatePrefix + nodeID
|
||||
_, err := m.kv.Put(ctx, key, jsonData)
|
||||
if err != nil {
|
||||
if m.obs != nil {
|
||||
m.obs.RecordKVSyncFailure(InstanceStateBucket, err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
slog.Warn("KV sync timed out (best-effort)", "key", key, "timeout", timeout)
|
||||
return
|
||||
}
|
||||
if m.obs != nil {
|
||||
m.obs.RecordKVSyncSuccess(InstanceStateBucket)
|
||||
}
|
||||
slog.Debug("Wrote state to KV (best-effort)", "key", key, "bytes", len(jsonData))
|
||||
case <-time.After(timeout):
|
||||
if m.obs != nil {
|
||||
m.obs.RecordKVSyncFailure(InstanceStateBucket, fmt.Errorf("kv sync timeout after %s", timeout))
|
||||
}
|
||||
slog.Warn("KV sync timed out (best-effort)", "key", key, "timeout", timeout)
|
||||
slog.Warn("KV sync failed (best-effort)", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
if m.obs != nil {
|
||||
m.obs.RecordKVSyncSuccess(InstanceStateBucket)
|
||||
}
|
||||
slog.Debug("Wrote state to KV (best-effort)", "key", key, "bytes", len(jsonData))
|
||||
}
|
||||
|
||||
// marshalInstanceState produces the JSON wire form of vms.
|
||||
@@ -664,22 +674,22 @@ func (m *JetStreamManager) LoadState(nodeID string) (map[string]*vm.VM, error) {
|
||||
}
|
||||
|
||||
key := InstanceStatePrefix + nodeID
|
||||
entry, err := m.kv.Get(key)
|
||||
entry, err := m.kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
slog.Debug("No existing state in JetStream KV, returning empty state", "key", key)
|
||||
return make(map[string]*vm.VM), nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry the read — if we reconnected, data may still exist
|
||||
entry, err = kv.Get(key)
|
||||
entry, err = kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
slog.Warn("No state found after KV recovery", "key", key)
|
||||
return make(map[string]*vm.VM), nil
|
||||
}
|
||||
@@ -712,16 +722,16 @@ func (m *JetStreamManager) DeleteState(nodeID string) error {
|
||||
}
|
||||
|
||||
key := InstanceStatePrefix + nodeID
|
||||
err := m.kv.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
err := m.kv.Delete(context.Background(), key)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteState", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
// Retry — if we reconnected the key may still exist
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
@@ -744,14 +754,21 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
|
||||
|
||||
m.replicas = newReplicas
|
||||
|
||||
ctx := context.Background()
|
||||
// Iterate all streams and update any KV-backed stream (prefixed "KV_")
|
||||
updated := 0
|
||||
for name := range m.js.StreamNames() {
|
||||
lister := m.js.StreamNames(ctx)
|
||||
for name := range lister.Name() {
|
||||
if !strings.HasPrefix(name, "KV_") {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := m.js.StreamInfo(name)
|
||||
stream, err := m.js.Stream(ctx, name)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open stream", "stream", name, "error", err)
|
||||
continue
|
||||
}
|
||||
info, err := stream.Info(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get stream info", "stream", name, "error", err)
|
||||
continue
|
||||
@@ -763,7 +780,7 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
|
||||
|
||||
oldReplicas := info.Config.Replicas
|
||||
info.Config.Replicas = newReplicas
|
||||
if _, err := m.js.UpdateStream(&info.Config); err != nil {
|
||||
if _, err := m.js.UpdateStream(ctx, info.Config); err != nil {
|
||||
slog.Warn("Failed to update KV bucket replicas", "stream", name, "error", err)
|
||||
continue
|
||||
}
|
||||
@@ -772,6 +789,9 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
|
||||
slog.Info("Updated KV bucket replicas", "bucket", bucket, "oldReplicas", oldReplicas, "newReplicas", newReplicas)
|
||||
updated++
|
||||
}
|
||||
if err := lister.Err(); err != nil {
|
||||
return fmt.Errorf("list JetStream streams: %w", err)
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
slog.Info("KV replication update complete", "bucketsUpdated", updated, "replicas", newReplicas)
|
||||
@@ -797,15 +817,15 @@ func (m *JetStreamManager) WriteStoppedInstance(instanceID string, instance *vm.
|
||||
}
|
||||
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
err = casPut(m.kv, key, jsonData, true)
|
||||
err = casPut(context.Background(), m.kv, key, jsonData, true)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if retryErr := casPut(kv, key, jsonData, true); retryErr != nil {
|
||||
if retryErr := casPut(context.Background(), kv, key, jsonData, true); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote stopped instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
|
||||
@@ -826,21 +846,21 @@ func (m *JetStreamManager) LoadStoppedInstance(instanceID string) (*vm.VM, error
|
||||
}
|
||||
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
entry, err := m.kv.Get(key)
|
||||
entry, err := m.kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry — if we reconnected, data may still exist
|
||||
entry, err = kv.Get(key)
|
||||
entry, err = kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -867,16 +887,16 @@ func (m *JetStreamManager) DeleteStoppedInstance(instanceID string) error {
|
||||
}
|
||||
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
err := m.kv.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
err := m.kv.Delete(context.Background(), key)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
// Retry — if we reconnected the key may still exist
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
@@ -903,15 +923,15 @@ func (m *JetStreamManager) ClaimStoppedInstance(instanceID string) (*vm.VM, erro
|
||||
}
|
||||
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
instance, notFound, err := casClaim[vm.VM](m.kv, key)
|
||||
instance, notFound, err := casClaim[vm.VM](context.Background(), m.kv, key)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ClaimStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance, notFound, err = casClaim[vm.VM](kv, key)
|
||||
instance, notFound, err = casClaim[vm.VM](context.Background(), kv, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -931,25 +951,25 @@ func (m *JetStreamManager) ClaimStoppedInstance(instanceID string) (*vm.VM, erro
|
||||
// stopped record for instanceID and writes it back using optimistic
|
||||
// concurrency (CAS), retrying on a concurrent writer's revision conflict.
|
||||
// createIfAbsent is false: if a winning ClaimStoppedInstance deletes the
|
||||
// record mid-flight, the CAS write fails with nats.ErrKeyNotFound instead of
|
||||
// record mid-flight, the CAS write fails with jetstream.ErrKeyNotFound instead of
|
||||
// resurrecting it. Used by tag/attribute mutations that read-modify-write a
|
||||
// stopped instance so they cannot race a claim into recreating a stale
|
||||
// record. Returns nats.ErrKeyNotFound if no record exists.
|
||||
// record. Returns jetstream.ErrKeyNotFound if no record exists.
|
||||
func (m *JetStreamManager) UpdateStoppedInstance(instanceID string, mutate func(*vm.VM)) (*vm.VM, error) {
|
||||
if m.kv == nil {
|
||||
return nil, errors.New("KV bucket not initialized")
|
||||
}
|
||||
|
||||
key := StoppedInstancePrefix + instanceID
|
||||
updated, err := casUpdate(m.kv, key, mutate, false)
|
||||
updated, err := casUpdate(context.Background(), m.kv, key, mutate, false)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "UpdateStoppedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
return casUpdate(kv, key, mutate, false)
|
||||
return casUpdate(context.Background(), kv, key, mutate, false)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -962,21 +982,21 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
|
||||
return nil, errors.New("KV bucket not initialized")
|
||||
}
|
||||
|
||||
keys, err := m.kv.Keys()
|
||||
keys, err := m.kv.Keys(context.Background())
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListStoppedInstances", "err", err)
|
||||
kv, recoverErr := m.recoverKVBucket()
|
||||
kv, recoverErr := m.recoverKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Retry — if we reconnected, data may still exist
|
||||
keys, err = kv.Keys()
|
||||
keys, err = kv.Keys(context.Background())
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -996,9 +1016,9 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := m.kv.Get(key)
|
||||
entry, err := m.kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
@@ -1035,15 +1055,15 @@ func (m *JetStreamManager) WriteTerminatedInstance(instanceID string, instance *
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
err = casPut(m.terminatedKV, key, jsonData, true)
|
||||
err = casPut(context.Background(), m.terminatedKV, key, jsonData, true)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if retryErr := casPut(kv, key, jsonData, true); retryErr != nil {
|
||||
if retryErr := casPut(context.Background(), kv, key, jsonData, true); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
slog.Debug("Wrote terminated instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
|
||||
@@ -1061,22 +1081,22 @@ func (m *JetStreamManager) WriteTerminatedInstance(instanceID string, instance *
|
||||
// optimistic concurrency (CAS), retrying on a concurrent writer's revision
|
||||
// conflict. Used by the teardown reaper to merge per-dependent Teardown
|
||||
// progress without clobbering marks written by a concurrent update to the
|
||||
// same record. Returns nats.ErrKeyNotFound if no record exists yet.
|
||||
// same record. Returns jetstream.ErrKeyNotFound if no record exists yet.
|
||||
func (m *JetStreamManager) UpdateTerminatedInstance(instanceID string, mutate func(*vm.VM)) (*vm.VM, error) {
|
||||
if m.terminatedKV == nil {
|
||||
return nil, errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
updated, err := casUpdate(m.terminatedKV, key, mutate, false)
|
||||
updated, err := casUpdate(context.Background(), m.terminatedKV, key, mutate, false)
|
||||
if err != nil {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "UpdateTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
return casUpdate(kv, key, mutate, false)
|
||||
return casUpdate(context.Background(), kv, key, mutate, false)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -1089,20 +1109,20 @@ func (m *JetStreamManager) ListTerminatedInstances() ([]*vm.VM, error) {
|
||||
return nil, errors.New("terminated instance KV bucket not initialized")
|
||||
}
|
||||
|
||||
keys, err := m.terminatedKV.Keys()
|
||||
keys, err := m.terminatedKV.Keys(context.Background())
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListTerminatedInstances", "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err = kv.Keys()
|
||||
keys, err = kv.Keys(context.Background())
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -1121,9 +1141,9 @@ func (m *JetStreamManager) ListTerminatedInstances() ([]*vm.VM, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := m.terminatedKV.Get(key)
|
||||
entry, err := m.terminatedKV.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
@@ -1148,15 +1168,15 @@ func (m *JetStreamManager) DeleteTerminatedInstance(instanceID string) error {
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
err := m.terminatedKV.Delete(key)
|
||||
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
err := m.terminatedKV.Delete(context.Background(), key)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return err
|
||||
}
|
||||
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
|
||||
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
|
||||
return retryErr
|
||||
}
|
||||
return nil
|
||||
@@ -1176,20 +1196,20 @@ func (m *JetStreamManager) LoadTerminatedInstance(instanceID string) (*vm.VM, er
|
||||
}
|
||||
|
||||
key := TerminatedInstancePrefix + instanceID
|
||||
entry, err := m.terminatedKV.Get(key)
|
||||
entry, err := m.terminatedKV.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if isStreamUnavailable(err) {
|
||||
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadTerminatedInstance", "key", key, "err", err)
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket()
|
||||
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
|
||||
if recoverErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err = kv.Get(key)
|
||||
entry, err = kv.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -343,8 +344,10 @@ func TestJetStreamManager_UpdateReplicas(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify initial replica count
|
||||
js, _ := nc.JetStream()
|
||||
streamInfo, err := js.StreamInfo("KV_" + InstanceStateBucket)
|
||||
js, _ := jetstream.New(nc)
|
||||
stream, err := js.Stream(t.Context(), "KV_"+InstanceStateBucket)
|
||||
require.NoError(t, err)
|
||||
streamInfo, err := stream.Info(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, streamInfo.Config.Replicas, "Should start with 1 replica")
|
||||
|
||||
@@ -598,7 +601,7 @@ func TestJetStreamManager_UpdateStoppedInstance_ConflictRetry(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestJetStreamManager_UpdateStoppedInstance_NotFound verifies
|
||||
// UpdateStoppedInstance surfaces nats.ErrKeyNotFound rather than silently
|
||||
// UpdateStoppedInstance surfaces jetstream.ErrKeyNotFound rather than silently
|
||||
// creating a record when nothing exists to update.
|
||||
func TestJetStreamManager_UpdateStoppedInstance_NotFound(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
@@ -610,7 +613,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NotFound(t *testing.T) {
|
||||
require.NoError(t, jsm.InitKVBucket())
|
||||
|
||||
_, err = jsm.UpdateStoppedInstance("i-does-not-exist", func(v *vm.VM) {})
|
||||
assert.ErrorIs(t, err, nats.ErrKeyNotFound)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
// TestJetStreamManager_UpdateStoppedInstance_NoResurrectAfterClaim is the
|
||||
@@ -646,7 +649,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NoResurrectAfterClaim(t *testing
|
||||
_, err = jsm.UpdateStoppedInstance(testVM.ID, func(v *vm.VM) {
|
||||
v.InstanceType = "should-not-land"
|
||||
})
|
||||
assert.ErrorIs(t, err, nats.ErrKeyNotFound, "a claim that deleted the record must not be resurrected by a losing racer's update")
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "a claim that deleted the record must not be resurrected by a losing racer's update")
|
||||
|
||||
stillGone, err := jsm.LoadStoppedInstance(testVM.ID)
|
||||
require.NoError(t, err)
|
||||
@@ -776,7 +779,7 @@ func TestJetStreamManager_WriteServiceManifest(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read the KV entry directly and verify JSON contents
|
||||
entry, err := jsm.clusterKV.Get("node.test-node-svc.services")
|
||||
entry, err := jsm.clusterKV.Get(t.Context(), "node.test-node-svc.services")
|
||||
require.NoError(t, err)
|
||||
|
||||
var manifest map[string]any
|
||||
@@ -811,7 +814,7 @@ func TestJetStreamManager_WriteServiceManifest_EmptyServices(t *testing.T) {
|
||||
err = jsm.WriteServiceManifest("empty-svc-node", []string{}, "10.0.0.2:4222", "10.0.0.2:8443")
|
||||
require.NoError(t, err)
|
||||
|
||||
entry, err := jsm.clusterKV.Get("node.empty-svc-node.services")
|
||||
entry, err := jsm.clusterKV.Get(t.Context(), "node.empty-svc-node.services")
|
||||
require.NoError(t, err)
|
||||
|
||||
var manifest map[string]any
|
||||
@@ -847,9 +850,9 @@ func TestJetStreamManager_WriteServiceManifest_ClusterKVNotInitialized(t *testin
|
||||
// deleteInstanceStateBucket deletes the underlying JetStream stream for the instance-state KV bucket.
|
||||
func deleteInstanceStateBucket(t *testing.T, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
err = js.DeleteStream("KV_" + InstanceStateBucket)
|
||||
err = js.DeleteStream(t.Context(), "KV_"+InstanceStateBucket)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -1006,7 +1009,7 @@ func swapToNonJSContext(t *testing.T, jsm *JetStreamManager) {
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
jsm.js = js
|
||||
}
|
||||
@@ -1137,12 +1140,12 @@ func TestJetStreamManager_ListStoppedInstances_RecoveryFailure(t *testing.T) {
|
||||
|
||||
func TestIsStreamUnavailable(t *testing.T) {
|
||||
assert.False(t, isStreamUnavailable(nil))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrStreamNotFound))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrNoStreamResponse))
|
||||
assert.True(t, isStreamUnavailable(jetstream.ErrStreamNotFound))
|
||||
assert.True(t, isStreamUnavailable(jetstream.ErrNoStreamResponse))
|
||||
assert.True(t, isStreamUnavailable(nats.ErrNoResponders))
|
||||
assert.True(t, isStreamUnavailable(errors.New("nats: stream not found")))
|
||||
assert.False(t, isStreamUnavailable(errors.New("some other error")))
|
||||
assert.False(t, isStreamUnavailable(nats.ErrKeyNotFound))
|
||||
assert.False(t, isStreamUnavailable(jetstream.ErrKeyNotFound))
|
||||
}
|
||||
|
||||
// --- Terminated instance KV tests ---
|
||||
@@ -1376,7 +1379,7 @@ func TestJetStreamManager_UpdateTerminatedInstance_NotFound(t *testing.T) {
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
_, err = jsm.UpdateTerminatedInstance("i-does-not-exist", func(v *vm.VM) {})
|
||||
assert.ErrorIs(t, err, nats.ErrKeyNotFound)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
// TestJetStreamManager_WriteStoppedInstance_OverwritesConcurrentValue verifies
|
||||
@@ -1416,9 +1419,9 @@ func TestJetStreamManager_WriteStoppedInstance_OverwritesConcurrentValue(t *test
|
||||
// deleteTerminatedInstanceBucket deletes the underlying JetStream stream for the terminated-instances KV bucket.
|
||||
func deleteTerminatedInstanceBucket(t *testing.T, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
err = js.DeleteStream("KV_" + TerminatedInstanceBucket)
|
||||
err = js.DeleteStream(t.Context(), "KV_"+TerminatedInstanceBucket)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -1504,15 +1507,15 @@ func TestJetStreamManager_InitBuckets_WritesVersion(t *testing.T) {
|
||||
require.NoError(t, jsm.InitClusterStateBucket())
|
||||
require.NoError(t, jsm.InitTerminatedInstanceBucket())
|
||||
|
||||
v, err := utils.ReadVersion(jsm.kv)
|
||||
v, err := kvutil.ReadVersion(t.Context(), jsm.kv)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, InstanceStateBucketVersion, v)
|
||||
|
||||
v, err = utils.ReadVersion(jsm.clusterKV)
|
||||
v, err = kvutil.ReadVersion(t.Context(), jsm.clusterKV)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ClusterStateBucketVersion, v)
|
||||
|
||||
v, err = utils.ReadVersion(jsm.terminatedKV)
|
||||
v, err = kvutil.ReadVersion(t.Context(), jsm.terminatedKV)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TerminatedInstanceBucketVersion, v)
|
||||
}
|
||||
@@ -1634,3 +1637,90 @@ func TestJetStreamManager_BestEffort_NilObserver_NoPanic(t *testing.T) {
|
||||
// No observer set — must not panic on success or failure paths.
|
||||
jsm.WriteStateBytesBestEffort("obs-nil", []byte(`{"vms":{}}`), 5*time.Second)
|
||||
}
|
||||
|
||||
// --- UpdateMgmtIPAM CAS tests ---
|
||||
|
||||
// TestJetStreamManager_UpdateMgmtIPAM_ConflictRetry locks the CAS contract
|
||||
// for mgmt-ipam records: a concurrent writer landing between Get and Update
|
||||
// must be detected via a revision conflict and retried, and both the
|
||||
// injected concurrent write and the retried write must land — mirroring
|
||||
// UpdateStoppedInstance/UpdateTerminatedInstance's conflict-retry tests,
|
||||
// applied to the allocator's backing store (mulga-f3j2x).
|
||||
func TestJetStreamManager_UpdateMgmtIPAM_ConflictRetry(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitClusterStateBucket())
|
||||
|
||||
const subnet = "10.99.8.0/24"
|
||||
defer func() { _ = jsm.clusterKV.Delete(t.Context(), mgmtIPAMKeyPrefix+subnet) }()
|
||||
|
||||
_, err = jsm.UpdateMgmtIPAM(subnet, func(r *MgmtIPRecord) {
|
||||
r.Subnet = subnet
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: "10.99.8.10", InstanceID: "i-seed", Node: "node-seed"})
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
var attempts int
|
||||
var injectOnce sync.Once
|
||||
updated, err := jsm.UpdateMgmtIPAM(subnet, func(r *MgmtIPRecord) {
|
||||
attempts++
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: "10.99.8.11", InstanceID: "i-retried", Node: "node-a"})
|
||||
|
||||
// Simulate a concurrent writer landing between our Get and Update on
|
||||
// the first attempt only, forcing exactly one revision conflict.
|
||||
injectOnce.Do(func() {
|
||||
_, cerr := jsm.UpdateMgmtIPAM(subnet, func(cr *MgmtIPRecord) {
|
||||
cr.Allocated = append(cr.Allocated, MgmtIPEntry{IP: "10.99.8.12", InstanceID: "i-concurrent", Node: "node-b"})
|
||||
}, true)
|
||||
require.NoError(t, cerr)
|
||||
})
|
||||
}, true)
|
||||
require.NoError(t, err, "the retried Update must eventually succeed against the fresh revision")
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, 2, attempts, "the injected concurrent write must force exactly one retry")
|
||||
|
||||
ids := make(map[string]bool, len(updated.Allocated))
|
||||
for _, e := range updated.Allocated {
|
||||
ids[e.InstanceID] = true
|
||||
}
|
||||
assert.True(t, ids["i-seed"], "the original entry must survive")
|
||||
assert.True(t, ids["i-concurrent"], "the concurrent writer's entry must survive, not be clobbered")
|
||||
assert.True(t, ids["i-retried"], "the retried update must also land")
|
||||
}
|
||||
|
||||
// TestJetStreamManager_UpdateMgmtIPAM_NotFound verifies createIfAbsent=false
|
||||
// surfaces jetstream.ErrKeyNotFound instead of silently creating a record —
|
||||
// MgmtIPAllocator.Release relies on this to no-op cleanly when there is
|
||||
// nothing to release.
|
||||
func TestJetStreamManager_UpdateMgmtIPAM_NotFound(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, jsm.InitClusterStateBucket())
|
||||
|
||||
_, err = jsm.UpdateMgmtIPAM("10.98.8.0/24-nonexistent", func(r *MgmtIPRecord) {}, false)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
// TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized tests error
|
||||
// handling when the cluster-state KV bucket has not been initialized.
|
||||
func TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized(t *testing.T) {
|
||||
nc, err := nats.Connect(sharedJSNATSURL)
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
jsm, err := NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
// Don't call InitClusterStateBucket
|
||||
|
||||
_, err = jsm.UpdateMgmtIPAM("10.97.8.0/24", func(r *MgmtIPRecord) {}, true)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cluster state KV not initialized")
|
||||
}
|
||||
|
||||
@@ -32,15 +32,6 @@ func checkInstanceOwnership(msg *nats.Msg, instanceID, ownerAccountID string) bo
|
||||
return true
|
||||
}
|
||||
|
||||
// isInstanceVisible reports whether the caller may see this instance.
|
||||
// Empty ownerAccountID is root-only.
|
||||
func isInstanceVisible(callerAccountID, ownerAccountID string) bool {
|
||||
if ownerAccountID == "" {
|
||||
return callerAccountID == utils.GlobalAccountID
|
||||
}
|
||||
return callerAccountID == ownerAccountID
|
||||
}
|
||||
|
||||
// volumeVisibleTo reports whether callerAccountID may access a volume.
|
||||
// Empty tenantID is root-only to prevent untenanted volumes from leaking.
|
||||
func volumeVisibleTo(tenantID, callerAccountID string) bool {
|
||||
|
||||
@@ -6,62 +6,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
|
||||
func TestIsInstanceVisible(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callerAccountID string
|
||||
ownerAccountID string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "same account",
|
||||
callerAccountID: "123456789012",
|
||||
ownerAccountID: "123456789012",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "different account",
|
||||
callerAccountID: "123456789012",
|
||||
ownerAccountID: "999999999999",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "untenanted instance visible to root",
|
||||
callerAccountID: utils.GlobalAccountID,
|
||||
ownerAccountID: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "untenanted instance hidden from non-root",
|
||||
callerAccountID: "123456789012",
|
||||
ownerAccountID: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "root accessing owned instance",
|
||||
callerAccountID: utils.GlobalAccountID,
|
||||
ownerAccountID: utils.GlobalAccountID,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "root accessing other account instance",
|
||||
callerAccountID: utils.GlobalAccountID,
|
||||
ownerAccountID: "123456789012",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isInstanceVisible(tt.callerAccountID, tt.ownerAccountID)
|
||||
if got != tt.expected {
|
||||
t.Errorf("isInstanceVisible(%q, %q) = %v, want %v",
|
||||
tt.callerAccountID, tt.ownerAccountID, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeVisibleTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/topology"
|
||||
@@ -91,9 +90,12 @@ type volumeMounterAdapter struct {
|
||||
var _ vm.VolumeMounter = (*volumeMounterAdapter)(nil)
|
||||
|
||||
// unmountSealTimeout bounds the ebs.unmount NATS request. The handler now drives
|
||||
// a synchronous block-map seal to predastore (S3 I/O), so it matches
|
||||
// viperblockd's 120s KillProcess wait rather than a bare RPC budget.
|
||||
const unmountSealTimeout = 120 * time.Second
|
||||
// a synchronous block-map seal to predastore (S3 I/O), which runs AFTER
|
||||
// viperblockd's SIGKILL once its 120s KillProcess grace elapses — so this
|
||||
// must strictly exceed that grace plus a seal allowance, never merely match
|
||||
// it: an unmount that consumes the whole grace could otherwise never be
|
||||
// observed as successful.
|
||||
const unmountSealTimeout = 180 * time.Second
|
||||
|
||||
func newVolumeMounterAdapter(nc *nats.Conn, node string, volState vm.VolumeStateUpdater) *volumeMounterAdapter {
|
||||
return &volumeMounterAdapter{nc: nc, node: node, volState: volState}
|
||||
@@ -619,10 +621,34 @@ func (a *instanceCleanerAdapter) DeleteVolumes(instance *vm.VM) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// User-visible volumes: only delete when DeleteOnTermination is set.
|
||||
// User-visible volumes: DeleteOnTermination=false survives terminate,
|
||||
// but terminate still implies detach (AWS semantics). Only the Boot
|
||||
// volume can still be attached here — shutdownAndUnmount's Unmount
|
||||
// clears every non-Boot volume's attachment already, so a non-Boot
|
||||
// DoT=false volume is genuinely a no-op skip. A DoT=false Boot volume
|
||||
// is never touched by Unmount, so without this it would strand
|
||||
// attached to the now-terminated instance forever.
|
||||
if !ebsRequest.DeleteOnTermination {
|
||||
slog.Info("Volume has DeleteOnTermination=false, skipping deletion",
|
||||
if !ebsRequest.Boot {
|
||||
slog.Info("Volume has DeleteOnTermination=false, skipping deletion",
|
||||
"name", ebsRequest.Name, "id", instance.ID)
|
||||
continue
|
||||
}
|
||||
slog.Info("Boot volume has DeleteOnTermination=false, detaching without deleting",
|
||||
"name", ebsRequest.Name, "id", instance.ID)
|
||||
if a.d.volumeService == nil {
|
||||
slog.Warn("Volume service not configured, cannot detach volume",
|
||||
"name", ebsRequest.Name, "id", instance.ID)
|
||||
continue
|
||||
}
|
||||
if err := a.d.volumeService.DetachVolumeOnTerminate(context.Background(), ebsRequest.Name, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
|
||||
slog.Error("Failed to detach volume on termination",
|
||||
"name", ebsRequest.Name, "id", instance.ID, "err", err)
|
||||
firstErr = cmp.Or(firstErr, err)
|
||||
} else {
|
||||
slog.Info("Detached volume on termination",
|
||||
"name", ebsRequest.Name, "id", instance.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -633,9 +659,12 @@ func (a *instanceCleanerAdapter) DeleteVolumes(instance *vm.VM) error {
|
||||
"name", ebsRequest.Name, "id", instance.ID)
|
||||
continue
|
||||
}
|
||||
if _, err := a.d.volumeService.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: &ebsRequest.Name,
|
||||
}, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
|
||||
// Terminate implies detach: DeleteVolumeOnTerminate clears the stale
|
||||
// AttachedInstance before deleting. shutdownAndUnmount's Unmount
|
||||
// (above, via terminateCleanup) deliberately never clears a Boot
|
||||
// volume's attachment, so without this the root volume still hits
|
||||
// DeleteVolume's in-use guard here.
|
||||
if err := a.d.volumeService.DeleteVolumeOnTerminate(context.Background(), ebsRequest.Name, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
|
||||
slog.Error("Failed to delete volume on termination",
|
||||
"name", ebsRequest.Name, "id", instance.ID, "err", err)
|
||||
firstErr = cmp.Or(firstErr, err)
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"sync"
|
||||
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// bedrockCredentialsBucket is the cluster-replicated KV bucket holding
|
||||
@@ -26,13 +27,13 @@ func credentialKey(accountID, vendor string) string {
|
||||
// bedrock-credentials JetStream KV bucket, falling back to a platform-wide
|
||||
// default when an account has none. Keys are encrypted at rest.
|
||||
type CredentialStore struct {
|
||||
js nats.JetStreamContext
|
||||
js jetstream.JetStream
|
||||
masterKey []byte
|
||||
replicas int
|
||||
platformDefaults map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
kv nats.KeyValue
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
var _ CredentialResolver = (*CredentialStore)(nil)
|
||||
@@ -40,7 +41,7 @@ var _ CredentialResolver = (*CredentialStore)(nil)
|
||||
// NewCredentialStore constructs a CredentialStore. platformDefaults maps
|
||||
// vendor to a platform-wide API key used when an account has no key of its
|
||||
// own; pass an empty map to disable platform defaults entirely.
|
||||
func NewCredentialStore(js nats.JetStreamContext, masterKey []byte, replicas int, platformDefaults map[string]string) *CredentialStore {
|
||||
func NewCredentialStore(js jetstream.JetStream, masterKey []byte, replicas int, platformDefaults map[string]string) *CredentialStore {
|
||||
return &CredentialStore{
|
||||
js: js,
|
||||
masterKey: masterKey,
|
||||
@@ -49,23 +50,17 @@ func NewCredentialStore(js nats.JetStreamContext, masterKey []byte, replicas int
|
||||
}
|
||||
}
|
||||
|
||||
// bucket lazily opens (or creates) the bedrock-credentials KV bucket,
|
||||
// caching the handle, mirroring gateway_ecrauth.openSigningBucket.
|
||||
func (s *CredentialStore) bucket() (nats.KeyValue, error) {
|
||||
// bucket lazily opens (or creates) the cluster-replicated bedrock-credentials
|
||||
// KV bucket, caching the handle for subsequent calls.
|
||||
func (s *CredentialStore) bucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.kv != nil {
|
||||
return s.kv, nil
|
||||
}
|
||||
kv, err := s.js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: bedrockCredentialsBucket,
|
||||
History: bedrockCredentialsHistory,
|
||||
Replicas: s.replicas,
|
||||
})
|
||||
kv, err := kvutil.GetOrCreateBucketWithReplicas(ctx, s.js, bedrockCredentialsBucket, bedrockCredentialsHistory, s.replicas)
|
||||
if err != nil {
|
||||
if kv, err = s.js.KeyValue(bedrockCredentialsBucket); err != nil {
|
||||
return nil, fmt.Errorf("open %s bucket: %w", bedrockCredentialsBucket, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.kv = kv
|
||||
return kv, nil
|
||||
@@ -75,13 +70,13 @@ func (s *CredentialStore) bucket() (nats.KeyValue, error) {
|
||||
// stored, else the platform default, else ("", false, nil). A nil js is
|
||||
// tolerated so a store built with only platformDefaults (e.g. in tests) can
|
||||
// still serve the default-only path without touching JetStream.
|
||||
func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (string, bool, error) {
|
||||
func (s *CredentialStore) Resolve(ctx context.Context, accountID, vendor string) (string, bool, error) {
|
||||
if s.js != nil {
|
||||
kv, err := s.bucket()
|
||||
kv, err := s.bucket(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
entry, err := kv.Get(credentialKey(accountID, vendor))
|
||||
entry, err := kv.Get(ctx, credentialKey(accountID, vendor))
|
||||
switch {
|
||||
case err == nil:
|
||||
plaintext, err := handlers_iam.DecryptSecret(string(entry.Value()), s.masterKey)
|
||||
@@ -89,7 +84,7 @@ func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (
|
||||
return "", false, fmt.Errorf("decrypt credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
return plaintext, true, nil
|
||||
case !errors.Is(err, nats.ErrKeyNotFound):
|
||||
case !errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return "", false, fmt.Errorf("kv get credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
}
|
||||
@@ -100,8 +95,8 @@ func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (
|
||||
}
|
||||
|
||||
// PutCredential encrypts and stores key as accountID's vendor credential.
|
||||
func (s *CredentialStore) PutCredential(_ context.Context, accountID, vendor, key string) error {
|
||||
kv, err := s.bucket()
|
||||
func (s *CredentialStore) PutCredential(ctx context.Context, accountID, vendor, key string) error {
|
||||
kv, err := s.bucket(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,7 +104,7 @@ func (s *CredentialStore) PutCredential(_ context.Context, accountID, vendor, ke
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
if _, err := kv.Put(credentialKey(accountID, vendor), []byte(ciphertext)); err != nil {
|
||||
if _, err := kv.Put(ctx, credentialKey(accountID, vendor), []byte(ciphertext)); err != nil {
|
||||
return fmt.Errorf("kv put credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -17,7 +17,7 @@ func fixedMasterKey() []byte {
|
||||
}
|
||||
|
||||
// TestCredentialStore_Resolve_NilJS covers the platform-default and
|
||||
// vendor-miss paths of a store built with a nil JetStream context (as a
|
||||
// vendor-miss paths of a store built with a nil JetStream handle (as a
|
||||
// live NATS server is out of scope here). Resolve must consult
|
||||
// platformDefaults without dereferencing js.
|
||||
func TestCredentialStore_Resolve_NilJS(t *testing.T) {
|
||||
@@ -62,8 +62,8 @@ func TestNoopCredentialResolver_ResolvesNothing(t *testing.T) {
|
||||
// bucket (lazy create), credentialKey, PutCredential (encrypt+Put), and the
|
||||
// KV-hit branch of Resolve (Get+decrypt).
|
||||
func TestCredentialStore_PutAndResolve_KV(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(js, fixedMasterKey(), 1, nil)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.PutCredential(ctx, "000000000001", "anthropic", "sk-test"))
|
||||
@@ -75,10 +75,10 @@ func TestCredentialStore_PutAndResolve_KV(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault covers a KV
|
||||
// miss (nats.ErrKeyNotFound) that falls through to the platform default.
|
||||
// miss (jetstream.ErrKeyNotFound) that falls through to the platform default.
|
||||
func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(js, fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"})
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"})
|
||||
|
||||
key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic")
|
||||
require.NoError(t, err)
|
||||
@@ -89,8 +89,8 @@ func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T)
|
||||
// TestCredentialStore_Resolve_KVMissNoPlatformDefault covers a KV miss on an
|
||||
// unknown account with no platform defaults configured: ("", false, nil).
|
||||
func TestCredentialStore_Resolve_KVMissNoPlatformDefault(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(js, fixedMasterKey(), 1, nil)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil)
|
||||
|
||||
key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -110,7 +110,7 @@ var ec2Actions = map[string]EC2Handler{
|
||||
return gw.checkPolicyResource(r, "iam", "PassRole", roleARN)
|
||||
}
|
||||
launchQuotaCheck := func() error {
|
||||
return gw.Quota.EnforceLaunch(accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
|
||||
return gw.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
|
||||
}
|
||||
reservation, err := gateway_ec2_instance.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
|
||||
if err != nil {
|
||||
@@ -118,7 +118,7 @@ var ec2Actions = map[string]EC2Handler{
|
||||
}
|
||||
// Charge the actual launched vCPUs; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the already-successful launch.
|
||||
if err := gw.Quota.ChargeLaunch(accountID, &reservation); err != nil {
|
||||
if err := gw.Quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
|
||||
slog.WarnContext(ctx, "RunInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return reservation, nil
|
||||
@@ -166,7 +166,7 @@ var ec2Actions = map[string]EC2Handler{
|
||||
var delta int
|
||||
if input.InstanceType != nil {
|
||||
resolve := handlers_quota.NATSInstanceTypeResolver(gw.NATSConn, func() int { return gw.ExpectedNodes })
|
||||
d, err := gw.Quota.EnforceRetype(resolve, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
|
||||
d, err := gw.Quota.EnforceRetype(ctx, resolve, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -178,7 +178,7 @@ var ec2Actions = map[string]EC2Handler{
|
||||
}
|
||||
// Charge the retype's vCPU growth; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the applied retype.
|
||||
if err := gw.Quota.AddVCPU(accountID, delta); err != nil {
|
||||
if err := gw.Quota.AddVCPU(ctx, accountID, delta); err != nil {
|
||||
slog.WarnContext(ctx, "ModifyInstanceAttribute: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -101,7 +101,7 @@ func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *n
|
||||
}
|
||||
// Hash before any mutation so the same token+params always matches.
|
||||
paramHash := clientTokenParamHash(input)
|
||||
return runInstancesWithClientToken(store, accountID, token, paramHash, func() (ec2.Reservation, error) {
|
||||
return runInstancesWithClientToken(ctx, store, accountID, token, paramHash, func() (ec2.Reservation, error) {
|
||||
return runInstancesInner(ctx, input, natsConn, iamSvc, accountID, passRoleCheck, launchQuotaCheck, expectedNodes)
|
||||
})
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
|
||||
reservationPtr, err := distributeInstances(ctx, input, natsConn, accountID, expectedNodes)
|
||||
if err != nil {
|
||||
// Distinguish "unknown type" from "no capacity" via DescribeInstanceTypes.
|
||||
if err.Error() == awserrors.ErrorInsufficientInstanceCapacity {
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
|
||||
if !isKnownInstanceType(ctx, natsConn, *input.InstanceType) {
|
||||
return reservation, errors.New(awserrors.ErrorInvalidInstanceType)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,7 +55,7 @@ type tokenRecord struct {
|
||||
// ClientTokenStore implements RunInstances ClientToken idempotency over a TTL KV
|
||||
// bucket. The first caller owns the launch; duplicates replay (done) or poll (in-flight).
|
||||
type ClientTokenStore struct {
|
||||
kv nats.KeyValue
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -66,20 +67,24 @@ var (
|
||||
// getClientTokenStore lazily initialises the process-wide client-token store via sync.Once.
|
||||
func getClientTokenStore(ctx context.Context, nc *nats.Conn) (*ClientTokenStore, error) {
|
||||
ctOnce.Do(func() {
|
||||
js, err := nc.JetStream()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
errCTStoreInit = fmt.Errorf("clienttoken jetstream: %w", err)
|
||||
return
|
||||
}
|
||||
ctStore, errCTStoreInit = newClientTokenStore(js)
|
||||
// The bind happens once per process, so it must not inherit the first
|
||||
// caller's cancellation: a client that disconnects mid-open would poison
|
||||
// the store for every later launch. Deadline-free, so the open falls back
|
||||
// to the JetStream API's own timeout.
|
||||
ctStore, errCTStoreInit = newClientTokenStore(context.WithoutCancel(ctx), js)
|
||||
})
|
||||
return ctStore, errCTStoreInit
|
||||
}
|
||||
|
||||
func newClientTokenStore(js nats.JetStreamContext) (*ClientTokenStore, error) {
|
||||
kv, err := js.KeyValue(kvBucketClientTokens)
|
||||
if errors.Is(err, nats.ErrBucketNotFound) {
|
||||
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
func newClientTokenStore(ctx context.Context, js jetstream.JetStream) (*ClientTokenStore, error) {
|
||||
kv, err := js.KeyValue(ctx, kvBucketClientTokens)
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: kvBucketClientTokens,
|
||||
History: 1,
|
||||
TTL: clientTokenTTL,
|
||||
@@ -112,7 +117,7 @@ func clientTokenParamHash(input *ec2.RunInstancesInput) string {
|
||||
// Claim attempts to own the launch for this token.
|
||||
// Returns (nil, true, nil) when the caller owns the launch (must Finalize or Abort),
|
||||
// (reservation, false, nil) to replay a completed launch, or an error on mismatch/timeout.
|
||||
func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reservation, bool, error) {
|
||||
func (c *ClientTokenStore) Claim(ctx context.Context, accountID, token, paramHash string) (*ec2.Reservation, bool, error) {
|
||||
key := clientTokenKey(accountID, token)
|
||||
inflight, err := json.Marshal(tokenRecord{
|
||||
Status: tokenStatusInFlight,
|
||||
@@ -122,21 +127,21 @@ func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reser
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("clienttoken marshal: %w", err)
|
||||
}
|
||||
if _, cerr := c.kv.Create(key, inflight); cerr == nil {
|
||||
if _, cerr := c.kv.Create(ctx, key, inflight); cerr == nil {
|
||||
return nil, true, nil
|
||||
} else if !errors.Is(cerr, nats.ErrKeyExists) {
|
||||
} else if !errors.Is(cerr, jetstream.ErrKeyExists) {
|
||||
return nil, false, fmt.Errorf("clienttoken create %s: %w", key, cerr)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(clientTokenWaitTimeout)
|
||||
for {
|
||||
entry, gerr := c.kv.Get(key)
|
||||
entry, gerr := c.kv.Get(ctx, key)
|
||||
if gerr != nil {
|
||||
if errors.Is(gerr, nats.ErrKeyNotFound) {
|
||||
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
// Owner aborted or record aged out: race for ownership.
|
||||
if _, rcerr := c.kv.Create(key, inflight); rcerr == nil {
|
||||
if _, rcerr := c.kv.Create(ctx, key, inflight); rcerr == nil {
|
||||
return nil, true, nil
|
||||
} else if !errors.Is(rcerr, nats.ErrKeyExists) {
|
||||
} else if !errors.Is(rcerr, jetstream.ErrKeyExists) {
|
||||
return nil, false, fmt.Errorf("clienttoken recreate %s: %w", key, rcerr)
|
||||
}
|
||||
continue
|
||||
@@ -161,7 +166,7 @@ func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reser
|
||||
}
|
||||
|
||||
// Finalize marks the token done with the reservation so duplicates can replay it.
|
||||
func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2.Reservation) error {
|
||||
func (c *ClientTokenStore) Finalize(ctx context.Context, accountID, token, paramHash string, res *ec2.Reservation) error {
|
||||
key := clientTokenKey(accountID, token)
|
||||
data, err := json.Marshal(tokenRecord{
|
||||
Status: tokenStatusDone,
|
||||
@@ -172,7 +177,7 @@ func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2
|
||||
if err != nil {
|
||||
return fmt.Errorf("clienttoken finalize marshal: %w", err)
|
||||
}
|
||||
if _, err := c.kv.Put(key, data); err != nil {
|
||||
if _, err := c.kv.Put(ctx, key, data); err != nil {
|
||||
return fmt.Errorf("clienttoken finalize put %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
@@ -182,12 +187,13 @@ func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2
|
||||
// claims the token, replays a completed reservation, or (as owner) launches,
|
||||
// finalizes, and aborts on failure. Extracted for unit-testability.
|
||||
func runInstancesWithClientToken(
|
||||
ctx context.Context,
|
||||
store *ClientTokenStore,
|
||||
accountID, token, paramHash string,
|
||||
launch func() (ec2.Reservation, error),
|
||||
) (ec2.Reservation, error) {
|
||||
var zero ec2.Reservation
|
||||
replay, owned, cerr := store.Claim(accountID, token, paramHash)
|
||||
replay, owned, cerr := store.Claim(ctx, accountID, token, paramHash)
|
||||
if cerr != nil {
|
||||
if errors.Is(cerr, errIdempotentParamMismatch) {
|
||||
return zero, errors.New(awserrors.ErrorIdempotentParameterMismatch)
|
||||
@@ -203,11 +209,16 @@ func runInstancesWithClientToken(
|
||||
}
|
||||
|
||||
res, rerr := launch()
|
||||
|
||||
// Recording the launch outcome outlives ctx: a caller that went away mid-launch
|
||||
// is exactly when the record must be settled, and leaving it in-flight parks
|
||||
// every retry of that token behind the poll deadline until the record ages out.
|
||||
outcomeCtx := context.WithoutCancel(ctx)
|
||||
if rerr != nil {
|
||||
store.Abort(accountID, token)
|
||||
store.Abort(outcomeCtx, accountID, token)
|
||||
return zero, rerr
|
||||
}
|
||||
if ferr := store.Finalize(accountID, token, paramHash, &res); ferr != nil {
|
||||
if ferr := store.Finalize(outcomeCtx, accountID, token, paramHash, &res); ferr != nil {
|
||||
// Launch succeeded; finalize failure only weakens future dedup — don't fail.
|
||||
slog.Warn("RunInstances: failed to finalize client-token record", "token", token, "err", ferr)
|
||||
}
|
||||
@@ -215,9 +226,9 @@ func runInstancesWithClientToken(
|
||||
}
|
||||
|
||||
// Abort drops the in-flight token so a retry can re-launch.
|
||||
func (c *ClientTokenStore) Abort(accountID, token string) {
|
||||
func (c *ClientTokenStore) Abort(ctx context.Context, accountID, token string) {
|
||||
key := clientTokenKey(accountID, token)
|
||||
if err := c.kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
|
||||
if err := c.kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
slog.Warn("clienttoken: failed to abort token record", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -20,8 +19,8 @@ const ctTestAccount = "111122223333"
|
||||
|
||||
func newTestClientTokenStore(t *testing.T) *ClientTokenStore {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store, err := newClientTokenStore(js)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
store, err := newClientTokenStore(t.Context(), testutil.NewJetStream(t, nc))
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
@@ -32,14 +31,14 @@ func TestClientToken_ReplaysCompletedReservation(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok, hash = "tok-1", "hash-a"
|
||||
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, hash)
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned, "first caller owns the launch")
|
||||
|
||||
res := &ec2.Reservation{ReservationId: aws.String("r-123")}
|
||||
require.NoError(t, store.Finalize(ctTestAccount, tok, hash, res))
|
||||
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, hash, res))
|
||||
|
||||
replay, owned2, err := store.Claim(ctTestAccount, tok, hash)
|
||||
replay, owned2, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, owned2, "duplicate caller does not own the launch")
|
||||
require.NotNil(t, replay)
|
||||
@@ -51,12 +50,12 @@ func TestClientToken_ParamMismatchRejected(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok = "tok-2"
|
||||
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, "hash-a")
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, "hash-a")
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned)
|
||||
require.NoError(t, store.Finalize(ctTestAccount, tok, "hash-a", &ec2.Reservation{ReservationId: aws.String("r-1")}))
|
||||
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, "hash-a", &ec2.Reservation{ReservationId: aws.String("r-1")}))
|
||||
|
||||
_, _, err = store.Claim(ctTestAccount, tok, "hash-DIFFERENT")
|
||||
_, _, err = store.Claim(t.Context(), ctTestAccount, tok, "hash-DIFFERENT")
|
||||
assert.ErrorIs(t, err, errIdempotentParamMismatch)
|
||||
}
|
||||
|
||||
@@ -66,13 +65,13 @@ func TestClientToken_AbortAllowsRelaunch(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok, hash = "tok-3", "hash-a"
|
||||
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, hash)
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned)
|
||||
|
||||
store.Abort(ctTestAccount, tok)
|
||||
store.Abort(t.Context(), ctTestAccount, tok)
|
||||
|
||||
_, owned2, err := store.Claim(ctTestAccount, tok, hash)
|
||||
_, owned2, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, owned2, "after abort the token is free to re-own")
|
||||
}
|
||||
@@ -93,7 +92,7 @@ func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
replay, owned, err := store.Claim(ctTestAccount, tok, hash)
|
||||
replay, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
@@ -101,7 +100,7 @@ func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
|
||||
if owned {
|
||||
atomic.AddInt32(&owners, 1)
|
||||
// Simulate the launch then publish the reservation.
|
||||
errs[i] = store.Finalize(ctTestAccount, tok, hash, res)
|
||||
errs[i] = store.Finalize(t.Context(), ctTestAccount, tok, hash, res)
|
||||
replays[i] = res
|
||||
return
|
||||
}
|
||||
@@ -147,13 +146,13 @@ func TestClientTokenParamHash_IgnoresTokenReflectsParams(t *testing.T) {
|
||||
func TestRunInstancesWithClientToken_ReplaySkipsLaunch(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok, hash = "rt-1", "h"
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, hash)
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned)
|
||||
require.NoError(t, store.Finalize(ctTestAccount, tok, hash, &ec2.Reservation{ReservationId: aws.String("r-x")}))
|
||||
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, hash, &ec2.Reservation{ReservationId: aws.String("r-x")}))
|
||||
|
||||
launched := false
|
||||
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
launched = true
|
||||
return ec2.Reservation{}, nil
|
||||
})
|
||||
@@ -172,11 +171,11 @@ func TestRunInstancesWithClientToken_OwnerLaunchesOnceThenReplay(t *testing.T) {
|
||||
return ec2.Reservation{ReservationId: aws.String("r-own")}, nil
|
||||
}
|
||||
|
||||
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, launch)
|
||||
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, launch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "r-own", aws.StringValue(res.ReservationId))
|
||||
|
||||
res2, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, launch)
|
||||
res2, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, launch)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "r-own", aws.StringValue(res2.ReservationId))
|
||||
assert.Equal(t, 1, launches, "duplicate must replay, not relaunch")
|
||||
@@ -187,13 +186,13 @@ func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok, hash = "rt-3", "h"
|
||||
|
||||
_, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
_, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
return ec2.Reservation{}, errors.New("no capacity")
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
relaunched := false
|
||||
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
|
||||
relaunched = true
|
||||
return ec2.Reservation{ReservationId: aws.String("r-retry")}, nil
|
||||
})
|
||||
@@ -207,13 +206,13 @@ func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
|
||||
func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok = "rt-4"
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, "hA")
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, "hA")
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned)
|
||||
require.NoError(t, store.Finalize(ctTestAccount, tok, "hA", &ec2.Reservation{ReservationId: aws.String("r")}))
|
||||
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, "hA", &ec2.Reservation{ReservationId: aws.String("r")}))
|
||||
|
||||
launched := false
|
||||
_, err = runInstancesWithClientToken(store, ctTestAccount, tok, "hB", func() (ec2.Reservation, error) {
|
||||
_, err = runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, "hB", func() (ec2.Reservation, error) {
|
||||
launched = true
|
||||
return ec2.Reservation{}, nil
|
||||
})
|
||||
@@ -225,10 +224,10 @@ func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
|
||||
// instance on subsequent calls.
|
||||
func TestGetClientTokenStore_BindsOnce(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
s1, err := getClientTokenStore(context.Background(), nc)
|
||||
s1, err := getClientTokenStore(t.Context(), nc)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s1)
|
||||
s2, err := getClientTokenStore(context.Background(), nc)
|
||||
s2, err := getClientTokenStore(t.Context(), nc)
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, s1, s2, "store binds once")
|
||||
}
|
||||
@@ -239,7 +238,7 @@ func TestClientToken_InFlightWaitTimesOut(t *testing.T) {
|
||||
store := newTestClientTokenStore(t)
|
||||
const tok, hash = "wt-1", "h"
|
||||
|
||||
_, owned, err := store.Claim(ctTestAccount, tok, hash)
|
||||
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
require.NoError(t, err)
|
||||
require.True(t, owned, "owner holds the in-flight record and never finalizes")
|
||||
|
||||
@@ -247,6 +246,6 @@ func TestClientToken_InFlightWaitTimesOut(t *testing.T) {
|
||||
clientTokenWaitTimeout, clientTokenPollStep = 30*time.Millisecond, 10*time.Millisecond
|
||||
defer func() { clientTokenWaitTimeout, clientTokenPollStep = origTimeout, origStep }()
|
||||
|
||||
_, _, err = store.Claim(ctTestAccount, tok, hash)
|
||||
_, _, err = store.Claim(t.Context(), ctTestAccount, tok, hash)
|
||||
assert.ErrorIs(t, err, errClientTokenWaitTimeout)
|
||||
}
|
||||
|
||||
@@ -437,11 +437,11 @@ func extractClientError(results []nodeLaunchResult) error {
|
||||
if r.Err == nil {
|
||||
continue
|
||||
}
|
||||
inner := errors.Unwrap(r.Err)
|
||||
if inner == nil {
|
||||
code, ok := awserrors.ResolveErrorCode(r.Err)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch inner.Error() {
|
||||
switch code {
|
||||
case awserrors.ErrorInsufficientInstanceCapacity,
|
||||
awserrors.ErrorInvalidAMIIDNotFound,
|
||||
awserrors.ErrorInvalidAMIIDMalformed,
|
||||
@@ -451,7 +451,7 @@ func extractClientError(results []nodeLaunchResult) error {
|
||||
awserrors.ErrorInvalidGroupNotFound,
|
||||
awserrors.ErrorInvalidParameterValue,
|
||||
awserrors.ErrorSecurityGroupsPerInterfaceLimitExceeded:
|
||||
return inner
|
||||
return errors.New(code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -371,6 +371,27 @@ func TestExtractClientError_AMINotFound(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorInvalidAMIIDNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestExtractClientError_NestedWrappedCode(t *testing.T) {
|
||||
inner := errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
wrapped := fmt.Errorf("launch on node-1: %w", fmt.Errorf("prepare instance: %w", inner))
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: wrapped},
|
||||
}
|
||||
|
||||
err := extractClientError(results)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestExtractClientError_JoinedCode(t *testing.T) {
|
||||
joined := errors.Join(assert.AnError, errors.New(awserrors.ErrorInvalidGroupNotFound))
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: fmt.Errorf("launch on node-1: %w", joined)},
|
||||
}
|
||||
|
||||
err := extractClientError(results)
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidGroupNotFound)
|
||||
}
|
||||
|
||||
func TestExtractClientError_KeyPairNotFound(t *testing.T) {
|
||||
inner := errors.New(awserrors.ErrorInvalidKeyPairNotFound)
|
||||
wrapped := fmt.Errorf("launch on node-1: %w", inner)
|
||||
|
||||
@@ -76,7 +76,7 @@ func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesIn
|
||||
// Gate on the per-account vCPU cap exactly like the on-demand path; spot
|
||||
// launches are real VMs and must not slip past the quota.
|
||||
launchQuotaCheck := func() error {
|
||||
return quota.EnforceLaunch(accountID, aws.StringValue(runInput.InstanceType), int(count))
|
||||
return quota.EnforceLaunch(ctx, accountID, aws.StringValue(runInput.InstanceType), int(count))
|
||||
}
|
||||
|
||||
// RunInstances normalises runInput in place (e.g. instance profile to ARN),
|
||||
@@ -88,7 +88,7 @@ func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesIn
|
||||
|
||||
// Charge the actual launched vCPUs; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the already-successful launch.
|
||||
if err := quota.ChargeLaunch(accountID, &reservation); err != nil {
|
||||
if err := quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
|
||||
slog.WarnContext(ctx, "RequestSpotInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,8 @@ func TestListImages_TaggedAndUntagged(t *testing.T) {
|
||||
// canonicalize on meta.Digest to emit a real digest and keep tag association.
|
||||
// The memory store returns real digests and would mask this.
|
||||
func TestListImages_KVDigestForm(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
reg := NewRegistry(objectstore.NewMemoryObjectStore(), ecr.NewKVMetaStore(js), testAccount)
|
||||
repo := "team/app"
|
||||
require.NoError(t, reg.Meta.PutRepo(context.Background(), testAccount, ecr.RepoMeta{Name: repo}))
|
||||
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
// subjects against an embedded KV-backed service, returning a gateway client.
|
||||
func newRepoLifecycleGateway(t *testing.T) (*GatewayConfig, *nats.Conn) {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
svc := handlers_ecr.NewKVMetaService(js)
|
||||
serveECRMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
|
||||
serveECRMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
|
||||
|
||||
@@ -42,7 +42,8 @@ func serveECRMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn
|
||||
|
||||
func newDescribeReposGateway(t *testing.T, repos ...string) *GatewayConfig {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
svc := handlers_ecr.NewKVMetaService(js)
|
||||
serveECRMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
|
||||
serveECRMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
|
||||
|
||||
@@ -20,7 +20,8 @@ const validLifecyclePolicy = `{"rules":[{"rulePriority":1,"selection":{"tagStatu
|
||||
// lifecycle metadata subjects served, returning the gateway NATS connection.
|
||||
func newLifecycleTestConn(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
svc := handlers_ecr.NewKVMetaService(js)
|
||||
serveMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
|
||||
serveMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
|
||||
|
||||
@@ -45,7 +45,8 @@ func serveMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn fun
|
||||
// metadata subjects served, returning the gateway NATS connection.
|
||||
func newPolicyTestConn(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
svc := handlers_ecr.NewKVMetaService(js)
|
||||
serveMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
|
||||
serveMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
|
||||
|
||||
@@ -37,42 +37,46 @@ func samplePrincipal() Principal {
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSigningKey_CreatesThenReloads(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
|
||||
key1, verify1, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
key1, verify1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, key1.Kid)
|
||||
require.Contains(t, verify1, key1.Kid)
|
||||
|
||||
// Second call must reload the same persisted key, not mint a new one.
|
||||
key2, verify2, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
key2, verify2, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key1.Kid, key2.Kid, "persisted signing key must be reused")
|
||||
assert.Len(t, verify2, 1)
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSigningKey_StoresEncrypted(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
kv, err := js.KeyValue(SigningBucket)
|
||||
kv, err := js.KeyValue(t.Context(), SigningBucket)
|
||||
require.NoError(t, err)
|
||||
entry, err := kv.Get(signingKeyName(key.Kid))
|
||||
entry, err := kv.Get(t.Context(), signingKeyName(key.Kid))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(entry.Value()), "PRIVATE KEY",
|
||||
"signing key must be encrypted at rest")
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSigningKey_EmptyMasterKeyRejected(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, _, err := LoadOrCreateSigningKey(js, nil, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
_, _, err := LoadOrCreateSigningKey(t.Context(), js, nil, 1)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIssuerVerifier_RoundTrip(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
iss := NewIssuer(key, testAudience)
|
||||
@@ -88,8 +92,9 @@ func TestIssuerVerifier_RoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIssuer_MintRequiresAccountID(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
p := samplePrincipal()
|
||||
@@ -103,8 +108,9 @@ func TestIssuer_MintRequiresAccountID(t *testing.T) {
|
||||
// accessKeyID, an unsupported principalType) must fail to mint at all, since
|
||||
// a token that can't name a lookup key can never be safely rehydrated.
|
||||
func TestMint_RequiresCompleteIdentityPointer(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
iss := NewIssuer(key, testAudience)
|
||||
|
||||
@@ -136,8 +142,9 @@ func TestSupportedPrincipalType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifier_RejectsWrongAudience(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal())
|
||||
@@ -148,8 +155,9 @@ func TestVerifier_RejectsWrongAudience(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifier_RejectsUnknownKid(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal())
|
||||
@@ -161,8 +169,9 @@ func TestVerifier_RejectsUnknownKid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifier_RejectsExpiredToken(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Hand-mint an already-expired token with the same signing key.
|
||||
@@ -188,8 +197,9 @@ func TestVerifier_RejectsExpiredToken(t *testing.T) {
|
||||
// Mint's own checks, proving Verify itself refuses a claim set missing any
|
||||
// part of the identity pointer even if it were signed by some other path.
|
||||
func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
base := func() Claims {
|
||||
@@ -234,8 +244,9 @@ func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifier_RejectsNonES256(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
_, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// HS256 token must be refused regardless of signature.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Rotation defaults (ecr-v1 Q3): rotate the active signing key every 30 days,
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
DefaultRetainFor = 24 * time.Hour
|
||||
DefaultRetainCount = 2
|
||||
DefaultRotationCheckInterval = time.Hour
|
||||
|
||||
// rotationCycleTimeout bounds the KV work of one rotation cycle, so a wedged
|
||||
// JetStream stalls a single tick instead of the whole loop until shutdown.
|
||||
rotationCycleTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// rotationParams holds the rotation/retention policy.
|
||||
@@ -61,7 +65,7 @@ func planRotation(metas []keyMeta, now time.Time, p rotationParams) (mint bool,
|
||||
// safe to run on every awsgw instance — mint and delete on the shared KV bucket
|
||||
// converge, and a delete of an already-gone key is a no-op.
|
||||
type Rotator struct {
|
||||
kv nats.KeyValue
|
||||
kv jetstream.KeyValue
|
||||
masterKey []byte
|
||||
issuer *Issuer
|
||||
verifier *Verifier
|
||||
@@ -73,8 +77,8 @@ type Rotator struct {
|
||||
// NewRotator opens the signing-key bucket and builds a rotator that keeps issuer
|
||||
// and verifier current. replicas matches the cluster size for first-run bucket
|
||||
// creation.
|
||||
func NewRotator(js nats.JetStreamContext, masterKey []byte, replicas int, issuer *Issuer, verifier *Verifier) (*Rotator, error) {
|
||||
kv, err := openSigningBucket(js, masterKey, replicas)
|
||||
func NewRotator(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int, issuer *Issuer, verifier *Verifier) (*Rotator, error) {
|
||||
kv, err := openSigningBucket(ctx, js, masterKey, replicas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,15 +109,17 @@ func (r *Rotator) Run(ctx context.Context) {
|
||||
slog.Info("ECR signing-key rotator stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.rotateOnce(r.now())
|
||||
cycleCtx, cancel := context.WithTimeout(ctx, rotationCycleTimeout)
|
||||
r.rotateOnce(cycleCtx, r.now())
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rotateOnce runs one rotation/prune cycle and refreshes the issuer/verifier.
|
||||
// Errors are logged and skipped so a transient KV fault never crashes the loop.
|
||||
func (r *Rotator) rotateOnce(now time.Time) {
|
||||
_, _, metas, err := reloadKeys(r.kv, r.masterKey)
|
||||
func (r *Rotator) rotateOnce(ctx context.Context, now time.Time) {
|
||||
_, _, metas, err := reloadKeys(ctx, r.kv, r.masterKey)
|
||||
if err != nil {
|
||||
slog.Warn("ECR signing-key rotation: load keys failed", "err", err)
|
||||
return
|
||||
@@ -121,7 +127,7 @@ func (r *Rotator) rotateOnce(now time.Time) {
|
||||
|
||||
mint, prune := planRotation(metas, now, r.params)
|
||||
if mint {
|
||||
newKey, err := generateSigningKey(r.kv, r.masterKey)
|
||||
newKey, err := generateSigningKey(ctx, r.kv, r.masterKey)
|
||||
if err != nil {
|
||||
slog.Warn("ECR signing-key rotation: mint failed", "err", err)
|
||||
return
|
||||
@@ -129,19 +135,19 @@ func (r *Rotator) rotateOnce(now time.Time) {
|
||||
slog.Info("ECR signing-key rotated", "kid", newKey.Kid)
|
||||
}
|
||||
for _, kid := range prune {
|
||||
if err := deleteSigningKey(r.kv, kid); err != nil {
|
||||
if err := deleteSigningKey(ctx, r.kv, kid); err != nil {
|
||||
slog.Warn("ECR signing-key rotation: prune failed", "kid", kid, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("ECR signing-key pruned", "kid", kid)
|
||||
}
|
||||
|
||||
r.refresh()
|
||||
r.refresh(ctx)
|
||||
}
|
||||
|
||||
// refresh reloads the canonical key set and installs it on the issuer/verifier.
|
||||
func (r *Rotator) refresh() {
|
||||
active, verify, _, err := reloadKeys(r.kv, r.masterKey)
|
||||
func (r *Rotator) refresh(ctx context.Context) {
|
||||
active, verify, _, err := reloadKeys(ctx, r.kv, r.masterKey)
|
||||
if err != nil {
|
||||
slog.Warn("ECR signing-key rotation: refresh failed", "err", err)
|
||||
return
|
||||
|
||||
@@ -77,13 +77,14 @@ func TestPlanRotation(t *testing.T) {
|
||||
// always due: a new active key is minted, installed on the issuer, and accepted
|
||||
// by the verifier, while the prior key stays verifiable.
|
||||
func TestRotateOnce_MintsAndSwaps(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
issuer := NewIssuer(key, testAudience)
|
||||
verifier := NewVerifier(verify, testAudience)
|
||||
kv, err := openSigningBucket(js, testMasterKey, 1)
|
||||
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token minted under the original key before rotation.
|
||||
@@ -95,7 +96,7 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
|
||||
params: rotationParams{rotateAfter: 0, retainFor: time.Hour, retainCount: 2},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
r.rotateOnce(time.Now().UTC())
|
||||
r.rotateOnce(t.Context(), time.Now().UTC())
|
||||
|
||||
// A new active key took over.
|
||||
assert.NotEqual(t, key.Kid, issuer.ActiveKid(), "issuer should sign with the rotated key")
|
||||
@@ -108,7 +109,7 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
|
||||
_, err = verifier.Verify(newTok)
|
||||
require.NoError(t, err, "token from the rotated key must verify")
|
||||
|
||||
_, _, metas, err := reloadKeys(kv, testMasterKey)
|
||||
_, _, metas, err := reloadKeys(t.Context(), kv, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, metas, 2, "previous key retained alongside the new active key")
|
||||
}
|
||||
@@ -117,12 +118,13 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
|
||||
// retainFor=0 (and no mint) so the older key is pruned and dropped from the
|
||||
// verifier.
|
||||
func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
first, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
first, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
kv, err := openSigningBucket(js, testMasterKey, 1)
|
||||
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
second, err := generateSigningKey(kv, testMasterKey)
|
||||
second, err := generateSigningKey(t.Context(), kv, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first.Kid, second.Kid)
|
||||
|
||||
@@ -134,9 +136,9 @@ func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
|
||||
params: rotationParams{rotateAfter: 1000 * time.Hour, retainFor: 0, retainCount: 2},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
r.rotateOnce(time.Now().UTC())
|
||||
r.rotateOnce(t.Context(), time.Now().UTC())
|
||||
|
||||
active, _, metas, err := reloadKeys(kv, testMasterKey)
|
||||
active, _, metas, err := reloadKeys(t.Context(), kv, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, metas, 1, "the older key should be pruned")
|
||||
assert.Equal(t, second.Kid, active.Kid, "newest key remains active")
|
||||
@@ -147,12 +149,13 @@ func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
|
||||
// with rotateAfter=0 so it mints on the first tick, then cancels: the active key
|
||||
// changes and Run returns.
|
||||
func TestRotator_RunRotatesThenStops(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
issuer := NewIssuer(key, testAudience)
|
||||
verifier := NewVerifier(verify, testAudience)
|
||||
kv, err := openSigningBucket(js, testMasterKey, 1)
|
||||
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := &Rotator{
|
||||
@@ -180,14 +183,15 @@ func TestRotator_RunRotatesThenStops(t *testing.T) {
|
||||
// TestIssuerVerifier_ConcurrentSwap exercises the hot-swap under -race: Mint and
|
||||
// Verify run while SetActiveKey/SetKeys rotate the key set.
|
||||
func TestIssuerVerifier_ConcurrentSwap(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
k1, v1, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
k1, v1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
kv, err := openSigningBucket(js, testMasterKey, 1)
|
||||
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
|
||||
require.NoError(t, err)
|
||||
k2, err := generateSigningKey(kv, testMasterKey)
|
||||
k2, err := generateSigningKey(t.Context(), kv, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
_, v2, _, err := reloadKeys(kv, testMasterKey)
|
||||
_, v2, _, err := reloadKeys(t.Context(), kv, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
issuer := NewIssuer(k1, testAudience)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package gateway_ecrauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -19,7 +20,8 @@ import (
|
||||
"time"
|
||||
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,17 +75,17 @@ func kidFor(pub *ecdsa.PublicKey) (string, error) {
|
||||
// returns the active signing key. On first run it generates and persists one.
|
||||
// The active key is the newest by KV creation time (ties broken by kid), so all
|
||||
// nodes converge on the same signer and a freshly rotated key takes over.
|
||||
func LoadOrCreateSigningKey(js nats.JetStreamContext, masterKey []byte, replicas int) (*SigningKey, map[string]*ecdsa.PublicKey, error) {
|
||||
kv, err := openSigningBucket(js, masterKey, replicas)
|
||||
func LoadOrCreateSigningKey(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (*SigningKey, map[string]*ecdsa.PublicKey, error) {
|
||||
kv, err := openSigningBucket(ctx, js, masterKey, replicas)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
active, verify, _, err := reloadKeys(kv, masterKey)
|
||||
active, verify, _, err := reloadKeys(ctx, kv, masterKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if active == nil {
|
||||
active, err = generateSigningKey(kv, masterKey)
|
||||
active, err = generateSigningKey(ctx, kv, masterKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -94,32 +96,22 @@ func LoadOrCreateSigningKey(js nats.JetStreamContext, masterKey []byte, replicas
|
||||
|
||||
// openSigningBucket validates inputs and returns the awsgw-keys KV handle,
|
||||
// creating the cluster-replicated bucket on first use.
|
||||
func openSigningBucket(js nats.JetStreamContext, masterKey []byte, replicas int) (nats.KeyValue, error) {
|
||||
func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (jetstream.KeyValue, error) {
|
||||
if js == nil {
|
||||
return nil, errors.New("ecrauth: nil JetStream context")
|
||||
}
|
||||
if len(masterKey) == 0 {
|
||||
return nil, errors.New("ecrauth: empty master key")
|
||||
}
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: SigningBucket,
|
||||
History: signingKeyHistory,
|
||||
Replicas: replicas,
|
||||
})
|
||||
if err != nil {
|
||||
if kv, err = js.KeyValue(SigningBucket); err != nil {
|
||||
return nil, fmt.Errorf("open %s bucket: %w", SigningBucket, err)
|
||||
}
|
||||
}
|
||||
return kv, nil
|
||||
return kvutil.GetOrCreateBucketWithReplicas(ctx, js, SigningBucket, signingKeyHistory, replicas)
|
||||
}
|
||||
|
||||
// reloadKeys reads every stored signing key, returning the verify set, each
|
||||
// key's metadata, and the active (newest) decrypted key. active is nil for an
|
||||
// empty bucket. Used by both startup and the rotation scheduler.
|
||||
func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, err error) {
|
||||
names, err := kv.Keys()
|
||||
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
|
||||
func reloadKeys(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, err error) {
|
||||
names, err := kvutil.Keys(ctx, kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil, nil, fmt.Errorf("list signing keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -129,7 +121,7 @@ func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify
|
||||
if !strings.HasPrefix(name, signingKeyPrefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := kv.Get(name)
|
||||
entry, err := kv.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("kv get %s: %w", name, err)
|
||||
}
|
||||
@@ -149,8 +141,8 @@ func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify
|
||||
|
||||
// deleteSigningKey removes a rotated-out key from the bucket once its retention
|
||||
// window has elapsed.
|
||||
func deleteSigningKey(kv nats.KeyValue, kid string) error {
|
||||
if err := kv.Delete(signingKeyName(kid)); err != nil {
|
||||
func deleteSigningKey(ctx context.Context, kv jetstream.KeyValue, kid string) error {
|
||||
if err := kv.Delete(ctx, signingKeyName(kid)); err != nil {
|
||||
return fmt.Errorf("kv delete %s: %w", signingKeyName(kid), err)
|
||||
}
|
||||
return nil
|
||||
@@ -172,7 +164,7 @@ func decodeSigningKey(kid string, ciphertext []byte, masterKey []byte) (*Signing
|
||||
|
||||
// generateSigningKey creates a fresh ES256 key, persists the encrypted PEM under
|
||||
// jwt-signing/{kid}, and returns it.
|
||||
func generateSigningKey(kv nats.KeyValue, masterKey []byte) (*SigningKey, error) {
|
||||
func generateSigningKey(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (*SigningKey, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate ES256 key: %w", err)
|
||||
@@ -190,7 +182,7 @@ func generateSigningKey(kv nats.KeyValue, masterKey []byte) (*SigningKey, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt signing key: %w", err)
|
||||
}
|
||||
if _, err := kv.Put(signingKeyName(kid), []byte(ciphertext)); err != nil {
|
||||
if _, err := kv.Put(ctx, signingKeyName(kid), []byte(ciphertext)); err != nil {
|
||||
return nil, fmt.Errorf("kv put %s: %w", signingKeyName(kid), err)
|
||||
}
|
||||
return &SigningKey{Kid: kid, priv: priv}, nil
|
||||
|
||||
@@ -32,8 +32,9 @@ func ecrTestMasterKey(t *testing.T) []byte {
|
||||
// newECRAuth builds a wired issuer+verifier over a freshly generated signing key.
|
||||
func newECRAuth(t *testing.T) (*gateway_ecrauth.Issuer, *gateway_ecrauth.Verifier) {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(js, ecrTestMasterKey(t), 1)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t), 1)
|
||||
require.NoError(t, err)
|
||||
return gateway_ecrauth.NewIssuer(key, ecrTestAudience), gateway_ecrauth.NewVerifier(verify, ecrTestAudience)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -56,9 +57,11 @@ func TestWebhookTokenReview_InfraFaultIsServerInternal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebhookTokenReview_ResolvesIdentity(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
kv := testutil.SeedKV(t, js, handlers_eks.AccountBucketName("111122223333"), nil)
|
||||
require.NoError(t, handlers_eks.PutAccessEntryRecord(kv, &handlers_eks.AccessEntryRecord{
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
kv, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: handlers_eks.AccountBucketName("111122223333")})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, handlers_eks.PutAccessEntryRecord(t.Context(), kv, &handlers_eks.AccessEntryRecord{
|
||||
ClusterName: "alpha",
|
||||
PrincipalARN: testARN,
|
||||
KubernetesUsername: testARN,
|
||||
|
||||
+20
-28
@@ -550,15 +550,13 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
|
||||
|
||||
var requestId = uuid.NewString()
|
||||
var errorMsg = awserrors.ErrorMessage{}
|
||||
|
||||
if _, exists := awserrors.ErrorLookup[err.Error()]; !exists {
|
||||
code, exists := awserrors.ResolveErrorCode(err)
|
||||
if !exists {
|
||||
slog.Warn("Unknown error code", "error", err.Error())
|
||||
err = errors.New(awserrors.ErrorInternalError)
|
||||
code = awserrors.ErrorInternalError
|
||||
}
|
||||
|
||||
errorMsg = awserrors.ErrorLookup[err.Error()]
|
||||
|
||||
errorMsg := awserrors.ErrorLookup[code]
|
||||
if errorMsg.HTTPCode == 0 {
|
||||
errorMsg.HTTPCode = 500
|
||||
}
|
||||
@@ -566,8 +564,8 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
// EKS, ECR, ACM, ECS, tagging, and bedrock/bedrock-runtime use AWS JSON 1.1;
|
||||
// query/XML services fall through.
|
||||
if svc == "eks" || svc == "ecr" || svc == "acm" || svc == "ecs" || svc == "tagging" || svc == "bedrock" || svc == "bedrock-runtime" {
|
||||
body := GenerateEKSErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
slog.Debug("Generated JSON error response", "service", svc, "error", err.Error(), "json", string(body), "requestId", requestId)
|
||||
body := GenerateEKSErrorResponse(code, errorMsg.Message, requestId)
|
||||
slog.Debug("Generated JSON error response", "service", svc, "error", err, "code", code, "json", string(body), "requestId", requestId)
|
||||
w.Header().Set("Content-Type", eksJSONContentType)
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
@@ -578,12 +576,12 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
|
||||
var xmlError []byte
|
||||
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" {
|
||||
xmlError = GenerateIAMErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
xmlError = GenerateIAMErrorResponse(code, errorMsg.Message, requestId)
|
||||
} else {
|
||||
xmlError = GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
|
||||
xmlError = GenerateEC2ErrorResponse(code, errorMsg.Message, requestId)
|
||||
}
|
||||
|
||||
slog.Debug("Generated error response", "error", err.Error(), "xml", string(xmlError), "requestId", requestId)
|
||||
slog.Debug("Generated error response", "error", err, "code", code, "xml", string(xmlError), "requestId", requestId)
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(errorMsg.HTTPCode)
|
||||
@@ -608,23 +606,17 @@ func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
// ParseAWSQueryArgs parses an AWS query-protocol body. Returns an error on
|
||||
// invalid percent-encoding so callers can surface MalformedQueryString.
|
||||
func ParseAWSQueryArgs(query string) (map[string]string, error) {
|
||||
params := make(map[string]string)
|
||||
pairs := strings.SplitSeq(query, "&")
|
||||
for pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
key, err := url.QueryUnescape(kv[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL encoding in parameter name: %w", err)
|
||||
}
|
||||
if len(kv) == 2 {
|
||||
value, err := url.QueryUnescape(kv[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL encoding in value for %q: %w", key, err)
|
||||
}
|
||||
params[key] = value
|
||||
} else {
|
||||
params[key] = ""
|
||||
}
|
||||
values, err := url.ParseQuery(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid AWS query string: %w", err)
|
||||
}
|
||||
|
||||
params := make(map[string]string, len(values))
|
||||
for key, vs := range values {
|
||||
// The query protocol indexes repeated parameters (Filter.1.Value.1), so a
|
||||
// bare duplicate key only arrives from a non-conforming client. Take the
|
||||
// last occurrence.
|
||||
params[key] = vs[len(vs)-1]
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user