mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-11 04:51:48 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca4aa9d730 | |||
| f0ef931894 | |||
| 38c2a6aeeb | |||
| 610c2ede7c | |||
| 65049cb9e3 | |||
| 575a43a722 | |||
| 435a0e3f2d | |||
| c8b6d45edd | |||
| 85d54b72c7 | |||
| 46e0db9d4f | |||
| 798af3bbd0 | |||
| 83bb47d821 | |||
| 7eef0699d9 | |||
| ff48f0fed9 | |||
| d46ba1f82a | |||
| 2480052a4f | |||
| 94dd7076c8 | |||
| 0acb44f9f9 | |||
| 3669f00f74 | |||
| 8f4266087d | |||
| cf9f92d30d | |||
| 15ca6c63bc | |||
| c9f99bf556 | |||
| 92caea1e9b | |||
| 22ca9c2c1d | |||
| 3e792efe31 | |||
| 9b350a7e61 | |||
| 6ab3ec385f | |||
| 7b0d57d889 | |||
| 2743bfa491 | |||
| c4885b57e8 | |||
| a50ff3ac7d | |||
| b4e793babd | |||
| 2ac9d00335 | |||
| a2b8c5ec4f | |||
| 33798c007c | |||
| fc60d434df | |||
| 77d8595bb3 | |||
| 7b233087d0 | |||
| 95c73b57c3 | |||
| c70b6b2212 | |||
| 72a0826a31 | |||
| 1ec9ee2059 | |||
| 8edd34ed71 | |||
| b6018925bc | |||
| 90f5aef0d7 | |||
| ca6a0163af | |||
| b8b767bdd8 | |||
| bb7c33d273 | |||
| 6854a2c7cc | |||
| 748d7e2132 | |||
| 7488de37cb | |||
| e11e5b4d78 | |||
| 12fa5586e6 | |||
| 49e24aa9a5 | |||
| 1963ef65b9 | |||
| 0516909954 | |||
| 13dbd9a443 | |||
| a2fd140bfa | |||
| 4fff7db3ae | |||
| bd35be1d8a | |||
| 4767510102 | |||
| c479bd9c20 | |||
| d611768aab | |||
| 92e24f9ce5 | |||
| eadd67fd33 | |||
| 115b788475 | |||
| 81bd03928f | |||
| 6da160a0ef | |||
| afb226b70f | |||
| 4ae5cd35a2 | |||
| f7fa0f5b9a | |||
| 32af5427d0 | |||
| 82446b3c35 | |||
| 5017587f75 | |||
| c2c0a730e4 | |||
| c5ae0240e1 | |||
| 1e3e0d5cec | |||
| 1e1a51a8dc | |||
| 805c518f77 | |||
| ac117a3a1e | |||
| 45dc82a85d | |||
| 2a2097b4cf | |||
| 89a4e24966 | |||
| 4be471c196 | |||
| ce29eb12bf | |||
| 8ef35a9c1d | |||
| 79e8c16ea5 | |||
| 17ef43327e | |||
| acc56dca66 | |||
| 668f6341e3 |
@@ -40,7 +40,11 @@
|
||||
# received. It is last because it SIGSTOPs a cluster-wide
|
||||
# service: anything sharing the environment would see the same outage and report
|
||||
# a failure that says nothing about itself.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault"
|
||||
# uefistore is single-only because it runs on the node it tests: it restarts
|
||||
# that node's daemon under a running guest and reads the variable store with
|
||||
# the node's own Predastore credentials. Guests outlive a daemon restart, so
|
||||
# suites sharing the node see a brief control-plane gap, not an outage.
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota route53 uefistore storagefault"
|
||||
|
||||
# Suites runnable against a multi-node environment.
|
||||
#
|
||||
@@ -195,6 +199,10 @@ e2e_suite_timeout() {
|
||||
# took 24-29m, and an Oracle host ran out at 30m inside the last test's
|
||||
# cleanup. The default left it no headroom at all.
|
||||
single) echo "40m" ;;
|
||||
# uefistore's prune test has to outwait the 10-minute prune age and a GC
|
||||
# sweep after its last write-back, on top of three stop/starts and a boot.
|
||||
# A pass is estimated at 40 minutes, unmeasured; the rest is the watch's cap.
|
||||
uefistore) echo "60m" ;;
|
||||
*) echo "30m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ workflows:
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'docker/setup-buildx-action@v4.4.1'
|
||||
- 'softprops/action-gh-release@v3.0.3'
|
||||
- 'softprops/action-gh-release@v3.0.4'
|
||||
'.github/workflows/segscan-oracle.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
@@ -79,7 +79,7 @@ workflows:
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-node@v7.1.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'pnpm/action-setup@v6.1.0'
|
||||
- 'pnpm/action-setup@v6.1.1'
|
||||
dependencies:
|
||||
'actions/cache@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
@@ -131,13 +131,13 @@ dependencies:
|
||||
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
|
||||
owner_id: 761456
|
||||
repo_id: 258271226
|
||||
'pnpm/action-setup@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
|
||||
'pnpm/action-setup@v6.1.1':
|
||||
ref: 'v6.1.1'
|
||||
commit: 'sha1-73e46992e05b19abd31f2f3c20e0e7370d762115'
|
||||
owner_id: 21320719
|
||||
repo_id: 262289747
|
||||
'softprops/action-gh-release@v3.0.3':
|
||||
ref: 'v3.0.3'
|
||||
commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64'
|
||||
'softprops/action-gh-release@v3.0.4':
|
||||
ref: 'v3.0.4'
|
||||
commit: 'sha1-8a0cd651e5b2365bd640854a3fa5dfc70e4875f3'
|
||||
owner_id: 2242
|
||||
repo_id: 204253808
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
topologies:
|
||||
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal). One name runs one topology."
|
||||
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd). One name runs one topology."
|
||||
required: false
|
||||
workbooks:
|
||||
description: "Space-separated workbooks (blank = the driver's default list)"
|
||||
@@ -62,9 +62,10 @@ jobs:
|
||||
# a dedicated label keeps a two-hour cloud wait off the hypervisor labels and
|
||||
# lifts the six-hour cap a hosted job carries.
|
||||
runs-on: ${{ inputs.runner || vars.OCI_E2E_RUNNER || 'ubuntu-26.04' }}
|
||||
# Three topologies including a bare-metal build, each with its own install,
|
||||
# formation and workbook pass. The sweep still runs if this fires.
|
||||
timeout-minutes: 300
|
||||
# Six topologies including a bare-metal build, each with its own install,
|
||||
# formation and workbook pass, so this is the hosted runner's cap. The sweep
|
||||
# still runs if this fires, ocibsd volumes and backups included.
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
# Set here rather than in a job-level env block: the runner context is not
|
||||
# available at job level, only from a step onwards, and a workflow that
|
||||
@@ -236,8 +237,8 @@ jobs:
|
||||
|
||||
# always(), and before the artifact upload: an OCI bare-metal host left
|
||||
# running by a cancelled or timed-out job is the most expensive way this
|
||||
# workflow can fail. The sweep works from the Terraform state files, so it
|
||||
# can only destroy what this run created.
|
||||
# workflow can fail. It destroys by state file, then deletes OCI volumes and
|
||||
# backups by the cluster tag each ocibsd topology recorded.
|
||||
- name: Sweep anything left behind
|
||||
if: always() && !inputs.keep_on_fail
|
||||
working-directory: spinifex/scripts/terraform/oci-spx
|
||||
|
||||
@@ -578,6 +578,8 @@ jobs:
|
||||
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 \
|
||||
SPINIFEX_NODES='${{ steps.vm.outputs.wan_ip }}' SPINIFEX_SSH_USER=tf-user \
|
||||
SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./${SUITE}.test -test.v -test.timeout=${TMO} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
|
||||
|
||||
@@ -478,7 +478,9 @@ jobs:
|
||||
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
NODE_SSH=; if [ \"\$SUITE\" = eks ]; then \
|
||||
NODE_SSH=\"SPINIFEX_NODES=${{ steps.vm.outputs.wan_ip }} SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2\"; fi; \
|
||||
env \$NODE_SSH GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
name: Publish System AMIs
|
||||
|
||||
# Builds the system AMIs (libguestfs, on the runner) and publishes each
|
||||
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
|
||||
# iso.mulgadc.com/system-ami/. Six images ship:
|
||||
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
|
||||
# spinifex-ecs-node Alpine ECS container instance
|
||||
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (NVIDIA driver + toolkit)
|
||||
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance
|
||||
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
|
||||
# spinifex-rds-mariadb Alpine RDS MariaDB instance
|
||||
# Builds the system AMIs and publishes each compressed qcow2 + .sha256 sidecar
|
||||
# to the Cloudflare R2 bucket that backs iso.mulgadc.com/system-ami/. The Alpine
|
||||
# images build with libguestfs (build-system-image.sh); the Ubuntu GPU images
|
||||
# build with mkosi, which installs from resolute-updates and resolute-security,
|
||||
# so each release ships the newest kernel. Eight images ship:
|
||||
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
|
||||
# spinifex-ecs-node Alpine ECS container instance
|
||||
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
|
||||
# spinifex-rds-mariadb Alpine RDS MariaDB instance
|
||||
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (mkosi)
|
||||
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance (mkosi)
|
||||
# ubuntu-26.04-nvidia-gpu-x86_64 Ubuntu NVIDIA GPU base image (mkosi)
|
||||
# ubuntu-26.04-amd-gpu-x86_64 Ubuntu AMD ROCm GPU base image (mkosi)
|
||||
# Operators then install them on any cluster with:
|
||||
# spx admin images import --name spinifex-eks-node
|
||||
# spx admin images import --name spinifex-ecs-node
|
||||
# spx admin images import --name spinifex-eks-node-gpu
|
||||
# spx admin images import --name spinifex-ecs-node-gpu
|
||||
# spx admin images import --name spinifex-rds-postgres
|
||||
# spx admin images import --name spinifex-rds-mariadb
|
||||
# spx admin images import --name <catalog key>
|
||||
#
|
||||
# Publishing overwrites the mirror artifact, so it runs only from release.yml
|
||||
# once a tag has passed its gates, with siblings at the tags go.mod requires.
|
||||
@@ -54,9 +53,12 @@ jobs:
|
||||
VARIANT: ${{ inputs.variant }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cpu='{"name":"spinifex-eks-node","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","manifest":"scripts/images/ecs-agent/manifest.conf"}'
|
||||
gpu='{"name":"spinifex-eks-node-gpu","manifest":"scripts/images/eks-node-gpu/manifest.conf"},{"name":"spinifex-ecs-node-gpu","manifest":"scripts/images/ecs-agent-gpu/manifest.conf"}'
|
||||
rds='{"name":"spinifex-rds-postgres","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
|
||||
# builder=legacy entries carry a manifest for build-system-image.sh;
|
||||
# builder=mkosi entries carry the mkosi-build.sh --image name and the
|
||||
# R2 object base, which must equal the catalog URL basename.
|
||||
cpu='{"name":"spinifex-eks-node","builder":"legacy","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","builder":"legacy","manifest":"scripts/images/ecs-agent/manifest.conf"}'
|
||||
gpu='{"name":"spinifex-eks-node-gpu","builder":"mkosi","image":"spinifex-eks-node-gpu","object":"spinifex-eks-node-gpu-x86_64"},{"name":"spinifex-ecs-node-gpu","builder":"mkosi","image":"spinifex-ecs-node-gpu","object":"spinifex-ecs-node-gpu-x86_64"},{"name":"ubuntu-26.04-nvidia-gpu","builder":"mkosi","image":"ubuntu-gpu-nvidia","object":"ubuntu-26.04-nvidia-gpu-x86_64"},{"name":"ubuntu-26.04-amd-gpu","builder":"mkosi","image":"ubuntu-gpu-amd","object":"ubuntu-26.04-amd-gpu-x86_64"}'
|
||||
rds='{"name":"spinifex-rds-postgres","builder":"legacy","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","builder":"legacy","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
|
||||
case "${VARIANT}" in
|
||||
cpu) items="${cpu}" ;;
|
||||
gpu) items="${gpu}" ;;
|
||||
@@ -132,6 +134,8 @@ jobs:
|
||||
go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
- name: Install build + upload tooling
|
||||
# mkosi legs build and upload inside the pinned builder container.
|
||||
if: matrix.builder == 'legacy'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
@@ -144,6 +148,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build + publish ${{ matrix.name }} to R2
|
||||
if: matrix.builder == 'legacy'
|
||||
working-directory: spinifex
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
@@ -168,3 +173,34 @@ jobs:
|
||||
FLAGS+=(--dry-run)
|
||||
fi
|
||||
./scripts/publish-system-image.sh "${MANIFEST}" "${FLAGS[@]}"
|
||||
|
||||
- name: Build + publish ${{ matrix.name }} to R2 (mkosi)
|
||||
if: matrix.builder == 'mkosi'
|
||||
working-directory: spinifex
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: spinifex-iso
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
IMAGE: ${{ matrix.image }}
|
||||
OBJECT: ${{ matrix.object }}
|
||||
# On-disk scratch for the same reason as the legacy legs: the GPU raw
|
||||
# and its qcow2 do not fit a RAM-backed /tmp.
|
||||
MKOSI_OUTPUT_DIR: ${{ runner.temp }}/mkosi-output
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${MKOSI_OUTPUT_DIR}"
|
||||
./scripts/mkosi-build.sh --image "${IMAGE}" -- --manifest-format=json
|
||||
# The kernel this image boots, from mkosi's package manifest.
|
||||
KERNEL=$(jq -r '.packages[] | select(.name | test("^linux-image-[0-9]")) | "\(.name) \(.version)"' "${MKOSI_OUTPUT_DIR}/${IMAGE}.manifest")
|
||||
if [[ -z "${KERNEL}" ]]; then
|
||||
echo "::error::no linux-image-<version> package in ${IMAGE}.manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "kernel: ${KERNEL}"
|
||||
FLAGS=(--image "${IMAGE}" --name "${OBJECT}")
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
FLAGS+=(--dry-run)
|
||||
fi
|
||||
./scripts/mkosi-publish.sh "${FLAGS[@]}"
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Draft GitHub Release
|
||||
uses: softprops/action-gh-release@v3.0.3
|
||||
uses: softprops/action-gh-release@v3.0.4
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
uses: pnpm/action-setup@v6.1.1
|
||||
with:
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
uses: pnpm/action-setup@v6.1.1
|
||||
with:
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
|
||||
@@ -515,9 +515,9 @@ install-system:
|
||||
fi
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.27.4 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.27.4.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
|
||||
@@ -17,7 +17,6 @@ var allowlist = []allowance{
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/vpc", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
|
||||
{From: "agents/ecs/agent", To: "domains/ecs", Debt: "agents/ecs/agent", Reason: "assignment/state wire types from the ECS implementation; needs a guest/controller contract"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
|
||||
FROM golang:1.27.4-bookworm@sha256:4fa7c968ba93f1635670522dbb01a2c78150443e1385746924566079f36570ea AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
@@ -51,6 +51,8 @@ RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
|
||||
RUN cd spinifex/scripts && cp install-node.sh smoke-test.sh node-reset.sh uninstall-spx.sh /out/
|
||||
RUN mkdir -p /out/udev && cp spinifex/build/udev/* /out/udev/
|
||||
# Staged by setup.sh on an OCI VM; spx admin init or join installs it on ocibsd nodes.
|
||||
RUN cp -r spinifex/build/ocibsd /out/ocibsd
|
||||
RUN mkdir -p /out/lib && cp spinifex/scripts/lib/distro.sh /out/lib/distro.sh
|
||||
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
|
||||
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
|
||||
|
||||
@@ -32,6 +32,8 @@ load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || true
|
||||
# lb-agent probes each data NIC's gateway on an AF_PACKET socket.
|
||||
load_mod af_packet || true
|
||||
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
|
||||
@@ -16,8 +16,8 @@ load_mod() {
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
|
||||
# anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# lambda-agent reads its identity from fw_cfg, or the configuration disk, and
|
||||
# seals it before anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# insmod fallback resolves no dependencies, so load the chain bottom-up.
|
||||
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
|
||||
load_mod failover || true
|
||||
@@ -26,8 +26,30 @@ load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# Without fw_cfg data the executor attaches the same entries as a tar archive
|
||||
# on the second disk; lambda-agent reads them here and erases the disk before
|
||||
# the function starts. qemu_fw_cfg loads without a device, so test the entry.
|
||||
CFG_ROOT=/spinifex-cfg
|
||||
FWCFG_ROOT=/sys/firmware/qemu_fw_cfg
|
||||
if [ ! -f "$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw" ]; then
|
||||
load_mod virtio_blk || echo "[init] virtio_blk did not load" >/dev/console
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -b /dev/vdb ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -b /dev/vdb ]; then
|
||||
mkdir -m 0700 "$CFG_ROOT"
|
||||
if ! mount -t tmpfs -o mode=0700,size=1m tmpfs "$CFG_ROOT" ||
|
||||
! tar -xf /dev/vdb -C "$CFG_ROOT"; then
|
||||
echo "[init] configuration disk not readable" >/dev/console
|
||||
fi
|
||||
FWCFG_ROOT=$CFG_ROOT
|
||||
fi
|
||||
fi
|
||||
|
||||
# The guest's addresses come from the same netcfg entry as other system VMs.
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
NETCFG=$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Give QEMU (group spinifex) read-write on the disks of OCI Block Volume
|
||||
# attachments, named by ID_PATH, which is also the /dev/disk/by-path link.
|
||||
# Partitions and every other block device keep their default ownership.
|
||||
SUBSYSTEM=="block", ACTION!="remove", ENV{DEVTYPE}=="disk", ENV{ID_PATH}=="ip-*-iscsi-iqn.2015-12.com.oracleiaas:*", GROUP="spinifex", MODE="0660"
|
||||
@@ -0,0 +1,7 @@
|
||||
# ocibsd nodes only: QEMU, in the daemon's device cgroup, opens the SCSI disk
|
||||
# an iSCSI login creates, including one attached after the daemon started.
|
||||
[Unit]
|
||||
After=spinifex-ocibsd.service
|
||||
|
||||
[Service]
|
||||
DeviceAllow=block-sd rw
|
||||
@@ -0,0 +1,65 @@
|
||||
# spinifex-unit-version: 1
|
||||
[Unit]
|
||||
Description=Spinifex ocibsd (OCI Block Volumes)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service iscsid.socket iscsid.service
|
||||
Wants=spinifex-nats.service iscsid.socket
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
# Root without capabilities: iscsid answers iscsiadm only for uid 0, and the
|
||||
# node records, lock and devices it touches are root-owned. iscsid does the
|
||||
# privileged session and module work.
|
||||
User=root
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service ocibsd start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
OOMScoreAdjust=-500
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
EnvironmentFile=/etc/spinifex/systemd.env
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/ocibsd/
|
||||
StateDirectory=spinifex/ocibsd
|
||||
|
||||
# Filesystem access: iscsiadm writes node records and its lock
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=no
|
||||
DevicePolicy=closed
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/log/spinifex -/etc/iscsi -/var/lib/iscsi -/run/lock
|
||||
# read-only, not yes: a config_file credential may default to ~/.oci/config
|
||||
ProtectHome=read-only
|
||||
|
||||
# Hardening
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
AmbientCapabilities=
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
@@ -25,9 +26,22 @@ type startup struct {
|
||||
}
|
||||
|
||||
func defaultStartup() startup {
|
||||
if _, err := os.Stat(hostlink.ConfigDiskRoot); err == nil {
|
||||
return configDiskStartup(hostlink.ConfigDiskRoot, hostlink.ConfigDiskDevice)
|
||||
}
|
||||
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
}
|
||||
|
||||
// configDiskStartup reads the entries init extracted from the configuration
|
||||
// disk, and seals by erasing the disk and removing the extracted tree.
|
||||
func configDiskStartup(root, device string) startup {
|
||||
return startup{
|
||||
fwCfgDir: root + "/by_name/" + lambdav1.FwCfgPrefix,
|
||||
fwCfgRoot: root,
|
||||
unload: func() error { return hostlink.SealConfigDisk(device, root) },
|
||||
}
|
||||
}
|
||||
|
||||
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
|
||||
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
|
||||
func (s startup) connect(ctx context.Context) (*tls.Conn, lambdav1.Scope, int, error) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//test:in-package — package main cannot be imported by an external test package.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An identity that does not load still seals the configuration disk: the
|
||||
// guest key on it is erased before the agent refuses to start.
|
||||
func TestConfigDiskStartup_RefusedLoadErasesDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
device := filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, bytes.Repeat([]byte("guest-key"), 512), 0o600))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, device).connect(t.Context())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, exitRefused, code)
|
||||
got, rerr := os.ReadFile(device)
|
||||
require.NoError(t, rerr)
|
||||
require.Len(t, got, 9*512)
|
||||
require.Equal(t, make([]byte, len(got)), got, "configuration disk erased")
|
||||
}
|
||||
|
||||
// A disk that cannot be erased fails closed: the agent refuses, and the
|
||||
// extracted tree is kept so the seal check sees it.
|
||||
func TestConfigDiskStartup_UnerasableDiskRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, filepath.Join(dir, "missing")).connect(t.Context())
|
||||
require.ErrorContains(t, err, "erase configuration disk")
|
||||
require.Equal(t, exitRefused, code)
|
||||
require.DirExists(t, root)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
sdkEndpointEntry = "test-sdk-endpoint"
|
||||
sdkCAEntry = "test-sdk-ca"
|
||||
sdkBundlePath = "/run/lambda-sdk/ca-bundle.pem"
|
||||
systemBundlePath = "/etc/ssl/certs/ca-certificates.crt"
|
||||
)
|
||||
|
||||
type sdkConfig struct {
|
||||
endpoint string
|
||||
ca []byte
|
||||
}
|
||||
|
||||
// readSDKConfig reads optional public settings before the boot config is sealed.
|
||||
func readSDKConfig(dir string) (*sdkConfig, error) {
|
||||
endpoint, endpointErr := os.ReadFile(filepath.Join(dir, sdkEndpointEntry, "raw"))
|
||||
ca, caErr := os.ReadFile(filepath.Join(dir, sdkCAEntry, "raw"))
|
||||
if errors.Is(endpointErr, os.ErrNotExist) && errors.Is(caErr, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if endpointErr != nil || caErr != nil {
|
||||
return nil, errors.New("incomplete SDK gateway settings")
|
||||
}
|
||||
u, err := url.Parse(string(endpoint))
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" ||
|
||||
net.ParseIP(u.Hostname()) == nil || u.Port() == "" {
|
||||
return nil, errors.New("invalid SDK gateway endpoint")
|
||||
}
|
||||
if len(ca) > 64<<10 || !x509.NewCertPool().AppendCertsFromPEM(ca) {
|
||||
return nil, errors.New("invalid SDK gateway CA")
|
||||
}
|
||||
return &sdkConfig{endpoint: string(endpoint), ca: ca}, nil
|
||||
}
|
||||
|
||||
// writeBundle combines the system roots with the cluster's public root.
|
||||
func (c *sdkConfig) writeBundle(path, systemPath string) error {
|
||||
system, err := os.ReadFile(systemPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read system CA bundle: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create SDK CA directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("make SDK CA directory readable: %w", err)
|
||||
}
|
||||
bundle := append(append(system, '\n'), c.ca...)
|
||||
if err := os.WriteFile(path, bundle, 0o644); err != nil {
|
||||
return fmt.Errorf("write SDK CA bundle: %w", err)
|
||||
}
|
||||
return os.Chmod(path, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package exercises the unexported boot configuration reader
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSDKConfig_OptionalAndInvalid(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
endpoint []byte
|
||||
ca []byte
|
||||
wantErr bool
|
||||
want bool
|
||||
}{
|
||||
{name: "absent"},
|
||||
{name: "valid", endpoint: []byte(server.URL), ca: ca, want: true},
|
||||
{name: "missing ca", endpoint: []byte(server.URL), wantErr: true},
|
||||
{name: "missing endpoint", ca: ca, wantErr: true},
|
||||
{name: "invalid endpoint", endpoint: []byte("http://127.0.0.1:1234"), ca: ca, wantErr: true},
|
||||
{name: "invalid ca", endpoint: []byte(server.URL), ca: []byte("not a certificate"), wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, value := range map[string][]byte{sdkEndpointEntry: tc.endpoint, sdkCAEntry: tc.ca} {
|
||||
if value != nil {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), value, 0o600))
|
||||
}
|
||||
}
|
||||
cfg, err := readSDKConfig(dir)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, cfg)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if !tc.want {
|
||||
require.Nil(t, cfg)
|
||||
return
|
||||
}
|
||||
bundle := filepath.Join(t.TempDir(), "sdk", "ca.pem")
|
||||
require.NoError(t, cfg.writeBundle(bundle, filepath.Join(t.TempDir(), "absent")))
|
||||
data, err := os.ReadFile(bundle)
|
||||
require.NoError(t, err)
|
||||
roots := x509.NewCertPool()
|
||||
require.True(t, roots.AppendCertsFromPEM(data))
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}}
|
||||
resp, err := client.Get(cfg.endpoint)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKConfig_BundleKeepsSystemRoots(t *testing.T) {
|
||||
cluster := httptest.NewTLSServer(nil)
|
||||
defer cluster.Close()
|
||||
clusterCA := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cluster.Certificate().Raw})
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test system root"},
|
||||
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
systemCA := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
dir := t.TempDir()
|
||||
systemPath, bundlePath := filepath.Join(dir, "system.pem"), filepath.Join(dir, "sdk", "ca.pem")
|
||||
require.NoError(t, os.WriteFile(systemPath, systemCA, 0o600))
|
||||
require.NoError(t, (&sdkConfig{ca: clusterCA}).writeBundle(bundlePath, systemPath))
|
||||
bundle, err := os.ReadFile(bundlePath)
|
||||
require.NoError(t, err)
|
||||
roots := x509.NewCertPool()
|
||||
require.True(t, roots.AppendCertsFromPEM(bundle))
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}}
|
||||
resp, err := client.Get(cluster.URL)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
systemCert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
_, err = systemCert.Verify(x509.VerifyOptions{Roots: roots})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/lambdaenv"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -31,13 +34,17 @@ type testlink struct {
|
||||
pkg taskPackage
|
||||
runtimeAddr string
|
||||
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
|
||||
caBundlePath string
|
||||
systemCAPath string
|
||||
}
|
||||
|
||||
func start(ctx context.Context) int {
|
||||
t := testlink{
|
||||
startup: defaultStartup(),
|
||||
pkg: devTaskPackage(),
|
||||
runtimeAddr: runtimeAddr,
|
||||
startup: defaultStartup(),
|
||||
pkg: devTaskPackage(),
|
||||
runtimeAddr: runtimeAddr,
|
||||
caBundlePath: sdkBundlePath,
|
||||
systemCAPath: systemBundlePath,
|
||||
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
|
||||
},
|
||||
@@ -53,6 +60,7 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
// The package values are fw_cfg entries, so read them before connect seals it.
|
||||
spec, specErr := readPackageSpec(t.fwCfgDir)
|
||||
grant, grantErr := readGrant(t.fwCfgDir)
|
||||
sdk, sdkErr := readSDKConfig(t.fwCfgDir)
|
||||
conn, scope, code, err := t.connect(parent)
|
||||
if err != nil {
|
||||
return code, errors.Join(err, specErr, grantErr)
|
||||
@@ -85,6 +93,18 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
}
|
||||
cred := *first.Credential
|
||||
first.Credential = nil
|
||||
if err := lambdaenv.Validate(cred.Environment); err != nil {
|
||||
return exitRefused, errors.New("first frame carries an invalid function environment")
|
||||
}
|
||||
if sdkErr != nil {
|
||||
slog.Warn("lambda-agent: SDK gateway settings unavailable", "err", sdkErr)
|
||||
}
|
||||
if sdk != nil {
|
||||
if err := sdk.writeBundle(t.caBundlePath, t.systemCAPath); err != nil {
|
||||
slog.Warn("lambda-agent: SDK gateway settings unavailable", "err", err)
|
||||
sdk = nil
|
||||
}
|
||||
}
|
||||
if err := t.pkg.install(spec); err != nil {
|
||||
return exitRefused, fmt.Errorf("install package: %w", err)
|
||||
}
|
||||
@@ -100,7 +120,8 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
defer cancel(nil)
|
||||
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred))
|
||||
region, _, _ := strings.Cut(string(scope), "/")
|
||||
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred, region, sdk, t.caBundlePath))
|
||||
if err != nil {
|
||||
return exitLink, fmt.Errorf("start bootstrap: %w", err)
|
||||
}
|
||||
@@ -124,18 +145,35 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
return exitClosed, nil
|
||||
}
|
||||
|
||||
// bootstrapEnv is the bootstrap's whole environment: the runtime variables and
|
||||
// the execution role's three credential variables.
|
||||
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential) []string {
|
||||
return []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + runtimeAPI,
|
||||
"_HANDLER=" + handler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID=" + cred.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + cred.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + cred.SessionToken,
|
||||
// bootstrapEnv supplies the runtime, execution-role and SDK settings.
|
||||
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential, region string, sdk *sdkConfig, caPath string) []string {
|
||||
vars := map[string]string{
|
||||
"AWS_LAMBDA_RUNTIME_API": runtimeAPI,
|
||||
"_HANDLER": handler,
|
||||
"LAMBDA_TASK_ROOT": taskRoot,
|
||||
"PATH": "/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID": cred.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY": cred.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN": cred.SessionToken,
|
||||
"AWS_REGION": region,
|
||||
"AWS_DEFAULT_REGION": region,
|
||||
}
|
||||
if sdk != nil {
|
||||
vars["AWS_ENDPOINT_URL"], vars["AWS_CA_BUNDLE"] = sdk.endpoint, caPath
|
||||
}
|
||||
for k, v := range cred.Environment {
|
||||
vars[k] = v
|
||||
}
|
||||
keys := make([]string, 0, len(vars))
|
||||
for k := range vars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
env := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
env = append(env, k+"="+vars[k])
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// shutdownGrace is how long past the invocation deadline the agent waits for
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -70,6 +72,7 @@ type bootstrapStarts struct {
|
||||
sealed bool
|
||||
// handlerOut receives the _HANDLER the bootstrap process itself saw.
|
||||
handlerOut string
|
||||
caBundle string
|
||||
// env is the environment the agent gave the bootstrap.
|
||||
env []string
|
||||
}
|
||||
@@ -94,6 +97,7 @@ func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) test
|
||||
b.started = make(chan struct{}, 1)
|
||||
b.task = t.TempDir()
|
||||
b.handlerOut = filepath.Join(t.TempDir(), "handler")
|
||||
b.caBundle = filepath.Join(t.TempDir(), "sdk", "ca-bundle.pem")
|
||||
return testlink{
|
||||
startup: g.startup(),
|
||||
pkg: taskPackage{
|
||||
@@ -102,7 +106,9 @@ func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) test
|
||||
mountDir: func(string) error { return nil },
|
||||
sealDir: func(string) error { b.sealed = true; return nil },
|
||||
},
|
||||
runtimeAddr: "127.0.0.1:0",
|
||||
runtimeAddr: "127.0.0.1:0",
|
||||
caBundlePath: b.caBundle,
|
||||
systemCAPath: filepath.Join(t.TempDir(), "system-ca.pem"),
|
||||
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
b.n++
|
||||
b.env = env
|
||||
@@ -176,10 +182,87 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
|
||||
"AWS_ACCESS_KEY_ID=" + testCredential.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + testCredential.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + testCredential.SessionToken,
|
||||
"AWS_REGION=ap-southeast-2",
|
||||
"AWS_DEFAULT_REGION=ap-southeast-2",
|
||||
}, b.env, "the bootstrap gets exactly the runtime and credential variables")
|
||||
assert.NotEmpty(t, envValue(b.env, "AWS_LAMBDA_RUNTIME_API"))
|
||||
}
|
||||
|
||||
func TestTestlinkProvidesSDKSettingsToBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
entries := h.entries()
|
||||
entries[sdkEndpointEntry] = []byte(server.URL)
|
||||
entries[sdkCAEntry] = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, entries))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
_, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
res := wait(t, done, "agent to stop")
|
||||
require.NoError(t, res.err)
|
||||
assert.Equal(t, server.URL, envValue(b.env, "AWS_ENDPOINT_URL"))
|
||||
assert.Equal(t, b.caBundle, envValue(b.env, "AWS_CA_BUNDLE"))
|
||||
assert.Equal(t, "ap-southeast-2", envValue(b.env, "AWS_REGION"))
|
||||
assert.Equal(t, "ap-southeast-2", envValue(b.env, "AWS_DEFAULT_REGION"))
|
||||
ca, err := os.ReadFile(b.caBundle)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(ca), string(entries[sdkCAEntry]))
|
||||
info, err := os.Stat(b.caBundle)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0o644), info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestTestlinkProvidesFunctionEnvironment(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
cred := testCredential
|
||||
cred.Environment = map[string]string{"GREETING": "hello $WORLD", "PATH": "/my/bin"}
|
||||
req := hostlink.Request{Credential: &cred, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
_, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, wait(t, done, "agent to stop").err)
|
||||
assert.Equal(t, "hello $WORLD", envValue(b.env, "GREETING"))
|
||||
assert.Equal(t, "/my/bin", envValue(b.env, "PATH"))
|
||||
assert.Equal(t, testCredential.SecretAccessKey, envValue(b.env, "AWS_SECRET_ACCESS_KEY"))
|
||||
}
|
||||
|
||||
func TestTestlinkRejectsReservedFunctionEnvironment(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
cred := testCredential
|
||||
cred.Environment = map[string]string{"AWS_SECRET_ACCESS_KEY": "forged"}
|
||||
req := hostlink.Request{Credential: &cred, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(req))
|
||||
result := wait(t, done, "agent to refuse the environment")
|
||||
assert.Equal(t, exitRefused, result.code)
|
||||
assert.Error(t, result.err)
|
||||
assert.NotContains(t, result.err.Error(), "forged")
|
||||
assert.Zero(t, b.n)
|
||||
}
|
||||
|
||||
var testCredential = hostlink.Credential{AccessKeyID: "ASIAGUESTKEY", SecretAccessKey: "guest-secret", SessionToken: "guest-token"}
|
||||
|
||||
func envValue(env []string, name string) string {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package clusterv1
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
|
||||
// other cluster-wide state.
|
||||
@@ -11,6 +14,27 @@ func HeartbeatKey(node string) string {
|
||||
return "heartbeat." + node
|
||||
}
|
||||
|
||||
// LivenessWindow is how recently a node must have heartbeated for a reader
|
||||
// deciding on a takeover to count it live. It is six heartbeats, compared
|
||||
// across node clocks, where NTP skew is small against it.
|
||||
const LivenessWindow = 60 * time.Second
|
||||
|
||||
// LiveAt reports whether a heartbeat written at written still vouches for its
|
||||
// node at now: it does while it is under LivenessWindow old.
|
||||
func LiveAt(written, now time.Time) bool {
|
||||
return now.Sub(written) < LivenessWindow
|
||||
}
|
||||
|
||||
// HeartbeatWritten dates a stored heartbeat value. One that cannot be decoded
|
||||
// or dated vouches for nothing, which is an answer rather than a fault.
|
||||
func HeartbeatWritten(data []byte) (time.Time, bool) {
|
||||
var beat HeartbeatTimestamp
|
||||
if json.Unmarshal(data, &beat) != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return beat.Time()
|
||||
}
|
||||
|
||||
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
|
||||
// decodes only the timestamp, so a reader does not depend on the capacity
|
||||
// figures the daemon writes beside it.
|
||||
|
||||
@@ -33,3 +33,23 @@ func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
|
||||
assert.False(t, ok, "timestamp %q", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// A beat is live strictly under the window, so one exactly the window old is
|
||||
// the first a takeover may treat as stopped.
|
||||
func TestLiveAt_IsStrictlyUnderTheWindow(t *testing.T) {
|
||||
now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
assert.True(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow+time.Nanosecond), now))
|
||||
assert.False(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow), now))
|
||||
assert.True(t, clusterv1.LiveAt(now.Add(time.Second), now), "a beat from a clock running ahead is live")
|
||||
}
|
||||
|
||||
func TestHeartbeatWritten_DatesAStoredBeatAndRefusesAnUndecodableOne(t *testing.T) {
|
||||
written, ok := clusterv1.HeartbeatWritten([]byte(`{"node":"node-a","timestamp":"2026-10-30T01:02:03Z"}`))
|
||||
require.True(t, ok)
|
||||
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
|
||||
|
||||
for _, raw := range []string{"not json", `{"node":"node-a"}`} {
|
||||
_, ok := clusterv1.HeartbeatWritten([]byte(raw))
|
||||
assert.False(t, ok, raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ const (
|
||||
// this contract family. It is the NATS permission/subscription pattern for
|
||||
// the per-instance command route, rather than the broader descendant match.
|
||||
InstanceCommandSubjectWildcard = InstanceCommandSubjectPrefix + "*"
|
||||
|
||||
// HandledHeader is set to "1" on every reply an owner sends to a
|
||||
// modify-instance-metadata-options or set-disable-api-termination command.
|
||||
// An owner that predates those commands cannot set it.
|
||||
HandledHeader = "Spinifex-Ec2-Cmd-Handled"
|
||||
)
|
||||
|
||||
// InstanceCommandSubject returns the targeted command subject for instanceID.
|
||||
@@ -23,7 +28,9 @@ func InstanceCommandSubject(instanceID string) string {
|
||||
}
|
||||
|
||||
// EC2InstanceCommand is the NATS wire format for EC2 instance commands
|
||||
// (stop, terminate, start, attach/detach-volume, attach/detach-eni).
|
||||
// (stop, terminate, start, reboot, attach/detach-volume, attach/detach-eni,
|
||||
// IAM profile, spot lineage, tags, monitoring, metadata options and
|
||||
// termination protection).
|
||||
type EC2InstanceCommand struct {
|
||||
ID string `json:"id"`
|
||||
Attributes EC2CommandAttributes `json:"attributes"`
|
||||
@@ -35,6 +42,9 @@ type EC2InstanceCommand struct {
|
||||
SpotLineageData *SpotLineageData `json:"spot_lineage_data,omitempty"`
|
||||
InstanceTagsData *InstanceTagsData `json:"instance_tags_data,omitempty"`
|
||||
InstanceMonitoringData *InstanceMonitoringData `json:"instance_monitoring_data,omitempty"`
|
||||
|
||||
InstanceMetadataOptionsData *InstanceMetadataOptionsData `json:"instance_metadata_options_data,omitempty"`
|
||||
DisableApiTerminationData *DisableApiTerminationData `json:"disable_api_termination_data,omitempty"`
|
||||
}
|
||||
|
||||
// EC2CommandAttributes indicates which action the daemon should perform.
|
||||
@@ -52,6 +62,9 @@ type EC2CommandAttributes struct {
|
||||
SetInstanceTags bool `json:"set_instance_tags,omitempty"`
|
||||
RemoveInstanceTags bool `json:"remove_instance_tags,omitempty"`
|
||||
SetInstanceMonitoring bool `json:"set_instance_monitoring,omitempty"`
|
||||
|
||||
ModifyInstanceMetadataOptions bool `json:"modify_instance_metadata_options,omitempty"`
|
||||
SetDisableApiTermination bool `json:"set_disable_api_termination,omitempty"`
|
||||
}
|
||||
|
||||
// AttachVolumeData carries parameters for an attach-volume command.
|
||||
@@ -103,6 +116,23 @@ type InstanceMonitoringData struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// InstanceMetadataOptionsData carries the requested metadata options for a
|
||||
// modify-instance-metadata-options command. Unset fields are left unchanged.
|
||||
type InstanceMetadataOptionsData struct {
|
||||
HttpTokens string `json:"http_tokens,omitempty"`
|
||||
HttpEndpoint string `json:"http_endpoint,omitempty"`
|
||||
HttpProtocolIpv6 string `json:"http_protocol_ipv6,omitempty"`
|
||||
InstanceMetadataTags string `json:"instance_metadata_tags,omitempty"`
|
||||
HttpPutResponseHopLimit *int64 `json:"http_put_response_hop_limit,omitempty"`
|
||||
}
|
||||
|
||||
// DisableApiTerminationData carries the desired termination-protection value
|
||||
// for a set-disable-api-termination command. Carried as data so "false" is an
|
||||
// explicit value, not the absence of one.
|
||||
type DisableApiTerminationData struct {
|
||||
Value bool `json:"value"`
|
||||
}
|
||||
|
||||
// SpotLineageData carries the SIR id stamped onto a spot-launched instance.
|
||||
// Lifecycle is always "spot" for this command, so only the SIR id travels.
|
||||
type SpotLineageData struct {
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
SetInstanceTags: true,
|
||||
RemoveInstanceTags: true,
|
||||
SetInstanceMonitoring: true,
|
||||
|
||||
ModifyInstanceMetadataOptions: true,
|
||||
SetDisableApiTermination: true,
|
||||
},
|
||||
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
|
||||
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
|
||||
@@ -33,6 +36,15 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
SpotLineageData: &SpotLineageData{SpotInstanceRequestId: "sir-1"},
|
||||
InstanceTagsData: &InstanceTagsData{Tags: map[string]string{"Name": "example"}, TagKeys: []string{"old"}},
|
||||
InstanceMonitoringData: &InstanceMonitoringData{Enabled: true},
|
||||
|
||||
InstanceMetadataOptionsData: &InstanceMetadataOptionsData{
|
||||
HttpTokens: "required",
|
||||
HttpEndpoint: "enabled",
|
||||
HttpProtocolIpv6: "disabled",
|
||||
InstanceMetadataTags: "enabled",
|
||||
HttpPutResponseHopLimit: int64Ptr(3),
|
||||
},
|
||||
DisableApiTerminationData: &DisableApiTerminationData{Value: false},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(command)
|
||||
@@ -52,7 +64,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
"set_spot_lineage": true,
|
||||
"set_instance_tags": true,
|
||||
"remove_instance_tags": true,
|
||||
"set_instance_monitoring": true
|
||||
"set_instance_monitoring": true,
|
||||
"modify_instance_metadata_options": true,
|
||||
"set_disable_api_termination": true
|
||||
},
|
||||
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
|
||||
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
|
||||
@@ -61,10 +75,64 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
"spot_lineage_data": {"spot_instance_request_id": "sir-1"},
|
||||
"instance_tags_data": {"tags": {"Name": "example"}, "tag_keys": ["old"]},
|
||||
"instance_monitoring_data": {"enabled": true}
|
||||
"instance_monitoring_data": {"enabled": true},
|
||||
"instance_metadata_options_data": {
|
||||
"http_tokens": "required",
|
||||
"http_endpoint": "enabled",
|
||||
"http_protocol_ipv6": "disabled",
|
||||
"instance_metadata_tags": "enabled",
|
||||
"http_put_response_hop_limit": 3
|
||||
},
|
||||
"disable_api_termination_data": {"value": false}
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
|
||||
// An owner that predates the metadata and termination-protection commands
|
||||
// sends and receives payloads without the new fields.
|
||||
func TestEC2InstanceCommandNewFieldsAreOptionalOnTheWire(t *testing.T) {
|
||||
var n1 EC2InstanceCommand
|
||||
require.NoError(t, json.Unmarshal([]byte(`{
|
||||
"id": "i-0123456789abcdef0",
|
||||
"attributes": {"stop_instance": true},
|
||||
"instance_monitoring_data": {"enabled": true}
|
||||
}`), &n1))
|
||||
require.Equal(t, "i-0123456789abcdef0", n1.ID)
|
||||
require.True(t, n1.Attributes.StopInstance)
|
||||
require.False(t, n1.Attributes.ModifyInstanceMetadataOptions)
|
||||
require.False(t, n1.Attributes.SetDisableApiTermination)
|
||||
require.Nil(t, n1.InstanceMetadataOptionsData)
|
||||
require.Nil(t, n1.DisableApiTerminationData)
|
||||
|
||||
unset, err := json.Marshal(EC2InstanceCommand{ID: "i-1", Attributes: EC2CommandAttributes{StopInstance: true}})
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(unset), "modify_instance_metadata_options")
|
||||
require.NotContains(t, string(unset), "set_disable_api_termination")
|
||||
require.NotContains(t, string(unset), "instance_metadata_options_data")
|
||||
require.NotContains(t, string(unset), "disable_api_termination_data")
|
||||
}
|
||||
|
||||
func TestInstanceMetadataOptionsDataOmitsUnsetFields(t *testing.T) {
|
||||
data, err := json.Marshal(InstanceMetadataOptionsData{HttpPutResponseHopLimit: int64Ptr(1)})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"http_put_response_hop_limit": 1}`, string(data))
|
||||
|
||||
data, err = json.Marshal(InstanceMetadataOptionsData{})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{}`, string(data))
|
||||
}
|
||||
|
||||
func TestDisableApiTerminationDataKeepsExplicitFalse(t *testing.T) {
|
||||
data, err := json.Marshal(DisableApiTerminationData{Value: false})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"value": false}`, string(data))
|
||||
}
|
||||
|
||||
func TestHandledHeaderName(t *testing.T) {
|
||||
require.Equal(t, "Spinifex-Ec2-Cmd-Handled", HandledHeader)
|
||||
}
|
||||
|
||||
func TestInstanceCommandSubjects(t *testing.T) {
|
||||
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
|
||||
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
|
||||
|
||||
@@ -21,13 +21,16 @@ Tenant code may be able to reach the listener, but it cannot open the agent's co
|
||||
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
|
||||
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
|
||||
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
|
||||
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/3`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/3` on both host and guest, whose first frame carries the execution-role credential and function environment, so a test-link end and a release end fail the handshake; release builds are unchanged.
|
||||
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/4`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
|
||||
On that socket a request's raw payload is at most 6 MiB, checked by the gateway before encoding and by the daemon after decoding.
|
||||
Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata headroom and 64 bytes of field slack, and a receiver checks the frame length before allocating; metadata has no separate bound within the frame.
|
||||
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
|
||||
The credential and environment travel as their own frame of at most 40 KiB, and the reply has the same payload and frame bounds as the request.
|
||||
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
|
||||
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
|
||||
In the same builds, the function owner moves a created function with stored code from `Pending` to `Active` once this node's daemon serves the pinned image and the cluster configuration has exactly one node; a code copy over the test-link bound stays `Pending`, and `StateReason` names the unmet condition.
|
||||
Release builds never leave `Pending`.
|
||||
The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=true`; this profile does not meet Q-199 fail-closed revocation, and its guests take management addresses whose record can expire (mulga-0hsub).
|
||||
|
||||
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
|
||||
|
||||
@@ -41,6 +44,8 @@ Under TLS 1.3 the guest's handshake can complete before the host refuses its cer
|
||||
|
||||
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
|
||||
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
|
||||
On a VMM without fw_cfg (the test-link Firecracker executor), the executor attaches the same entries as a tar archive on the second, writable virtio-blk disk, laid out as `by_name/<entry>/raw`.
|
||||
The runtime image's init extracts it to a root-only tmpfs at `/spinifex-cfg`, and the agent reads the entries there, then overwrites the whole disk with zeros and removes the tree; it refuses to start if the tree is still present.
|
||||
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
|
||||
|
||||
**Authority:** one environment's attempt only.
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Viperblockd legacy contracts, version 1
|
||||
|
||||
This package owns the deployed, transitional configuration-update and
|
||||
state-reload request/reply protocols between Spinifex's legacy control plane
|
||||
and `viperblockd`. It makes those existing compatibility boundaries explicit
|
||||
while the generic `providers/ebs` contract is adopted; it is not the EBS
|
||||
provider abstraction and new provider integrations must not depend on it.
|
||||
|
||||
## Configuration update routes
|
||||
|
||||
`ConfigUpdateSubject` is `ebs.config`. Every `viperblockd` daemon subscribes
|
||||
to it in the `spinifex-workers` queue group. It is the fallback path for an
|
||||
encrypted volume with no live mount: the selected daemon opens the volume
|
||||
exclusively and reseals its state.
|
||||
|
||||
`VolumeConfigUpdateSubject(volume)` is `ebs.config.<volume>`. The daemon with
|
||||
the live mounted volume subscribes directly so the sole holder of the volume's
|
||||
state sequence number applies the update.
|
||||
|
||||
Both routes carry `EBSConfigUpdateRequest` and respond with
|
||||
`EBSConfigUpdateResponse`. The JSON field spelling, route shapes, and
|
||||
request/reply semantics are deployed compatibility behaviour. Incompatible
|
||||
changes require a new contract version or an explicit migration plan.
|
||||
|
||||
## State reload route
|
||||
|
||||
`SyncSubject` is `ebs.sync`. After a successful EC2 `ModifyVolume`, the daemon
|
||||
uses request/reply to ask a `spinifex-workers` queue-group member to reload the
|
||||
volume's state from its backend. The selected Viperblockd worker responds with
|
||||
`EBSSyncResponse`; it returns an error when it does not own a live mount of the
|
||||
volume. The daemon records that error as a warning after the API response has
|
||||
already been returned. This is deployed legacy behaviour, not a guarantee that
|
||||
every modification has reached its owning mount.
|
||||
|
||||
The route carries `EBSSyncRequest` and `EBSSyncResponse`. Their JSON shape and
|
||||
the queue-group route are compatibility behaviour.
|
||||
|
||||
## Volume lifecycle routes
|
||||
|
||||
`MountSubject(node)` and `UnmountSubject(node)` retain the deployed mount and
|
||||
unmount routes. With a node name they address that node's Viperblockd process
|
||||
(`ebs.<node>.mount` or `ebs.<node>.unmount`); without one they retain the
|
||||
single-node `spinifex-workers` queue-group routes. Both carry `EBSRequest`.
|
||||
`EBSMountResponse` supplies the exported NBD URI, while
|
||||
`EBSUnMountResponse` reports the seal result. The response broadcasts remain
|
||||
`ebs.mount.response` and `ebs.unmount.response` alongside ordinary
|
||||
request/reply replies.
|
||||
|
||||
`DeleteSubject` retains the `ebs.delete` queue-group route. It carries
|
||||
`EBSDeleteRequest` and responds with `EBSDeleteResponse` after Viperblockd has
|
||||
removed its local state and export. These routes and JSON shapes are deployed
|
||||
legacy compatibility behaviour, not a generic EBS-provider interface.
|
||||
|
||||
## Abandon route
|
||||
|
||||
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
|
||||
request/reply to ask the Viperblock adapter on that node to tear an export
|
||||
down without sealing, because the instance using it belongs to another node
|
||||
now. The route carries `VolumeAbandonRequest` and responds with
|
||||
`VolumeAbandonResponse`. `Abandoned` is false with no error when the volume
|
||||
was not exported there, which is the ordinary case: the request is
|
||||
idempotent by design, since the caller is reconciling rather than
|
||||
transacting.
|
||||
|
||||
## Dirty-marker bucket
|
||||
|
||||
`DirtyBucket` is the JetStream KV bucket `VIPERBLOCK_VOLUME_DIRTY`, keyed by
|
||||
volume name under `VolumeKeyPattern`, storing `DirtyRecord` JSON. It records
|
||||
which node holds writes for a volume that the backend may not have.
|
||||
|
||||
Owner: `providers/ebs/viperblock` owns the dirty-volume state and its
|
||||
transitions. `services/viperblockd` was only its transitional custodian.
|
||||
|
||||
Producers: only the Viperblock adapter writes, clears or changes a dirty
|
||||
record.
|
||||
|
||||
Consumers: Viperblock recovery and mount logic on any node. `spx admin` may
|
||||
obtain a read-only operational view.
|
||||
|
||||
Storage contract: the bucket name, `VolumeKeyPattern` and the `DirtyRecord`
|
||||
JSON shape are this package's legacy v1 contract. There is no TTL; a record
|
||||
persists until an explicit adapter-owned recovery or unseal transition
|
||||
removes it.
|
||||
|
||||
Compatibility: this is a deployed legacy v1 contract, and coordinated
|
||||
writers and readers support N/N-1. No renames, type changes or semantic
|
||||
reinterpretation of existing fields; a breaking change needs a successor
|
||||
contract and a migration.
|
||||
|
||||
CLI authority: the CLI is never a writer and must not perform arbitrary
|
||||
bucket operations.
|
||||
|
||||
This package does not authorize requests, validate a volume ID, store volume
|
||||
state, or define the generic storage-provider API. Those concerns remain with
|
||||
the consuming daemon, the EC2 domain, and `providers/ebs` respectively.
|
||||
@@ -1,36 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
// VolumeAbandonSubject is where the local daemon asks for an export to be torn
|
||||
// down because the instance using it belongs to another node now.
|
||||
//
|
||||
// It exists because an unmount is the wrong request and killing the guest is not
|
||||
// enough. An unmount seals, and this node's copy is the stale one, so sealing
|
||||
// would publish an older state over the winner's. Meanwhile the export holds the
|
||||
// volume lease, and a lease this node keeps renewing is one the winner can never
|
||||
// acquire — the guest would be stopped here and unable to start anywhere.
|
||||
//
|
||||
// Node-addressed, like mount and unmount: the export is local, and no other
|
||||
// node's viperblockd has anything it could tear down.
|
||||
func VolumeAbandonSubject(node string) string {
|
||||
if node == "" {
|
||||
return "ebs.abandon"
|
||||
}
|
||||
return "ebs." + node + ".abandon"
|
||||
}
|
||||
|
||||
// VolumeAbandonRequest asks for one volume's export to go away without sealing.
|
||||
// Reason is logged, so a node that dropped an export can be told apart from one
|
||||
// that was fenced.
|
||||
type VolumeAbandonRequest struct {
|
||||
Volume string `json:"volume"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// VolumeAbandonResponse reports what happened. Abandoned is false with no error
|
||||
// when the volume was not exported here, which is the ordinary case and not a
|
||||
// failure: the request is idempotent by design, since the caller is reconciling
|
||||
// rather than transacting.
|
||||
type VolumeAbandonResponse struct {
|
||||
Abandoned bool `json:"abandoned"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package viperblocklegacyv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
|
||||
// with an empty-node fallback for a single-node daemon.
|
||||
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
|
||||
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
|
||||
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
|
||||
}
|
||||
|
||||
// TestVolumeAbandonRequest_JSON pins the request runtime/daemon/vm_adapters.go sends
|
||||
// on the abandon route.
|
||||
func TestVolumeAbandonRequest_JSON(t *testing.T) {
|
||||
golden := `{"volume":"vol-1","reason":"instance owned by another node"}`
|
||||
req := viperblocklegacyv1.VolumeAbandonRequest{Volume: "vol-1", Reason: "instance owned by another node"}
|
||||
|
||||
marshaled, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, req, decoded)
|
||||
}
|
||||
|
||||
// TestVolumeAbandonResponse_JSON pins both response shapes: the ordinary
|
||||
// reconciling case with no error, and the failure case where Error is set.
|
||||
// Error is omitempty, so the two must not be mistaken for the same shape.
|
||||
func TestVolumeAbandonResponse_JSON(t *testing.T) {
|
||||
t.Run("abandoned, no error", func(t *testing.T) {
|
||||
golden := `{"abandoned":true}`
|
||||
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: true}
|
||||
|
||||
marshaled, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, resp, decoded)
|
||||
})
|
||||
|
||||
t.Run("not abandoned, with error", func(t *testing.T) {
|
||||
golden := `{"abandoned":false,"error":"nbdkit did not exit"}`
|
||||
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: false, Error: "nbdkit did not exit"}
|
||||
|
||||
marshaled, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, resp, decoded)
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Package viperblocklegacyv1 contains the versioned, transitional wire
|
||||
// contracts between Spinifex's legacy control-plane routes and viperblockd.
|
||||
package viperblocklegacyv1
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
// ConfigUpdateSubject is the legacy queue-group route for an encrypted
|
||||
// volume configuration update when no mounted volume owns the request.
|
||||
ConfigUpdateSubject = "ebs.config"
|
||||
)
|
||||
|
||||
// VolumeConfigUpdateSubject returns the legacy route for an update handled by
|
||||
// the node with a live mount of volume. Volume remains unvalidated here: route
|
||||
// authorization and volume-name validation are responsibilities of the
|
||||
// consumer, as they were before this contract was extracted.
|
||||
func VolumeConfigUpdateSubject(volume string) string {
|
||||
return ConfigUpdateSubject + "." + volume
|
||||
}
|
||||
|
||||
// EBSConfigUpdateRequest carries a control-plane VolumeConfig update for an
|
||||
// encrypted volume. config.json is a sealed VBState; only the master-key holder
|
||||
// (viperblockd) can reseal it, so the EC2 edge ships the new config here instead
|
||||
// of rewriting the object directly. VolumeConfig is a marshaled
|
||||
// viperblock.VolumeConfig (RawMessage keeps this contract dependency-free).
|
||||
type EBSConfigUpdateRequest struct {
|
||||
Volume string `json:"Volume"`
|
||||
VolumeConfig json.RawMessage `json:"VolumeConfig"`
|
||||
}
|
||||
|
||||
// EBSConfigUpdateResponse is the request/reply response for either legacy
|
||||
// configuration-update route.
|
||||
type EBSConfigUpdateResponse struct {
|
||||
Volume string `json:"Volume"`
|
||||
Success bool `json:"Success"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigUpdateSubjects(t *testing.T) {
|
||||
require.Equal(t, "ebs.config", ConfigUpdateSubject)
|
||||
require.Equal(t, "ebs.config.vol-123", VolumeConfigUpdateSubject("vol-123"))
|
||||
// The deployed route builder did not validate its final token. Validation
|
||||
// remains with the handler and is not silently introduced by this move.
|
||||
require.Equal(t, "ebs.config.", VolumeConfigUpdateSubject(""))
|
||||
}
|
||||
|
||||
func TestConfigUpdateRequestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSConfigUpdateRequest{
|
||||
Volume: "vol-123",
|
||||
VolumeConfig: json.RawMessage(`{"size_gib":8}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123","VolumeConfig":{"size_gib":8}}`, string(data))
|
||||
}
|
||||
|
||||
func TestConfigUpdateResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSConfigUpdateResponse{
|
||||
Volume: "vol-123",
|
||||
Success: true,
|
||||
Error: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123","Success":true,"Error":""}`, string(data))
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
const (
|
||||
// SyncSubject is the legacy queue-group route that asks a viperblockd
|
||||
// worker to reload a mounted volume's state from its backend.
|
||||
SyncSubject = "ebs.sync"
|
||||
)
|
||||
|
||||
// EBSSyncRequest identifies the volume whose mounted Viperblock state should
|
||||
// be reloaded by the selected legacy worker.
|
||||
type EBSSyncRequest struct {
|
||||
Volume string `json:"Volume"`
|
||||
}
|
||||
|
||||
// EBSSyncResponse reports the selected worker's reload result. A worker that
|
||||
// does not own the mounted volume returns an error rather than forwarding the
|
||||
// request.
|
||||
type EBSSyncResponse struct {
|
||||
Volume string `json:"Volume"`
|
||||
Synced bool `json:"Synced"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSyncSubject(t *testing.T) {
|
||||
require.Equal(t, "ebs.sync", SyncSubject)
|
||||
}
|
||||
|
||||
func TestSyncRequestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSSyncRequest{Volume: "vol-123"})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123"}`, string(data))
|
||||
}
|
||||
|
||||
func TestSyncResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSSyncResponse{
|
||||
Volume: "vol-123",
|
||||
Synced: true,
|
||||
Error: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123","Synced":true,"Error":""}`, string(data))
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import "fmt"
|
||||
|
||||
const (
|
||||
// DeleteSubject is the legacy queue-group route that deletes a volume's
|
||||
// Viperblockd-local state and any active export.
|
||||
DeleteSubject = "ebs.delete"
|
||||
|
||||
// MountResponseSubject is the legacy broadcast emitted after an ebs.mount
|
||||
// request is handled. Request/reply callers receive the same payload.
|
||||
MountResponseSubject = "ebs.mount.response"
|
||||
|
||||
// UnmountResponseSubject is the legacy broadcast emitted after an
|
||||
// ebs.unmount request is handled. Request/reply callers receive the same
|
||||
// payload.
|
||||
UnmountResponseSubject = "ebs.unmount.response"
|
||||
)
|
||||
|
||||
// MountSubject returns the deployed mount route. An empty node retains the
|
||||
// single-node queue-group route; a named node selects the Viperblockd worker
|
||||
// colocated with the instance runtime.
|
||||
func MountSubject(node string) string {
|
||||
if node == "" {
|
||||
return "ebs.mount"
|
||||
}
|
||||
return fmt.Sprintf("ebs.%s.mount", node)
|
||||
}
|
||||
|
||||
// UnmountSubject returns the deployed unmount route. Its empty-node behaviour
|
||||
// matches MountSubject for single-node compatibility.
|
||||
func UnmountSubject(node string) string {
|
||||
if node == "" {
|
||||
return "ebs.unmount"
|
||||
}
|
||||
return fmt.Sprintf("ebs.%s.unmount", node)
|
||||
}
|
||||
|
||||
// EBSRequest is the deployed request payload shared by the legacy mount and
|
||||
// unmount routes. It is intentionally separate from the generic EBS provider
|
||||
// contract: this is Viperblockd's transitional NATS protocol.
|
||||
type EBSRequest struct {
|
||||
Name string `json:"Name"`
|
||||
VolType string `json:"VolType"`
|
||||
Boot bool `json:"Boot"`
|
||||
EFI bool `json:"EFI"`
|
||||
DeleteOnTermination bool `json:"DeleteOnTermination"`
|
||||
NBDURI string `json:"NBDURI"`
|
||||
DeviceName string `json:"DeviceName"`
|
||||
HotplugPort int `json:"HotplugPort,omitempty"`
|
||||
// BlockDevice and BlockSizeBytes describe a host block device export, the
|
||||
// alternative to NBDURI; the guest sees BlockSizeBytes of it. Exactly one
|
||||
// of NBDURI and BlockDevice may be set.
|
||||
BlockDevice string `json:"BlockDevice,omitempty"`
|
||||
BlockSizeBytes uint64 `json:"BlockSizeBytes,omitempty"`
|
||||
}
|
||||
|
||||
// EBSMountResponse is the response to an EBSRequest on MountSubject.
|
||||
type EBSMountResponse struct {
|
||||
URI string `json:"URI"`
|
||||
Mounted bool `json:"Mounted"`
|
||||
Error string `json:"Error"`
|
||||
Retryable bool `json:"Retryable"`
|
||||
}
|
||||
|
||||
// EBSUnMountResponse is the response to an EBSRequest on UnmountSubject.
|
||||
// NotFound reports an idempotent retry after an already-complete seal; Reaped
|
||||
// reports a leaked export that was cleaned up without a registry entry.
|
||||
type EBSUnMountResponse struct {
|
||||
Volume string `json:"Volume"`
|
||||
Mounted bool `json:"Mounted"`
|
||||
Error string `json:"Error"`
|
||||
NotFound bool `json:"NotFound"`
|
||||
Reaped bool `json:"Reaped,omitempty"`
|
||||
}
|
||||
|
||||
// EBSDeleteRequest identifies the volume to delete on DeleteSubject.
|
||||
type EBSDeleteRequest struct {
|
||||
Volume string `json:"Volume"`
|
||||
}
|
||||
|
||||
// EBSDeleteResponse is the response to EBSDeleteRequest on DeleteSubject.
|
||||
type EBSDeleteResponse struct {
|
||||
Volume string `json:"Volume"`
|
||||
Success bool `json:"Success"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVolumeSubjects(t *testing.T) {
|
||||
require.Equal(t, "ebs.delete", DeleteSubject)
|
||||
require.Equal(t, "ebs.mount", MountSubject(""))
|
||||
require.Equal(t, "ebs.unmount", UnmountSubject(""))
|
||||
require.Equal(t, "ebs.node-a.mount", MountSubject("node-a"))
|
||||
require.Equal(t, "ebs.node-a.unmount", UnmountSubject("node-a"))
|
||||
require.Equal(t, "ebs.mount.response", MountResponseSubject)
|
||||
require.Equal(t, "ebs.unmount.response", UnmountResponseSubject)
|
||||
}
|
||||
|
||||
func TestEBSRequestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSRequest{
|
||||
Name: "vol-123",
|
||||
VolType: "gp3",
|
||||
Boot: true,
|
||||
EFI: true,
|
||||
DeleteOnTermination: true,
|
||||
NBDURI: "nbd:unix:/run/spinifex/vol-123.sock",
|
||||
DeviceName: "/dev/sdf",
|
||||
HotplugPort: 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"Name":"vol-123", "VolType":"gp3", "Boot":true, "EFI":true,
|
||||
"DeleteOnTermination":true,
|
||||
"NBDURI":"nbd:unix:/run/spinifex/vol-123.sock",
|
||||
"DeviceName":"/dev/sdf", "HotplugPort":3
|
||||
}`, string(data))
|
||||
|
||||
block, err := json.Marshal(EBSRequest{
|
||||
Name: "vol-456",
|
||||
BlockDevice: "/dev/sdx",
|
||||
BlockSizeBytes: 10 << 30,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"Name":"vol-456", "VolType":"", "Boot":false, "EFI":false,
|
||||
"DeleteOnTermination":false, "NBDURI":"", "DeviceName":"",
|
||||
"BlockDevice":"/dev/sdx", "BlockSizeBytes":10737418240
|
||||
}`, string(block))
|
||||
}
|
||||
|
||||
func TestVolumeResponseJSONContracts(t *testing.T) {
|
||||
mount, err := json.Marshal(EBSMountResponse{
|
||||
URI: "nbd:unix:/run/spinifex/vol-123.sock", Mounted: true, Retryable: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"URI":"nbd:unix:/run/spinifex/vol-123.sock", "Mounted":true,
|
||||
"Error":"", "Retryable":true
|
||||
}`, string(mount))
|
||||
|
||||
unmount, err := json.Marshal(EBSUnMountResponse{
|
||||
Volume: "vol-123", NotFound: true, Reaped: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"Volume":"vol-123", "Mounted":false, "Error":"",
|
||||
"NotFound":true, "Reaped":true
|
||||
}`, string(unmount))
|
||||
|
||||
deleteRequest, err := json.Marshal(EBSDeleteRequest{Volume: "vol-123"})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123"}`, string(deleteRequest))
|
||||
|
||||
deleteResponse, err := json.Marshal(EBSDeleteResponse{Volume: "vol-123", Success: true})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123", "Success":true, "Error":""}`, string(deleteResponse))
|
||||
}
|
||||
+2
-2
@@ -174,8 +174,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `<spinifex-dir>/images`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. Compressed and qcow2 images are extracted into a hidden `.import-*` scratch directory under `--tmp-dir`, which is removed when the import succeeds or fails, so the catalog directory keeps only the downloaded source (for `--name`) and the AMI manifest. Point `--tmp-dir` at a disk-backed filesystem with room for the full raw image, never at a tmpfs such as `/tmp`. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
|
||||
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
|
||||
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
|
||||
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
|
||||
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads the AMI's metadata document, validates the AMI is account-owned, then rewrites its `ImageOwnerAlias` to `"system"` and re-keys its snapshot document under the global account. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Connects to NATS and predastore. Loads the AMI's metadata document, asks the EBS provider for the capacity of the image's volume (`absent` when the provider answers not found, `unknown` with the provider's error on any other failure, which never aborts the preview), and walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap. It then prompts (skipped with `--yes`) before deleting the image's UEFI variable store, then its metadata document (the DescribeImages barrier), then asking the provider to delete snapshot `snap-ami-<id>` and then volume `ami-<id>`. Both provider deletes succeed on an absent target, so a rerun finishes a half-done removal; the provider refuses to delete a volume that is still published. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and corrupt-document checks for salvage of an orphaned volume and snapshot. |
|
||||
|
||||
### GPU Management
|
||||
|
||||
|
||||
+3
-3
@@ -111,9 +111,9 @@ Daemons (`spinifex/runtime/daemon/daemon.go`) subscribe to NATS topics and handl
|
||||
When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
|
||||
A UEFI instance whose record carries a variable store pointer has no EFI volume: its `vars.fd` is fetched from the object under `uefi/` in the configured Predastore bucket that the pointer names, recorded in the `spinifex-uefi-stores` JetStream bucket (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a record with both or neither is refused.
|
||||
RunInstances still creates the EFI volume, nothing writes `vars.fd` back to the object store yet, and a terminated instance's store is removed by a cluster-wide reaper.
|
||||
2. **Volume Generation**: Creates the boot volume through the EBS provider and, for a UEFI guest, seeds its variable store as an object under `uefi/<instance-id>/` with a store record in `spinifex-uefi-stores` that points at it
|
||||
At launch a UEFI instance's `vars.fd` is fetched from the object its store pointer names (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a UEFI instance with no live store record naming a pointer, or one that still carries an EFI volume request, is refused.
|
||||
The store is written back to the object store on write and at stop, and a terminated instance's store is removed by a cluster-wide reaper.
|
||||
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
|
||||
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
|
||||
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
|
||||
|
||||
@@ -695,7 +695,7 @@ series above; "needs decision" is open.
|
||||
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
|
||||
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the `dirty` package, which owns the dirty-marker bucket and its read-only reader), it serves only the `providers/ebs` contract, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
|
||||
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
|
||||
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
|
||||
| `daemon` | `runtime/daemon` (whole-package move, above) | Done. Q-28's ownership and deployment refactor remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. |
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "UEFI Variable Store"
|
||||
seoTitle: "UEFI Variable Store Durability and Recovery — Spinifex Docs"
|
||||
description: "Where a UEFI instance's variable store lives, how much a node loss can lose, how owed write-backs recover, and which firmware differences refuse a launch."
|
||||
category: "Admin"
|
||||
tags:
|
||||
- uefi
|
||||
- firmware
|
||||
- instances
|
||||
- storage
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Host and Guest Lifecycle"
|
||||
url: "/docs/host-lifecycle"
|
||||
---
|
||||
|
||||
# UEFI Variable Store
|
||||
|
||||
> Where a UEFI instance keeps its firmware variables, how durable they are, and what an operator sees when something goes wrong.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Where the Store Lives](#where-the-store-lives)
|
||||
- [Durability](#durability)
|
||||
- [Owed Write-Backs](#owed-write-backs)
|
||||
- [Starting on Another Node](#starting-on-another-node)
|
||||
- [Firmware Compatibility](#firmware-compatibility)
|
||||
- [Instances Refused at Launch](#instances-refused-at-launch)
|
||||
- [Terminate](#terminate)
|
||||
- [Images](#images)
|
||||
- [Inspecting Stores](#inspecting-stores)
|
||||
- [Metrics](#metrics)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
An instance whose boot mode is `uefi` or `uefi-preferred` boots through UEFI firmware, which keeps its variables (`BootOrder`, `BootNext`, secure-boot state) in a VARS region. Spinifex gives each such instance its own variable store, so those variables survive a reboot, a stop and start, and a start on a different node. A direct-boot microvm boots without UEFI and has no store.
|
||||
|
||||
RunInstances seeds the store from the launching image's store when the image carries one (see [Images](#images)), and from the launching node's VARS template otherwise. From then on the node running the instance writes the guest's changes back to the platform object store, and every later start boots from the last change that landed there.
|
||||
|
||||
## Where the Store Lives
|
||||
|
||||
A store has three parts.
|
||||
|
||||
| Part | Where | What it holds |
|
||||
|---|---|---|
|
||||
| Store objects | The platform object store, in the bucket set as the node's Predastore bucket, under `uefi/<instance-id>/` | The VARS region, gzip-compressed, one object per write. Each write goes to a fresh key, so no write replaces an object a record points at. Each object's metadata names the instance, the writer node, the source (`template`, `write-back` or `image:<ami-id>`) and the firmware it was written under. |
|
||||
| Store record | KV bucket `spinifex-uefi-stores`, keyed by instance ID | The instance and account, the state (`live` or `deleting`), the claim generation that fences writes, and the pointer: the one object the instance boots from, with the firmware it was written under, the writer node and the write time. |
|
||||
| Local copy | `<data-dir>/uefi/<instance-id>/` on the node running the instance (`/var/lib/spinifex/uefi/<instance-id>/` by default) | `vars.fd`, the file QEMU opens as pflash unit 1, and `seeded.json`, which records the key the copy came from or last landed at, the claim generation, the firmware, and whether a write-back is owed. |
|
||||
|
||||
The record outlives the instance record, so the store's deletion obligation is not lost when a terminated instance record expires. The bucket is never recreated empty if it goes missing, because an empty bucket would report every store as having no obligations.
|
||||
|
||||
Only one node may write a store at a time. That node holds the store's claim, `uefi.<instance-id>` in the cluster's sync bucket, and a write moves the pointer only while the record still carries that claim's generation. A node that takes the claim stamps the record with its own generation before it boots, so a write from the previous holder is refused rather than landing over newer variables.
|
||||
|
||||
## Durability
|
||||
|
||||
The running node watches `vars.fd` for writes. Once the guest changes a variable, the change is written back 2 seconds after the last write, or 30 seconds after the first write in the window, whichever is sooner. A guest that writes continuously therefore still lands a write-back every 30 seconds. A node that cannot watch the file polls it every 10 seconds instead, and logs `Cannot watch the UEFI variable store; polling it for changes instead`.
|
||||
|
||||
A write-back puts the region at a fresh key (bounded at 10 seconds) and then moves the store record's pointer to it (bounded at 10 seconds). A write-back that fails is retried 30 seconds later, and the changes stay owed until one lands.
|
||||
|
||||
When an instance stops, the node writes the store back once QEMU has exited and before the instance's volumes unmount, then releases the claim.
|
||||
|
||||
**What a node loss can lose:** the variable changes made since the last write-back that landed. While the object store is reachable that is at most the 30-second window plus the write in flight. While write-backs are failing, it is every change since the last one that landed, however long the failures last. The node's local copy still holds those changes, and they are recovered if the node comes back before another node takes the store over (see below).
|
||||
|
||||
## Owed Write-Backs
|
||||
|
||||
A write-back is owed whenever `vars.fd` may hold changes no object has. The node records that in `seeded.json` as `write_back_owed` when a window opens and clears it when the write lands. A stop whose write-back fails keeps the claim, marks the copy owed and logs `The stop could not write the UEFI variable store back; this node keeps its claim and retries`.
|
||||
|
||||
Each node runs a `uefi-write-back` reaper on the garbage-collection sweep, every 2 minutes while KV is healthy. For each local copy whose instance is not running on the node, it:
|
||||
|
||||
- writes an owed copy back and releases the claim once it lands (`trigger=reaper` on the write-back metric);
|
||||
- releases the claim over a clean copy once the stream leader confirms the record still points at the object the copy came from;
|
||||
- drops an owed copy whose claim another node now holds, or whose record a later claim has stamped, logging `vm/gc: dropping an owed UEFI variable store write-back; its changes are lost` and counting `mulga.vm.uefi_store.write_backs_lost`;
|
||||
- removes the copy of a store that is being deleted.
|
||||
|
||||
The same reaper starts a writer for a running guest that has none, such as one whose reconnect after a daemon restart could not read the claim. A guest that outlives a daemon restart has its writer resumed with the window open, so any change the restart missed is written back.
|
||||
|
||||
The reaper also deletes objects no pointer names under each prefix its write-backs touched, keeping the pointer's object and anything younger than 10 minutes.
|
||||
|
||||
An operator does nothing to recover an owed write-back on a node that is up: the reaper retries until it lands. If it keeps failing, the node's log carries `Failed to write the UEFI variable store back` with the error, and the fix is whatever stops the node writing to the object store.
|
||||
|
||||
## Starting on Another Node
|
||||
|
||||
A start on a node that does not hold the claim takes it if it is free. If another node holds it, the start is refused while that node's heartbeat is fresh (under 60 seconds old) or cannot be read, because that node may still land newer variables. StartInstances returns `InsufficientInstanceCapacity` for that refusal, and the node logs `Refusing to start: another live node holds the UEFI variable store claim`. This is what an operator sees when a stopped instance's owed write-back has not landed yet: start it on the node that holds the claim, or let the reaper land the write-back and release the claim.
|
||||
|
||||
Once the holder's heartbeat is 60 seconds old or absent, the starting node takes the claim over and logs `Took over the UEFI variable store claim from a node whose heartbeat has stopped; variable changes it never wrote back are lost`. If the old node comes back, its reaper finds the claim held elsewhere and drops its owed copy.
|
||||
|
||||
A start on the node that holds the claim under its copy's generation boots that copy, because it may carry changes no object has.
|
||||
|
||||
## Firmware Compatibility
|
||||
|
||||
A store records the firmware it was written under: architecture, VARS size and the SHA-256 of the CODE blob. A node's firmware is the first installed pair from its firmware candidates (`/usr/share/OVMF/OVMF_CODE_4M.fd` and `OVMF_VARS_4M.fd` first on x86_64, `/usr/share/AAVMF/AAVMF_CODE.fd` and `AAVMF_VARS.fd` first on arm64).
|
||||
|
||||
| Difference | Result |
|
||||
|---|---|
|
||||
| Architecture | The launch fails before the claim is taken, so the store is left untouched. |
|
||||
| VARS size | The launch fails before the claim is taken, so the store is left untouched. pflash needs a byte-exact region. |
|
||||
| CODE hash only | The instance boots. The node logs `UEFI variable store was written under a different CODE build; booting it` and counts `mulga.vm.uefi_store.firmware_mismatches` with `kind=code`. |
|
||||
|
||||
A refused launch logs `UEFI variable store firmware does not match this node; refusing the launch` naming both firmwares, counts the mismatch with `kind=arch` or `kind=vars_size`, and records a `Server.InternalError` state reason naming both firmwares.
|
||||
|
||||
Every node of an architecture should carry the same firmware package. A node with a different VARS size cannot boot any store written elsewhere, and a node with a different CODE build boots stores with a counted warning on every launch.
|
||||
|
||||
## Instances Refused at Launch
|
||||
|
||||
A UEFI instance is refused before anything about it changes when:
|
||||
|
||||
- it still carries an EFI volume request (`<root>-efi`), which no node boots;
|
||||
- it has no store record, its record is `deleting`, or its record names no pointer;
|
||||
- its record cannot be read as valid.
|
||||
|
||||
These starts fail with `Server.InternalError`, and the node logs `refusing to launch a UEFI instance without exactly one variable store` or `Refusing to launch a UEFI instance without a variable store` with the reason. A start whose store object is missing or does not decode to the node's VARS size also fails, and retrying does not help.
|
||||
|
||||
## Terminate
|
||||
|
||||
Terminate moves the store record to `deleting`, releases a claim the node holds, and removes the node's local copy. It does not delete objects. A cluster-wide `uefi-store` reaper deletes every object under the instance's prefix, then the claim, then the record, and only for an instance whose terminate is decided. It completes a terminate whose mark failed from the terminated instance record. A `deleting` record whose instance is not terminating is left for an operator and logged as `uefi-store: refusing to delete the store of an instance that is not terminating`.
|
||||
|
||||
## Images
|
||||
|
||||
An image made from a UEFI instance carries a copy of the instance's variable store, so instances launched from it start with the same variables.
|
||||
|
||||
- **CreateImage** reads the instance's store record from the stream leader and copies the object its pointer names to a fresh key under `uefi/<ami-id>/`, before the root snapshot is taken. The AMI document records that key and its firmware. The image therefore carries the last write-back that landed, not changes still waiting in the write-back window. An instance with no store record, or a record with no pointer, gives an image with no store. A store being deleted refuses the request with `IncorrectInstanceState`.
|
||||
- **CopyImage** copies the source image's store to a fresh key under the new image's prefix, so deregistering either image leaves the other's store intact.
|
||||
- **RunInstances** from an image that carries a store seeds the new instance from it, with the object source `image:<ami-id>`. A store whose CODE hash differs from the node's seeds with a warning and counts `kind=code`. A store whose architecture or VARS size differs cannot boot on the node, so the instance is seeded from the node's VARS template instead, logged as `The image's UEFI variable store cannot boot under this node's firmware; seeding from the VARS template` and counted as `kind=image`.
|
||||
- **DeregisterImage** deletes the image's store objects before the AMI document. If that delete fails, the request fails with `Server.InternalError` and the image stays, so a retry finishes it. `spx admin images remove` deletes the store first in the same way.
|
||||
|
||||
A CreateImage or CopyImage that fails after copying the store can leave an object under `uefi/<ami-id>/` with no AMI document, which `spx admin volumes orphans` reports.
|
||||
|
||||
## Inspecting Stores
|
||||
|
||||
`spx admin volumes orphans` reports UEFI variable stores nothing owns before it reports orphaned volumes:
|
||||
|
||||
- `uefi/i-*/` prefixes with neither an instance record nor a store record;
|
||||
- `uefi/ami-*/` prefixes with no AMI document;
|
||||
- prefixes named for neither an instance nor an image;
|
||||
- store records that are not `deleting` and have no instance record.
|
||||
|
||||
It only reads, and attaches to the buckets rather than creating them. It never deletes: an absent instance record is not proof of a terminate, so settling each one is an operator decision.
|
||||
|
||||
To read one instance's store record:
|
||||
|
||||
```bash
|
||||
nats kv get spinifex-uefi-stores i-0123456789abcdef0
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| `mulga.vm.uefi_store.seeds` | UEFI launches by where the store came from: `source=local` (the node's copy) or `source=object`. |
|
||||
| `mulga.vm.uefi_store.write_backs` | Write-backs by `outcome` (`landed`, `failed`, `fenced`, `deleting`) and `trigger` (`change`, `stop`, `reaper`). |
|
||||
| `mulga.vm.uefi_store.write_back_skips` | Writers stopped without writing back, by the path that stopped them. |
|
||||
| `mulga.vm.uefi_store.write_backs_lost` | Owed write-backs dropped because another node took the claim over. |
|
||||
| `mulga.vm.uefi_store.pruned_objects` | Superseded store objects a prune deleted. |
|
||||
| `mulga.vm.uefi_store.firmware_mismatches` | Launches whose store was written under different firmware, by `kind`. `kind=code` boots the store, and `kind=image` seeds a new instance from the VARS template instead of the image's store. |
|
||||
| `mulga.vm.uefi_store.claim_refusals` | Starts refused because another node holds the claim and may be live, by `reason`. |
|
||||
| `mulga.vm.uefi_store.claim_takeovers` | Claims taken over from a node whose heartbeat had stopped. |
|
||||
@@ -20,3 +20,18 @@ The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
|
||||
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
|
||||
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
|
||||
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
|
||||
|
||||
### CreateSnapshot Error Mapping
|
||||
|
||||
`CreateSnapshot` drains and snapshots the volume inside the EBS provider.
|
||||
A provider that cannot snapshot a volume because it is in use, as the `qemunbd` provider cannot for an attached volume, answers `ErrVolumeInUse`, which becomes `IncorrectState` with the message `The volume 'vol-...' is in use. Detach it before creating a snapshot.`
|
||||
The `viperblockd` provider drains a mounted export and snapshots it, so an attached volume succeeds there.
|
||||
A provider that cannot answer now, for example because another node holds the volume, answers `ServiceUnavailable` so the caller retries.
|
||||
Any other provider failure is `ServerInternal`.
|
||||
The snapshot taken for `CreateImage` maps these errors the same way.
|
||||
|
||||
### EBS Encryption by Default Follows the Platform
|
||||
|
||||
Whether new volumes are encrypted is decided by the platform's encryption key, not by the account.
|
||||
`GetEbsEncryptionByDefault` and `EnableEbsEncryptionByDefault` report `true` when the platform has an encryption key configured and `false` otherwise, and `EnableEbsEncryptionByDefault` does not change that.
|
||||
`DisableEbsEncryptionByDefault` is refused with `OperationNotPermitted` and the message `EBS encryption by default cannot be disabled: this platform encrypts every volume` while the platform encrypts, and answers `false` where it does not.
|
||||
|
||||
@@ -41,6 +41,8 @@ resources:
|
||||
- [Harden it before production](#harden-it-before-production)
|
||||
- [Next steps](#next-steps)
|
||||
- [Removing the deployment](#removing-the-deployment)
|
||||
- [EBS on OCI Block Volumes](#ebs-on-oci-block-volumes)
|
||||
- [Testing the OCI Block Volumes provider live](#testing-the-oci-block-volumes-provider-live)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
@@ -773,6 +775,116 @@ oci limits resource-availability get --service-name vcn \
|
||||
--limit-name reserved-public-ip-count --compartment-id "$TENANCY_OCID"
|
||||
```
|
||||
|
||||
## EBS on OCI Block Volumes
|
||||
|
||||
On an OCI virtual machine, EBS volumes can be backed by OCI Block Volumes through the `ocibsd` provider instead of Viperblock. Each EBS volume is one OCI block volume, attached over iSCSI to the node that runs the instance using it.
|
||||
|
||||
### How the provider is chosen
|
||||
|
||||
There is no flag. `spx admin init` takes the provider from the first of these that gives one:
|
||||
|
||||
1. The existing `/etc/spinifex/spinifex.toml`, whose provider the node keeps; a node table that omits it is `viperblockd`.
|
||||
2. The `SPINIFEX_EBS_PROVIDER` environment variable: `viperblockd`, `qemunbd` or `ocibsd`.
|
||||
3. The OCI instance metadata service (IMDS v2), read with a one-second timeout. An OCI VM shape selects `ocibsd`; a `BM.*` shape, or no answer, selects `viperblockd`.
|
||||
|
||||
Init prints its choice and where it came from, for example `EBS provider: ocibsd (detected: OCI virtual machine <shape>)`. Joining nodes take the provider from formation rather than choosing their own. The choice is written once for every node and cannot be changed without wiping and re-forming the cluster.
|
||||
|
||||
For `ocibsd`, init fills the compartment and availability domain from instance metadata unless the existing config names them, generates a cluster ID that tags every OCI volume and backup the cluster makes, and defaults `auth` to `instance_principal` when the existing config names no credential. It then reads the compartment's volumes with that credential, and stops with an error naming the needed policy, `manage volume-family` and `use instances`, if the read is refused. It never falls back to Viperblock.
|
||||
|
||||
The settings land in the node's table in `spinifex.toml`:
|
||||
|
||||
```toml
|
||||
[nodes.<node>.ebs]
|
||||
provider = "ocibsd"
|
||||
|
||||
[nodes.<node>.ebs.oci]
|
||||
compartment_id = "<compartment-ocid>"
|
||||
availability_domain = "<availability-domain>"
|
||||
cluster_id = "<generated-cluster-id>"
|
||||
auth = "instance_principal"
|
||||
```
|
||||
|
||||
On an OCI VM, `setup.sh` installs the iSCSI initiator (`open-iscsi` with apt, `iscsi-initiator-utils` with dnf) and stages the `ocibsd` files under `/usr/local/share/spinifex/ocibsd`. When the provider resolves to `ocibsd`, init and join install the `spinifex-ocibsd.service` unit, its udev rule and a `spinifex-daemon` drop-in, mask `spinifex-viperblock.service`, and enable `spinifex-ocibsd.service`, so exactly one service answers for EBS on the node.
|
||||
|
||||
To keep Viperblock on an OCI VM, name it before init:
|
||||
|
||||
```bash
|
||||
sudo env SPINIFEX_EBS_PROVIDER=viperblockd spx admin init <your usual flags>
|
||||
```
|
||||
|
||||
`validate-topology.sh` does this for every topology except `vm-single-ocibsd` and `vm-multi-ocibsd`, which rely on detection and need `--distro`.
|
||||
|
||||
### One availability domain per cluster
|
||||
|
||||
Every volume is created in the availability domain the cluster recorded at init, whichever node handles the request. Config validation refuses nodes that name different compartments or availability domains, and `spinifex-ocibsd` refuses to start on a node whose instance metadata places it in another availability domain. Deploy every node of an `ocibsd` cluster into one availability domain.
|
||||
|
||||
### IAM policy
|
||||
|
||||
The node's credential needs two grants in the compartment, on top of the four the address allocator needs:
|
||||
|
||||
```text
|
||||
Allow dynamic-group <dynamic-group-name> to manage volume-family in compartment id <compartment-ocid>
|
||||
Allow dynamic-group <dynamic-group-name> to use instances in compartment id <compartment-ocid>
|
||||
```
|
||||
|
||||
`manage volume-family` covers volumes, backups and attachments, and `use instances` covers attaching them to the node. The instance-principal policy that `--setup-instance-principal` on an `ocibsd` topology (or `setup-identity.sh --ebs-provider ocibsd`) creates grants both, for a Dynamic Group matching `ALL {instance.compartment.id = '<compartment-ocid>'}`. Built for `viperblockd`, the default, it grants neither, and because the policy is shared by every deployment in the compartment, re-running `setup-identity.sh` without the flag removes them.
|
||||
|
||||
For an API key, grant the same two verbs to the key user's group, alongside the four from [section 2.2](#grant-the-group-its-four-permissions):
|
||||
|
||||
```text
|
||||
Allow group <group-name> to manage volume-family in compartment id <compartment-ocid>
|
||||
Allow group <group-name> to use instances in compartment id <compartment-ocid>
|
||||
```
|
||||
|
||||
`terraform output -json api_key_policy_statements` prints all six for the group named by `api_key_group_name` (default `spinifex-nodes`) when `ebs_provider = "ocibsd"`, and only the four address grants otherwise. Because init defaults to the instance principal, an API-key node needs its credential named in `spinifex.toml` before init, which init then keeps:
|
||||
|
||||
```toml
|
||||
[nodes.<node>.ebs]
|
||||
provider = "ocibsd"
|
||||
|
||||
[nodes.<node>.ebs.oci]
|
||||
auth = "config_file"
|
||||
config_file = "/etc/spinifex/oci/config"
|
||||
config_profile = "spinifex"
|
||||
```
|
||||
|
||||
`validate-topology.sh` writes this for you on an API-key run of an `ocibsd` topology, after the credential hook has installed the key.
|
||||
|
||||
### The 50 GB floor
|
||||
|
||||
OCI creates no block volume smaller than 50 GB, where OCI's GB is 2^30 bytes. The OCI volume is the requested size rounded up to a whole GB, and never less than 50 GB. Spinifex records the requested size in a `spinifex-capacity-bytes` tag, reports it as the EBS volume's size, and presents exactly that size to the guest, so a 1 GiB EBS volume is 1 GiB to the API and the guest. OCI bills for the 50 GB volume underneath it.
|
||||
|
||||
## Testing the OCI Block Volumes provider live
|
||||
|
||||
`providers/ebs/ocibsd/live_test.go` runs the EBS provider conformance suite, in process and over NATS, against a real compartment and one OCI instance.
|
||||
It also checks that OCI's GB is 2^30 bytes on the attached device, and that recreating a deleted volume under the same ID never answers with the deleted volume.
|
||||
The test runs on your workstation with the API-key profile from `~/.oci/config`, and drives `sudo iscsiadm` and the device check on the instance over SSH, so no credential is placed on the instance.
|
||||
Every subtest skips unless `SPINIFEX_OCIBSD_LIVE` is set.
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `SPINIFEX_OCIBSD_LIVE` | Any value runs the live tests |
|
||||
| `SPINIFEX_OCIBSD_COMPARTMENT_ID` | Compartment the volumes and backups are created in |
|
||||
| `SPINIFEX_OCIBSD_AVAILABILITY_DOMAIN` | The instance's availability domain, where every volume is created |
|
||||
| `SPINIFEX_OCIBSD_INSTANCE_ID` | OCID of the instance volumes are attached to |
|
||||
| `SPINIFEX_OCIBSD_SSH_HOST` | Address of that instance |
|
||||
| `SPINIFEX_OCIBSD_SSH_USER` | SSH user with passwordless `sudo`, `opc` when unset |
|
||||
| `SPINIFEX_OCIBSD_SSH_KEY` | Private key for that user |
|
||||
| `SPINIFEX_OCIBSD_SSH_KNOWN_HOSTS` | Known-hosts file, a fresh temporary one when unset |
|
||||
| `SPINIFEX_OCIBSD_CLUSTER_ID` | `spinifex-cluster` tag value for the run, random when unset and logged either way |
|
||||
| `SPINIFEX_OCIBSD_OCI_PROFILE` | OCI config profile, `DEFAULT` when unset |
|
||||
|
||||
The instance needs `iscsi-initiator-utils` and the Oracle Cloud Agent "Block Volume Management" plugin disabled, so no agent logs sessions in or out behind the test.
|
||||
The profile needs `manage volume-family` and `use instances` in the compartment.
|
||||
|
||||
```bash
|
||||
cd spinifex
|
||||
SPINIFEX_OCIBSD_LIVE=1 go test ./providers/ebs/ocibsd/ -run TestLive -count=1 -v -timeout 60m
|
||||
```
|
||||
|
||||
The suite deletes what it creates, but a failed run can leave volumes and backups behind, and OCI bills for them.
|
||||
Sweep everything tagged `spinifex-cluster=<cluster ID>` in the compartment after every run.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
The deploy stops at the first failure and names the log it wrote, all under `.validate-<topology>/`. Add `--keep-on-fail` to leave a failed deployment up so you can log in and look. **It keeps billing** until you run `--destroy-only`.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# distroless base (no shell, no package manager) for a small, production-shaped
|
||||
# image suitable for pushing to ECR and pulling onto EKS workers.
|
||||
|
||||
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
|
||||
FROM golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY main.go ./
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/mulgadc/spinifex-demo
|
||||
|
||||
go 1.27.3
|
||||
go 1.27.4
|
||||
|
||||
@@ -6,7 +6,7 @@ Suites live in `tests/e2e/<name>/` and build to `tests/e2e/_bin/<name>.test`. Ev
|
||||
|
||||
| Set | Suites |
|
||||
| --- | --- |
|
||||
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault` |
|
||||
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota route53 uefistore storagefault` |
|
||||
| `E2E_SUITES_MULTI` | `multinode lb cert quota route53 kvquorum lbrecovery instancerecovery storagefault` |
|
||||
| `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` |
|
||||
| `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` |
|
||||
@@ -108,6 +108,8 @@ Three entry points, in the order they run:
|
||||
|
||||
**Its own suite and its own cell, for instancerecovery's reasons plus one.** It takes a node away, so anything sharing the environment reports that outage as its own failure; it needs its own VPC, public subnet and a backend placement no shared fixture describes; and a failure here has to read as "the load balancer did not survive its host" rather than as one more red subtest in a suite about load balancer features.
|
||||
|
||||
**A dead data NIC is the other half, and there the state does change.** `LBSelfHealsADeadDataNIC` disables one data NIC of a running load balancer guest through a second QMP client and runs for an internet-facing NLB and an internal ALB. The backends and an unrelated customer instance in the same subnet are launched more than 21 minutes before the fault, so a repair that reached a customer instance would find one the platform has long stopped expecting to change. A state watch samples `DescribeLoadBalancers` every two seconds and requires the sequence `active`, `active_impaired`, `active` with nothing in between, while the internet-facing run also watches the name continuously and serves traffic through it within five minutes. Afterwards the guest, its interfaces and its address are unchanged but its process is not, the backends and the bystander keep their instance ID, launch time and process (ADR-0003:S7), and the daemon journals exactly one `ELBv2 data-path repair started` line carrying the fields an operator needs. The internal ALB has no client inside the VPC, so it asserts the state sequence and target health rather than traffic through its name.
|
||||
|
||||
### `iam` — everything IAM and STS
|
||||
|
||||
Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, a user rename needing `iam:UpdateUser` on both the old and the new name, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.
|
||||
@@ -129,7 +131,7 @@ The client reports on its serial console because the runner has no route into th
|
||||
|
||||
### `eks`, `ecs`, `ecr` — container services
|
||||
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. The private-endpoint NIC of the cluster's endpoint NLB is disabled through QMP and every worker must be Ready again within ten minutes while the cluster stays `ACTIVE` and the NLB keeps its guest instance. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
|
||||
### `rds` — managed databases
|
||||
|
||||
@@ -138,10 +140,16 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
|
||||
### Storage and durability
|
||||
|
||||
- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
|
||||
- **No suite reaches a `pending` snapshot.** Every e2e cell runs the default `viperblockd` provider, which finishes a snapshot before `CreateSnapshot` answers, so `single`'s `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch` and `TestCreateImage`, and `rds`'s `TestSnapshotRestore` and `TestDelete`, run the create that writes the snapshot document `pending` before the provider call and settle it `completed` within the request, and the pending-snapshot reaper finds nothing to settle. The `pending` paths are unit-tested against the memory provider answering `pending`: the reaper settling snapshots and AMIs and carrying out deferred deletes, the request's rollback and its ambiguous calls, and `DeleteSnapshot` of a pending snapshot in `domains/ec2/snapshot`; `CreateImage` and the readiness gates in `domains/ec2/image`, `domains/ec2/instance` and `domains/ec2/volume`; DB snapshots and the final snapshot waiting in `creating` in `domains/rds/instance`; and the import's wait in `operator/admin`. The contract's `pending` answer, its filtered `ListSnapshots` and the `state` it reports are in the conformance suite (`providers/ebs/conformance`), which every provider runs.
|
||||
- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
|
||||
- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH.
|
||||
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest forced off and relaunched once its budget expires. Proves a real reboot by requiring a *new* boot ID.
|
||||
- **`diskperf`** — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires `external_mode=pool` for guest SSH.
|
||||
- **`uefistore`** — a real UEFI guest's variable writes reaching the variable store and surviving what it is built to survive. Single-node, and it runs on the node: it restarts that node's daemon and reads the store with the node's own Predastore credentials. Its three tests share one Ubuntu guest, which writes non-volatile variables (attributes 0x7) through efivarfs, and each landing is proved by fetching the object the store record points at and finding the variable's UTF-16 name and its run-unique value in the VARS region.
|
||||
- `TestUEFIGuestVariableSurvivesStopStart`: the pointer moves to a fresh object holding the variable within 60 seconds (the documented bound is 30), the stop leaves the store holding it, and the guest reads it back after the start with a new boot ID.
|
||||
- `TestUEFIWriterResumesAfterDaemonRestart`: a variable lands, `spinifex-daemon` restarts under the guest (whose boot ID must not change), a second variable lands, and both survive a stop and start.
|
||||
- **`TestS7_UEFIStorePruneNeverDeletesThePointedObject` is the ADR-0003:S7 evidence for the prune.** It lands three versions of one variable and requires all three objects to remain, then polls the record and the prefix every 10 seconds until the prefix holds one object, failing if the pointed object or any object under 10 minutes old disappears. The prune must finish on an instance older than the 10-minute prune age, and the last version survives a final stop and start.
|
||||
- Host reboot and node loss are not covered: both need host control or a second node that this suite does not have.
|
||||
|
||||
### Other
|
||||
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
module github.com/mulgadc/spinifex
|
||||
|
||||
go 1.27.3
|
||||
go 1.27.4
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.5
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7
|
||||
github.com/aws/smithy-go v1.28.7
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.7
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.15
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.15
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10
|
||||
github.com/aws/smithy-go v1.28.8
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/containerd/containerd/v2 v2.4.2
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.5.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/jackc/pgx/v5 v5.12.0
|
||||
github.com/klauspost/compress v1.20.3
|
||||
github.com/klauspost/cpuid/v2 v2.4.1
|
||||
github.com/klauspost/cpuid/v2 v2.4.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/minio/highwayhash v1.0.4
|
||||
github.com/mulgadc/bluebottle v1.24.0
|
||||
@@ -33,14 +34,14 @@ require (
|
||||
github.com/mulgadc/viperblock v1.24.0
|
||||
github.com/nats-io/nats-server/v2 v2.15.2
|
||||
github.com/nats-io/nats.go v1.55.0
|
||||
github.com/nats-io/nkeys v0.4.16
|
||||
github.com/nats-io/nkeys v0.4.17
|
||||
github.com/opencontainers/runtime-spec v1.3.0
|
||||
github.com/oracle/oci-go-sdk/v65 v65.128.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.0
|
||||
github.com/oracle/oci-go-sdk/v65 v65.129.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.1
|
||||
github.com/pelletier/go-toml/v2 v2.4.3
|
||||
github.com/pterm/pterm v0.12.84
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/spf13/pflag v1.1.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
go.etcd.io/etcd/api/v3 v3.7.3
|
||||
@@ -55,7 +56,7 @@ require (
|
||||
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
|
||||
go.yaml.in/yaml/v3 v3.0.6
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sync v0.25.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/time v0.16.0
|
||||
@@ -72,17 +73,17 @@ require (
|
||||
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
@@ -119,7 +120,6 @@ require (
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
@@ -144,7 +144,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.3 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/jstemmer/go-junit-report/v2 v2.1.0 // indirect
|
||||
|
||||
@@ -22,46 +22,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25 h1:MW0Iql1SOTgQPj8mCxrU/eH/K/AIDWi08ItOsXaWRLU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25/go.mod h1:+ucVYmHYB3JqVueY0E1RHahdXAnKQbIHqybSbvOUbOM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 h1:GACgECI422Xt5n4Pojr3O0aocPv8lfH4PJe2Aon5Wv8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9/go.mod h1:lPfnRkHpn1qWZJLbVEw10rIqSuCee1pNQTyGkUjAYh8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 h1:RS1Wt+K0r4X0ci9Vf4JYTUfHzsQihYA2LgGJeEneKpk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8/go.mod h1:adMnTy5kyoUW86jhXnGSVc/mvMQI4GZd1qYWMeaS8M8=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4 h1:wvAdq1sNfwuedO1BX8GjY+oZWzoyeD4hhdjJI+4EPnI=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4/go.mod h1:HgkYN4d72Fawg3MAJQepmNnROFoN11XYu14hgFfChTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6 h1:to7+Vr3rLkpYRwVKJ5QRriI0eE306vrVN/6TnVHgxvY=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6/go.mod h1:Jc89ASa2k7HMAskj3bSnt8bf+k+p2IApzkoXnEjkgUw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7 h1:Eip+mZ0wFZY/10e5w89lWhF09Pa8tbbGwxePP3T2iMc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7/go.mod h1:bXp38eBNyvSevFIao60mYcC3pQIAkPYWOddhFVv9z8E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw=
|
||||
github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE=
|
||||
github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.7 h1:jBmtzRtGWVAzbfc4RBWo1dTte0ZZH8p9U42YJIw2+Bw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.7/go.mod h1:I8lFSH4MGrZiw2m7miCrRYHsgpC1+HW8uiVGOiPYPcw=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26 h1:SJNA4XaM8Z09adeTHf/vME93jzl44lJV70pSawIBXac=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26/go.mod h1:l0ME5pd2QytH8UZzETcrt90x9Bm+3TyBVeWfVEcZ82c=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.15 h1:eSO7lvznhY5nMN1K9xK+pPTwNNH0chVmCIzH3C5as+E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.15/go.mod h1:QLBxqEJFcgbrsw2dHaWMDW6DcIFWkHplXJMZGB/s2Ew=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.15 h1:LUFG3364w8wKBhGxGQ2XUxCCs+yOUrSg0dWUtBJskp8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.15/go.mod h1:Q+tKQpy3sU1MjBqXzhZauN7ffcNRF9p7Rvw293sYwE4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7 h1:5GGOwjXU738VrsBT0EDFtP5JtLPmJQILp8RS6P+KqLM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7/go.mod h1:jY/VeijtHNuTiy5XI9HjpLiamtaaLw+KInZULfookJ8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 h1:DKtPO+uoekvZX1tIFGVlWypYGSYkHPb7rliUI1wZtEw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10/go.mod h1:uAi0RDdZZz8IRpXERptWALeCgQG9Ye/HiMLxkxlt8IU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 h1:r8p95YELmfmuV9VvAaFcLesVBZduCCCccxpzQKkNThw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10/go.mod h1:W3M52Mqai5TjgzyL/dEG+UwE+Xz9PykyjXelvbAzWIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 h1:iytL6kFchhbCWWEfLzMGq1+yRecF7+BOXnp70J9zNRM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10/go.mod h1:OaBcfeWMHPSyFqiHL/KN1zVIricTvYDNiO4XhR781eM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 h1:jGzHjc2jDx08Rji1JTBtMxuXUDG7muTEw+ExYXqFzPg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24/go.mod h1:uJt75pK2cEyY80F5C+DK9YkCkJWsMkJViHxLYZRHl8Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 h1:+YhA5h9QMjVsOHfkfY/iVU7FfbT25LcGttfVzzed6v8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11/go.mod h1:Xn22cWokj55agZo3I0w1JFQUiHhA3qLmHi/F5t1COi0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 h1:3nCD0vZOTTMypcaEEJW0rAWqEgUXVzfmNn3dDI1IQ7I=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10/go.mod h1:KJbydA7vvlrQSceENaM/C//yI18Rt36x//hG0Bw3uW0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 h1:bCz6PRKhO7ErM3F0XqLl8pbQq+r0Fra6R8PpfPnjgbo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10/go.mod h1:N8UvNaHipKSUnUFR07OpGdjU8B5aS/XBxgrcKURb2DQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2 h1:SbfR8t3crViVjf8bCh9ohXwQYDbPJV/6P6CE3d9iKXc=
|
||||
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2/go.mod h1:is4sHkblHDcfDUxxZVrDR9hSzf5I4id6qgX6hH0tUDA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8 h1:0g/xXBWQEyR6Mcxo1ukBYqzeRX0bQ/ZdXrtKkIo+yT0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8/go.mod h1:g6EsKHPrO5GpWYKwBfenLLtjuoPRE+WDIlLPQOIixLI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 h1:8cQxxirJ0d0tvmS89hYUpnVArXIKlzwRyiaSfdaokvQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3/go.mod h1:cou3YaR7daAzApgGTG9il1c3iCh/bs72w6p4WFvJnaY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10 h1:ojdbdSOuwvVqcY+yfFO7Kz9Qow7brLZCALVBhorjUv8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10/go.mod h1:zPf8oFenYL6wvZ8izCh+fzFsSwpNNIBQP965awjBaXQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 h1:r5DHs3x9vC1p3VQh+V9EFfHIN5QmWDdS0yKITZM2kBI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3/go.mod h1:u/xiahjixIqyBFiYNeQkmuimZycycBUJEomieSVsrXk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 h1:ENjUto22h2MiHmv8WehZ2874SSCh5knHKsNxeyjzKpY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3/go.mod h1:jmVZvJzjoaStVpr4Nd11mMYCnyrq+sWT6UiDXVnHyco=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 h1:lervRo8V4a9tticNT+nPjqDq9olQPDmgqYczjvJbNio=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8/go.mod h1:NBCcbmgAjvOsYwA6CPgMmipPUxsEep1JxKb3RWBr9rA=
|
||||
github.com/aws/smithy-go v1.28.8 h1:aEmdAq7gVCoGCnvf1NnELlu3I6D/nhqrC321FyFiQkQ=
|
||||
github.com/aws/smithy-go v1.28.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -234,10 +234,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
|
||||
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
|
||||
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
|
||||
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
@@ -249,8 +249,8 @@ github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipY
|
||||
github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
|
||||
github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/cpuid/v2 v2.4.2 h1:xRw7IZEsAgVCp6trS/DgWbTAqpixhmywgVgBjYGg1bQ=
|
||||
github.com/klauspost/cpuid/v2 v2.4.2/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
|
||||
github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -317,8 +317,8 @@ github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbX
|
||||
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
|
||||
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
|
||||
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA=
|
||||
github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -327,10 +327,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
|
||||
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.128.0 h1:QWh0D4VsQ+94XMFy4sapwHVuW5+VOF4dom+h8/cyJsw=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.128.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.0 h1:WNQM8oCmh793mKa7PIIsF5JYoCa91lgXxG1nEEDStFU=
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.0/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.129.0 h1:keTL99Tc9APm7Cm3QQLtiHZ3KU9ZRkiXJzzgjzjpKRk=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.129.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.1 h1:+ltjpC2PEObBWqg2UuG83fH+qdnTuAibXqGrWdUJ1Iw=
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.1/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
@@ -375,8 +375,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
|
||||
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -485,8 +485,8 @@ golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Package recordingstore provides an objectstore.ObjectStore fake that records
|
||||
// every key it is asked to read and every prefix it is asked to list. It exists
|
||||
// so tests can assert an access pattern rather than only an answer: that an
|
||||
// account-scoped listing never touched another account's prefix, which an
|
||||
// in-memory filter over a whole-cluster read would satisfy just as well.
|
||||
// every key it is asked to read, head, write or delete and every prefix it is
|
||||
// asked to list. It exists so tests can assert an access pattern rather than
|
||||
// only an answer: that an account-scoped listing never touched another
|
||||
// account's prefix, which an in-memory filter over a whole-cluster read would
|
||||
// satisfy just as well.
|
||||
package recordingstore
|
||||
|
||||
import (
|
||||
@@ -16,14 +17,17 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
)
|
||||
|
||||
// Store wraps a MemoryObjectStore, appending to Gets and ListPrefixes before
|
||||
// delegating. Both slices are safe to read once the call under test returns.
|
||||
// Store wraps a MemoryObjectStore, recording each call's key or prefix before
|
||||
// delegating. The recordings are safe to read once the call under test returns.
|
||||
type Store struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
mu sync.Mutex
|
||||
gets []string
|
||||
listPrefixes []string
|
||||
heads []string
|
||||
puts []string
|
||||
deletes []string
|
||||
}
|
||||
|
||||
// New returns a recording store backed by an empty in-memory store.
|
||||
@@ -47,6 +51,27 @@ func (s *Store) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input)
|
||||
return s.MemoryObjectStore.ListObjectsV2(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Store) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
|
||||
s.record(&s.heads, aws.StringValue(input.Key))
|
||||
return s.MemoryObjectStore.HeadObject(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Store) PutObject(ctx context.Context, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
s.record(&s.puts, aws.StringValue(input.Key))
|
||||
return s.MemoryObjectStore.PutObject(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
s.record(&s.deletes, aws.StringValue(input.Key))
|
||||
return s.MemoryObjectStore.DeleteObject(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Store) record(into *[]string, key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
*into = append(*into, key)
|
||||
}
|
||||
|
||||
// Gets returns every key read so far.
|
||||
func (s *Store) Gets() []string {
|
||||
s.mu.Lock()
|
||||
@@ -61,12 +86,33 @@ func (s *Store) ListPrefixes() []string {
|
||||
return append([]string(nil), s.listPrefixes...)
|
||||
}
|
||||
|
||||
// Heads returns every key headed so far.
|
||||
func (s *Store) Heads() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.heads...)
|
||||
}
|
||||
|
||||
// Puts returns every key written so far.
|
||||
func (s *Store) Puts() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.puts...)
|
||||
}
|
||||
|
||||
// Deletes returns every key deleted so far.
|
||||
func (s *Store) Deletes() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.deletes...)
|
||||
}
|
||||
|
||||
// Reset drops what has been recorded, so a test can seed fixtures and then
|
||||
// assert only on the call it is actually exercising.
|
||||
func (s *Store) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.gets, s.listPrefixes = nil, nil
|
||||
s.gets, s.listPrefixes, s.heads, s.puts, s.deletes = nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
// TouchedPrefix reports whether any recorded get or listing named a key under
|
||||
|
||||
@@ -209,6 +209,7 @@ echo "[build-microvm-image] nginx: binary + stream module present"
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
|
||||
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
|
||||
# af_packet.ko* — AF_PACKET sockets (lb-agent's per-NIC gateway ARP probe)
|
||||
# All other loadable modules are removed. Drivers compiled into the kernel
|
||||
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
|
||||
echo "[build-microvm-image] stripping kernel modules to virtio+fw_cfg only..."
|
||||
@@ -222,6 +223,7 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
find "$kernel_dir" -name "virtio*.ko.gz" -o -name "virtio*.ko" \
|
||||
-o -name "*fw_cfg*.ko.gz" -o -name "*fw_cfg*.ko" \
|
||||
-o -name "*failover*.ko.gz" -o -name "*failover*.ko" \
|
||||
-o -name "af_packet.ko.gz" -o -name "af_packet.ko" \
|
||||
2>/dev/null | while read -r mod; do
|
||||
rel="${mod#"$kernel_dir/"}"
|
||||
dest_dir="$tmp_keep/$(dirname "$rel")"
|
||||
@@ -240,6 +242,15 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# lb-agent's data-path probe needs AF_PACKET; without it every LB would
|
||||
# report a dead NIC. Accept a kernel with CONFIG_PACKET built in.
|
||||
if ! find "$kernel_dir" -name "af_packet.ko*" | grep -q .; then
|
||||
if ! grep -q "CONFIG_PACKET=y" "$KERNEL_CONFIG" 2>/dev/null; then
|
||||
echo "ERROR: af_packet.ko missing from the kept modules — lb-agent's data-path probe cannot open its socket" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
@@ -310,12 +321,19 @@ echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
# Pack to a .partial and list it before renaming, so a pack cut short (fakeroot
|
||||
# losing its daemon, a full disk) fails here and never leaves a stale archive.
|
||||
fakeroot bash -c '
|
||||
set -euo pipefail
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
|
||||
&& gzip -t "$INITRAMFS_OUT.partial" \
|
||||
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|
||||
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
|
||||
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
|
||||
|
||||
# --- Log artifact sizes ---
|
||||
echo ""
|
||||
|
||||
@@ -40,6 +40,7 @@ mkdir -p "$STAGING/scripts"
|
||||
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
|
||||
mkdir -p "$STAGING/udev"
|
||||
cp "$PROJECT_ROOT/build/udev/"* "$STAGING/udev/"
|
||||
cp -r "$PROJECT_ROOT/build/ocibsd" "$STAGING/ocibsd"
|
||||
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
|
||||
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
|
||||
|
||||
|
||||
+19
-2
@@ -50,6 +50,11 @@
|
||||
# --yes Skip the confirmation prompt
|
||||
# --dry-run Print what would happen and touch nothing
|
||||
#
|
||||
# Environment:
|
||||
# SPINIFEX_EBS_PROVIDER Passed to spx admin init on the first host, naming
|
||||
# what backs EBS in place of its detection; joining
|
||||
# nodes take the provider from formation.
|
||||
#
|
||||
# THE HOST FIREWALL
|
||||
#
|
||||
# Nodes installed from the ISO boot armed, scoped to the only cluster member
|
||||
@@ -159,7 +164,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--no-firewall) MANAGE_FIREWALL=false; shift ;;
|
||||
--yes|-y) ASSUME_YES=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help) sed -n '2,67p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,72p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) echo "ERROR: unknown option: $1" >&2; exit 2 ;;
|
||||
*) HOSTS+=("$1"); shift ;;
|
||||
esac
|
||||
@@ -210,6 +215,18 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
# init_env prints the env prefix that carries SPINIFEX_EBS_PROVIDER to init, or
|
||||
# nothing when it is unset. It is joined into a remote shell, so the value is
|
||||
# limited to a provider name's characters rather than quoted.
|
||||
init_env() {
|
||||
case "$1" in
|
||||
"") ;;
|
||||
*[!a-z]*) echo "SPINIFEX_EBS_PROVIDER must be a provider name such as viperblockd, got: $1"; return 1 ;;
|
||||
*) printf 'env SPINIFEX_EBS_PROVIDER=%s ' "$1" ;;
|
||||
esac
|
||||
}
|
||||
INIT_ENV="$(init_env "${SPINIFEX_EBS_PROVIDER:-}")" || fail "$INIT_ENV"
|
||||
|
||||
case "$IPSEC" in
|
||||
on) IPSEC_ENABLED=true ;;
|
||||
off) IPSEC_ENABLED=false ;;
|
||||
@@ -671,7 +688,7 @@ log "initializing ${HOSTS[0]} as ${NODE_NAMES[0]} (waiting for $((N - 1)) node(s
|
||||
# init blocks in a formation server until every node has joined, so it runs
|
||||
# detached and the token is read back out of its output.
|
||||
on "${HOSTS[0]}" "sudo rm -f $INIT_LOG; \
|
||||
sudo setsid sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
|
||||
sudo setsid ${INIT_ENV}sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
|
||||
exit 0" || fail "${HOSTS[0]}: could not start spx admin init"
|
||||
|
||||
TOKEN=""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests remote_join_cmd from install-node.sh: the join token must reach spx only on stdin, so
|
||||
# sudo's log (which holds the command it was given) never contains it, and the other values must
|
||||
# arrive as plain arguments even when they hold shell syntax.
|
||||
# Tests remote_join_cmd and init_env from install-node.sh: the join token reaches spx only on
|
||||
# stdin, so sudo's log never holds it; other values arrive as plain arguments even when they
|
||||
# hold shell syntax; and a SPINIFEX_EBS_PROVIDER that is not a provider name is refused.
|
||||
# preflight runs this with sh
|
||||
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
|
||||
set -euo pipefail
|
||||
@@ -38,4 +38,15 @@ check "spx got the token" "$(grep -o '<--token><[^>]*>' "$FAKE_SPX_ARGV")" "<--t
|
||||
check "spx got the other values as given" "$(cat "$FAKE_SPX_ARGV")" "<admin><join><--force><--node><node2><--bind><10.0.0.2><--cluster-bind><10.0.0.2><--advertise><203.0.113.2><--host><10.0.0.1:4432;touch PWNED><--token><$TOKEN><--region><us-east-1><--az><az \$(touch PWNED2)>"
|
||||
check "no value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
|
||||
|
||||
# init_env: its value is joined into a remote sh -c unquoted, so anything beyond
|
||||
# a provider name's characters is refused, not passed on.
|
||||
eval "$(sed -n '/^init_env()/,/^}/p' "$here/install-node.sh")"
|
||||
check "no provider sets nothing" "$(init_env "")" ""
|
||||
check "a provider is carried to init" "$(init_env viperblockd)" "env SPINIFEX_EBS_PROVIDER=viperblockd "
|
||||
check "a provider with shell syntax is refused" \
|
||||
"$(init_env 'ocibsd;touch PWNED3' >/dev/null 2>&1 && echo accepted || echo refused)" refused
|
||||
check "a provider with a space is refused" \
|
||||
"$(init_env 'ocibsd $(touch PWNED4)' >/dev/null 2>&1 && echo accepted || echo refused)" refused
|
||||
check "no refused value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
|
||||
|
||||
[ "$fails" -eq 0 ]
|
||||
|
||||
@@ -382,10 +382,17 @@ microvm_pack_initramfs() {
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
# Pack to a .partial and list it before renaming, so a pack cut short fails
|
||||
# here and never leaves a stale archive.
|
||||
fakeroot bash -c '
|
||||
set -euo pipefail
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
|
||||
&& gzip -t "$INITRAMFS_OUT.partial" \
|
||||
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|
||||
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
|
||||
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
|
||||
}
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
# microvm-image_test.sh — microvm_pack_initramfs publishes only an archive that
|
||||
# lists completely, removes a rejected one, and succeeds again on a retry.
|
||||
#
|
||||
# The real pack step runs against a small chroot; fakeroot and mknod are
|
||||
# replaced so it needs no root, and cpio is wrapped to simulate a cut pack.
|
||||
set -eu
|
||||
|
||||
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- microvm_pack_initramfs"
|
||||
|
||||
if ! command -v cpio >/dev/null 2>&1 || ! command -v bash >/dev/null 2>&1; then
|
||||
echo " skip - cpio or bash not installed"
|
||||
exit 0
|
||||
fi
|
||||
REAL_CPIO=$(command -v cpio)
|
||||
|
||||
mkdir -p "$TMP/bin" "$TMP/chroot/dev" "$TMP/chroot/etc" "$TMP/stage"
|
||||
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/bin/fakeroot"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/mknod"
|
||||
chmod +x "$TMP/bin/fakeroot" "$TMP/bin/mknod"
|
||||
# Large enough that a cut at 64 KiB loses whole entries and the trailer.
|
||||
head -c 400000 /dev/urandom > "$TMP/chroot/etc/blob"
|
||||
printf 'init\n' > "$TMP/chroot/init"
|
||||
|
||||
# pack <cpio mode>: runs the real step; mode "cut" truncates the archive stream
|
||||
# and exits 0, mode "fail" writes it whole and exits 1, mode "ok" is untouched.
|
||||
pack() {
|
||||
cat > "$TMP/bin/cpio" <<EOF
|
||||
#!/bin/bash
|
||||
case " \$* " in *" -o "*) ;; *) exec "$REAL_CPIO" "\$@" ;; esac
|
||||
case "$1" in
|
||||
cut) "$REAL_CPIO" "\$@" | head -c 65536; exit 0 ;;
|
||||
fail) "$REAL_CPIO" "\$@"; exit 1 ;;
|
||||
*) exec "$REAL_CPIO" "\$@" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$TMP/bin/cpio"
|
||||
PATH="$TMP/bin:$PATH" bash -c '
|
||||
set -euo pipefail
|
||||
. "$1/lib/microvm-image.sh"
|
||||
TAG="[test]" CHROOT_DIR="$2" STAGE_DIR="$3"
|
||||
microvm_pack_initramfs
|
||||
' _ "$SCRIPTS_DIR" "$TMP/chroot" "$TMP/stage" > "$TMP/log" 2>&1
|
||||
}
|
||||
|
||||
OUT="$TMP/stage/initramfs.cpio.gz"
|
||||
|
||||
for mode in cut fail; do
|
||||
printf 'stale\n' > "$OUT"
|
||||
if pack "$mode"; then
|
||||
fail "$mode: pack succeeded, want failure"
|
||||
elif [ -e "$OUT" ] || [ -e "$OUT.partial" ]; then
|
||||
fail "$mode: rejected archive left behind"
|
||||
else
|
||||
pass "$mode: pack fails and leaves no archive"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! pack ok; then
|
||||
fail "retry after failure: $(tr '\n' ' ' < "$TMP/log")"
|
||||
elif [ -e "$OUT.partial" ]; then
|
||||
fail "retry after failure: partial file left behind"
|
||||
elif ! zcat "$OUT" | "$REAL_CPIO" -t --quiet 2>/dev/null | grep -qx 'etc/blob'; then
|
||||
fail "retry after failure: archive does not list etc/blob"
|
||||
else
|
||||
pass "retry after failure publishes a complete archive"
|
||||
fi
|
||||
|
||||
[ "$fails" -eq 0 ] || exit 1
|
||||
+98
-3
@@ -17,7 +17,7 @@
|
||||
# SETUP_STAGES Comma-separated subset of stages to run:
|
||||
# deps, aws, dns, users, sudoers, firewall, timesync, files,
|
||||
# directories, env, systemd, sysctl, needrestart, modules,
|
||||
# logrotate, udev, fixown, migrations
|
||||
# logrotate, udev, ocibsd, fixown, migrations
|
||||
# Unset = run every stage appropriate for the current mode.
|
||||
|
||||
set -e
|
||||
@@ -43,6 +43,13 @@ FIREWALL_TRACK="${FIREWALL_DIR}/conntrack.nft"
|
||||
FIREWALL_MODE_FILE="${SPINIFEX_FIREWALL_MODE_FILE:-${FIREWALL_DIR}/mode}"
|
||||
FIREWALL_APPLY="/usr/local/lib/spinifex/spinifex-firewall-apply"
|
||||
|
||||
# The OCI instance metadata service spx admin init detects ocibsd by. v2 needs
|
||||
# the bearer header; it is a confused-deputy guard, not a credential.
|
||||
OCI_IMDS_SHAPE_URL="http://169.254.169.254/opc/v2/instance/shape"
|
||||
# Where the tarball's ocibsd/ is staged; init and join install it from here.
|
||||
OCIBSD_STAGE_DIR="/usr/local/share/spinifex/ocibsd"
|
||||
SPINIFEX_TOML="/etc/spinifex/spinifex.toml"
|
||||
|
||||
# The daemon runs with ProtectKernelModules=yes, so every module it needs must
|
||||
# already be loaded when it starts.
|
||||
SPINIFEX_KERNEL_MODULES="openvswitch geneve vhost_net tun"
|
||||
@@ -1351,6 +1358,7 @@ install_deps() {
|
||||
apt) install_apt_deps ;;
|
||||
dnf) install_dnf_deps ;;
|
||||
esac
|
||||
install_iscsi_initiator "$pm"
|
||||
fi
|
||||
|
||||
# Mask the standalone dhcpcd.service auto-enabled on Debian Trixie when
|
||||
@@ -1385,6 +1393,53 @@ install_apt_deps() {
|
||||
fi
|
||||
}
|
||||
|
||||
# is_oci_vm is spx admin init's detection: the OCI metadata service answers
|
||||
# within a second with a shape that is not bare metal. Asked once per run,
|
||||
# because off OCI each ask costs the full second.
|
||||
is_oci_vm() {
|
||||
if [ -z "${OCI_VM:-}" ]; then
|
||||
local shape=""
|
||||
OCI_VM=no
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& shape=$(curl -sf -m 1 -H 'Authorization: Bearer Oracle' "$OCI_IMDS_SHAPE_URL" 2>/dev/null); then
|
||||
case "$shape" in
|
||||
"" | BM.*) ;;
|
||||
*) OCI_VM=yes ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
[ "$OCI_VM" = yes ]
|
||||
}
|
||||
|
||||
# node_runs_ocibsd reports whether this node's existing config names ocibsd in
|
||||
# a [nodes.<name>.ebs] table, the provider being the same on every node.
|
||||
node_runs_ocibsd() {
|
||||
local text
|
||||
[ -e "$SPINIFEX_TOML" ] || return 1
|
||||
if [ -r "$SPINIFEX_TOML" ]; then
|
||||
text=$(cat "$SPINIFEX_TOML") || return 1
|
||||
else
|
||||
text=$($SUDO cat "$SPINIFEX_TOML") || return 1
|
||||
fi
|
||||
printf '%s\n' "$text" | awk '
|
||||
/^[[:space:]]*\[/ { ebs = ($0 ~ /^[[:space:]]*\[nodes\.[^].]+\.ebs\][[:space:]]*(#.*)?$/); next }
|
||||
ebs && /^[[:space:]]*provider[[:space:]]*=[[:space:]]*"ocibsd"/ { found = 1 }
|
||||
END { exit !found }'
|
||||
}
|
||||
|
||||
# The initiator ocibsd drives, on the only host it can back EBS on.
|
||||
install_iscsi_initiator() {
|
||||
is_oci_vm || return 0
|
||||
info "OCI virtual machine detected, installing the iSCSI initiator..."
|
||||
case "$1" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq open-iscsi > /dev/null ;;
|
||||
dnf) $SUDO dnf install -y -q iscsi-initiator-utils > /dev/null ;;
|
||||
esac
|
||||
if [ "${ISO_BUILD:-0}" != "1" ]; then
|
||||
$SUDO systemctl enable --now iscsid.socket
|
||||
fi
|
||||
}
|
||||
|
||||
# The package table is the other place keyed on the OS ID: dnf alone does not
|
||||
# say which repositories or package names a host has.
|
||||
install_dnf_deps() {
|
||||
@@ -2135,6 +2190,45 @@ install_udev() {
|
||||
info "udev rules installed"
|
||||
}
|
||||
|
||||
# install_ocibsd stages the tarball's ocibsd/ on an OCI virtual machine, where
|
||||
# spx admin init or join installs it once the provider resolves to ocibsd. On
|
||||
# a node whose config already runs ocibsd it also reinstalls the staged files.
|
||||
install_ocibsd() {
|
||||
stage "staging the ocibsd files"
|
||||
local ocibsd_node=false f
|
||||
node_runs_ocibsd && ocibsd_node=true
|
||||
if ! $ocibsd_node && ! is_oci_vm; then
|
||||
return 0
|
||||
fi
|
||||
if [ ! -d "$EXTRACT_DIR/ocibsd" ]; then
|
||||
$ocibsd_node && fatal "This node runs ocibsd and the tarball has no ocibsd/ directory to reinstall it from"
|
||||
warn "The tarball has no ocibsd/ directory, so OCI Block Volumes cannot back EBS on this node"
|
||||
return 0
|
||||
fi
|
||||
$SUDO rm -rf "$OCIBSD_STAGE_DIR"
|
||||
$SUDO install -d -m 0755 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d"
|
||||
for f in spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf; do
|
||||
$SUDO install -m 0644 "$EXTRACT_DIR/ocibsd/$f" "$OCIBSD_STAGE_DIR/$f"
|
||||
done
|
||||
info " $OCIBSD_STAGE_DIR (staged)"
|
||||
$ocibsd_node || return 0
|
||||
|
||||
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-ocibsd.service" /etc/systemd/system/spinifex-ocibsd.service
|
||||
$SUDO install -d -m 0755 /etc/systemd/system/spinifex-daemon.service.d
|
||||
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d/ocibsd.conf" \
|
||||
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
|
||||
$SUDO install -d /etc/udev/rules.d
|
||||
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/99-spinifex-ocibsd.rules" /etc/udev/rules.d/99-spinifex-ocibsd.rules
|
||||
# install_systemd has just written the viperblock unit over its mask.
|
||||
$SUDO ln -sf /dev/null /etc/systemd/system/spinifex-viperblock.service
|
||||
info " ocibsd unit, device drop-in and udev rule reinstalled; spinifex-viperblock.service masked"
|
||||
if [ "${ISO_BUILD:-0}" != "1" ]; then
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable spinifex-ocibsd.service
|
||||
$SUDO udevadm control --reload-rules
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Upgrade handling ---
|
||||
handle_upgrade() {
|
||||
if $SUDO systemctl is-active --quiet spinifex.target 2>/dev/null; then
|
||||
@@ -2273,12 +2367,12 @@ main() {
|
||||
detect_os
|
||||
detect_arch
|
||||
|
||||
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev.
|
||||
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev, ocibsd.
|
||||
# Only download/extract when at least one such stage is enabled, and before
|
||||
# anything on the host changes, so the hand-off finds it untouched.
|
||||
if stage_enabled files || stage_enabled directories \
|
||||
|| stage_enabled systemd || stage_enabled logrotate \
|
||||
|| stage_enabled udev; then
|
||||
|| stage_enabled udev || stage_enabled ocibsd; then
|
||||
download_spinifex
|
||||
hand_off_to_packaged_setup "${args[@]}"
|
||||
fi
|
||||
@@ -2306,6 +2400,7 @@ main() {
|
||||
stage_enabled modules && install_kernel_modules
|
||||
stage_enabled logrotate && install_logrotate
|
||||
stage_enabled udev && install_udev
|
||||
stage_enabled ocibsd && install_ocibsd
|
||||
stage_enabled swap && setup_swap
|
||||
|
||||
# Migrations are only safe on a live system (need a running NATS and a
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/bin/sh
|
||||
# setup_ocibsd_test.sh — setup.sh's systemd and udev stages never install the
|
||||
# tarball's ocibsd/ files; install_ocibsd stages them on an OCI VM, and
|
||||
# reinstalls them on upgrade only where the node's config already runs ocibsd.
|
||||
# A stub SUDO records every privileged command and runs the file ones under a
|
||||
# scratch root, so what each case leaves on disk is checked as well.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
SETUP="$SCRIPT_DIR/setup.sh"
|
||||
BUILD="$(dirname -- "$SCRIPT_DIR")/build"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
# The tarball layout the release image and dev-install.sh assemble.
|
||||
EXTRACT="$TMP/extract"
|
||||
mkdir -p "$EXTRACT/systemd" "$EXTRACT/udev"
|
||||
cp "$BUILD"/systemd/* "$EXTRACT/systemd/"
|
||||
cp "$BUILD"/udev/* "$EXTRACT/udev/"
|
||||
cp -r "$BUILD/ocibsd" "$EXTRACT/ocibsd"
|
||||
|
||||
# install, ln, rm and mkdir run with /etc and /usr/local/share/spinifex moved
|
||||
# under $ROOT; anything else is only recorded.
|
||||
cat > "$TMP/sudo" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "$*" >> "$CALLS"
|
||||
case "$1" in
|
||||
install | ln | rm | mkdir) ;;
|
||||
tee) cat > /dev/null; exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
args=()
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
/etc/* | /usr/local/share/spinifex/*) a="$ROOT$a" ;;
|
||||
esac
|
||||
args+=("$a")
|
||||
done
|
||||
exec "${args[@]}"
|
||||
EOF
|
||||
chmod +x "$TMP/sudo"
|
||||
|
||||
# run_setup <case> <oci_vm yes|no> <config body or -> <functions...>
|
||||
# runs the named setup.sh functions in a fresh root for one case.
|
||||
run_setup() {
|
||||
name="$1" vm="$2" body="$3"
|
||||
shift 3
|
||||
ROOT="$TMP/$name/root"
|
||||
CALLS="$TMP/$name/calls"
|
||||
mkdir -p "$ROOT/etc/systemd/system" "$ROOT/etc/spinifex" "$ROOT/usr/local/share/spinifex"
|
||||
: > "$CALLS"
|
||||
[ "$body" = - ] || printf '%s\n' "$body" > "$ROOT/etc/spinifex/spinifex.toml"
|
||||
export ROOT CALLS
|
||||
OUT=$(INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '
|
||||
. "$1"; SUDO="$2"; EXTRACT_DIR="$3"; OCI_VM="$4"; SPINIFEX_TOML="$ROOT/etc/spinifex/spinifex.toml"
|
||||
shift 4
|
||||
for f in "$@"; do $f; done' _ "$SETUP" "$TMP/sudo" "$EXTRACT" "$vm" "$@" 2>&1) \
|
||||
|| fail "$name: $* failed: $OUT"
|
||||
}
|
||||
|
||||
echo "-- the systemd and udev stages leave the tarball's ocibsd/ alone"
|
||||
run_setup units yes - install_systemd install_udev
|
||||
if grep -q " /etc/systemd/system/spinifex-viperblock.service$" "$CALLS" && grep -q " /etc/udev/rules.d/99-spinifex-vfio.rules$" "$CALLS"; then
|
||||
pass "installs the systemd/ units and udev/ rules"
|
||||
else
|
||||
fail "did not install the systemd/ and udev/ files: $(cat "$CALLS")"
|
||||
fi
|
||||
if grep -q "ocibsd" "$CALLS"; then
|
||||
fail "installed an ocibsd/ file: $(grep ocibsd "$CALLS")"
|
||||
else
|
||||
pass "installs nothing from ocibsd/"
|
||||
fi
|
||||
|
||||
STAGED="usr/local/share/spinifex/ocibsd"
|
||||
FILES="spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf"
|
||||
installed_count() {
|
||||
n=0
|
||||
for p in etc/systemd/system/spinifex-ocibsd.service etc/udev/rules.d/99-spinifex-ocibsd.rules \
|
||||
etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf; do
|
||||
[ ! -e "$ROOT/$p" ] || n=$((n + 1))
|
||||
done
|
||||
echo "$n"
|
||||
}
|
||||
staged_ok() {
|
||||
for f in $FILES; do
|
||||
cmp -s "$EXTRACT/ocibsd/$f" "$ROOT/$STAGED/$f" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
echo "-- a fresh install on an OCI VM stages ocibsd/ and installs none of it"
|
||||
run_setup fresh yes - install_systemd install_udev install_ocibsd
|
||||
if staged_ok; then pass "the three files are staged"; else fail "the stage does not match the tarball: $(ls -R "$ROOT/$STAGED" 2>&1)"; fi
|
||||
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a node init has not resolved"
|
||||
[ -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && fail "masked viperblock" || pass "viperblock is not masked"
|
||||
|
||||
echo "-- an upgrade of an ocibsd node replaces the installed files with the new stage"
|
||||
OCIBSD_TOML='[nodes.node1.ebs]
|
||||
provider = "ocibsd"
|
||||
|
||||
[nodes.node1.ebs.oci]
|
||||
auth = "instance_principal"'
|
||||
ROOT="$TMP/upgrade/root"
|
||||
mkdir -p "$ROOT/$STAGED/spinifex-daemon.service.d" "$ROOT/etc/systemd/system/spinifex-daemon.service.d" "$ROOT/etc/udev/rules.d"
|
||||
for f in $FILES; do echo "# the previous release" > "$ROOT/$STAGED/$f"; done
|
||||
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-ocibsd.service"
|
||||
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf"
|
||||
echo "# the previous release" > "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/spinifex-viperblock.service"
|
||||
run_setup upgrade yes "$OCIBSD_TOML" install_systemd install_udev install_ocibsd
|
||||
if staged_ok; then pass "the stage holds the new files"; else fail "the stage kept the previous release"; fi
|
||||
if cmp -s "$EXTRACT/ocibsd/spinifex-ocibsd.service" "$ROOT/etc/systemd/system/spinifex-ocibsd.service" \
|
||||
&& cmp -s "$EXTRACT/ocibsd/spinifex-daemon.service.d/ocibsd.conf" "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf" \
|
||||
&& cmp -s "$EXTRACT/ocibsd/99-spinifex-ocibsd.rules" "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"; then
|
||||
pass "the unit, drop-in and udev rule are the new ones"
|
||||
else
|
||||
fail "an installed ocibsd file is not the new one"
|
||||
fi
|
||||
if [ "$(readlink "$ROOT/etc/systemd/system/spinifex-viperblock.service")" = /dev/null ]; then
|
||||
pass "viperblock is masked again after the unit reinstall"
|
||||
else
|
||||
fail "the reinstalled viperblock unit is not masked"
|
||||
fi
|
||||
grep -q '^systemctl daemon-reload$' "$CALLS" && grep -q '^udevadm control --reload-rules$' "$CALLS" \
|
||||
&& pass "systemd and udev are reloaded" || fail "no reload: $(cat "$CALLS")"
|
||||
|
||||
echo "-- an ocibsd node is refreshed even when the metadata service does not answer"
|
||||
run_setup upgrade-no-imds no "$OCIBSD_TOML" install_ocibsd
|
||||
[ "$(installed_count)" = 3 ] && pass "the three files are installed" || fail "installed $(installed_count) of 3"
|
||||
|
||||
echo "-- an upgrade of a viperblock node on an OCI VM installs none of them"
|
||||
run_setup viperblock yes '[nodes.node1.ebs]
|
||||
provider = "viperblockd"' install_systemd install_udev install_ocibsd
|
||||
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a viperblock node"
|
||||
[ -f "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && [ ! -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] \
|
||||
&& pass "the viperblock unit is installed and not masked" || fail "the viperblock unit was masked"
|
||||
|
||||
echo "-- off OCI nothing is staged"
|
||||
run_setup off-oci no '[nodes.node1.daemon]' install_ocibsd
|
||||
[ ! -e "$ROOT/$STAGED" ] && pass "no stage" || fail "staged ocibsd/ off OCI"
|
||||
|
||||
echo "-- the initiator is installed on an OCI VM only"
|
||||
run_setup initiator-apt yes - "install_iscsi_initiator apt"
|
||||
grep -q 'apt-get install -y -qq open-iscsi' "$CALLS" && grep -q '^systemctl enable --now iscsid.socket$' "$CALLS" \
|
||||
&& pass "apt installs open-iscsi and enables iscsid.socket" || fail "apt: $(cat "$CALLS")"
|
||||
run_setup initiator-dnf yes - "install_iscsi_initiator dnf"
|
||||
grep -q 'dnf install -y -q iscsi-initiator-utils' "$CALLS" && pass "dnf installs iscsi-initiator-utils" || fail "dnf: $(cat "$CALLS")"
|
||||
run_setup initiator-off no - "install_iscsi_initiator apt"
|
||||
[ -s "$CALLS" ] && fail "installed an initiator off OCI: $(cat "$CALLS")" || pass "no initiator off OCI"
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -230,11 +230,11 @@ Create them once per tenancy, then every deployment and rebuild afterwards uses
|
||||
|
||||
**`adopt` references the dynamic group by nothing at all.** Its matching rule is `instance.compartment.id`, so it covers every instance in the compartment and names no OCID — which is why adopting needs no read on an identity resource and no tenancy rights. The cost is that a missing policy is invisible at apply time: the node forms, passes every health check, and then refuses every launch wanting a public address. The allocator gate in `validate-topology.sh` is what catches that, by requiring `ocinet credential authorised to allocate` in each node's `spinifex-daemon` journal.
|
||||
|
||||
The policy grants four verbs in one compartment: `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets`.
|
||||
That is exactly what allocating an external address does and nothing more.
|
||||
The policy grants `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets` in one compartment for the allocator, and with `ebs_provider = "ocibsd"` also `manage volume-family` and `use instances` for the `ocibsd` EBS provider. `setup-identity.sh --ebs-provider ocibsd` creates it with them; the policy is shared by every deployment in the compartment, so running it without the flag removes them.
|
||||
The first four are exactly what allocating an external address does and nothing more; the last two are what creating, attaching, backing up and deleting a block volume does.
|
||||
`use subnets` looks unrelated and is not: `CreatePrivateIp` is checked against `SUBNET_ATTACH` and `CreatePrivateIp` is how an address is registered, so a policy without it authorises nothing and every allocation returns a 404.
|
||||
|
||||
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and nothing else; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
|
||||
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and volumes and nothing else: `terraform output -json api_key_policy_statements` prints the same statements for the group named by `api_key_group_name` (default `spinifex-nodes`), for a tenancy admin to apply; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
|
||||
|
||||
`spx-oci-config.sh` installs it on **every** node, as `/etc/spinifex/oci/{oci_api_key.pem,config}` — not a home directory, because the daemon's unit sets `ProtectHome=yes`. Both files are needed: `oci_config_file` names an ordinary OCI SDK config, so a node holding only the PEM forms and then fails every allocation. The profile it writes is `spinifex`, matching `oci_config_profile`:
|
||||
|
||||
@@ -275,6 +275,20 @@ The count that later gates read is the number of addresses the `hosts_file` outp
|
||||
|
||||
**A clean teardown can still leave public IPs behind, and they count against the tenancy.** The addresses Spinifex allocates for guests are created by the running node, so they are in no Terraform state and `destroy` neither sees nor removes them. The node's own reconcile collects detached ones every ten minutes, which is exactly the sweep a destroy takes away. Twenty accumulated in `spxbm` over four runs and exhausted the tenancy's 50 reserved-public-IP limit, at which point every later launch in every compartment failed. [Check for leftover public IPs afterwards](../../../docs/oci-integration/README.md#check-for-leftover-public-ips-afterwards) has the query and the rule for reading it.
|
||||
|
||||
### The ocibsd topologies
|
||||
|
||||
`vm-single-ocibsd` and `vm-multi-ocibsd` are `vm-single` and `vm-multi` with EBS backed by OCI Block Volumes. They build with `ebs_provider = "ocibsd"`, which disables the Block Volume Management plugin and attaches each node's data volume paravirtualized, because every iSCSI session on the node is `ocibsd`'s to manage. They need `--distro`, because a published release predates the `ocibsd` files that `setup.sh` stages, and an external pool, because an API-key run gives `ocibsd` the pool's credential.
|
||||
|
||||
The ocibsd topologies name no EBS provider. `spx admin init` detects that it is on an OCI virtual machine, reads the compartment, availability domain and region from the instance metadata, and generates the cluster's `cluster_id`. Every other topology sets `SPINIFEX_EBS_PROVIDER=viperblockd`, so its viperblock baseline is not replaced by detection on OCI. The run checks the `EBS provider:` line init prints: `detected` on the ocibsd topologies under the instance principal, and `from SPINIFEX_EBS_PROVIDER` on the rest.
|
||||
|
||||
Under the instance principal, `ocibsd` uses the instance's own certificate. An API-key run runs the credential hook before formation instead, and seeds each node's `spinifex.toml` with `provider = "ocibsd"` and the hook's `/etc/spinifex/oci/config`, which init keeps as an existing config, so its `EBS provider:` line reads `from the existing config`.
|
||||
|
||||
Every volume and backup `ocibsd` creates is tagged `spinifex-cluster=<cluster_id>`, and none of them is in any Terraform state, so the run records the ID in `.validate-<topology>/ocibsd-clusters` before any volume exists.
|
||||
|
||||
The `ebs provider` gate then checks the units, the viperblock mask, the daemon's device drop-in, and that the compartment and availability domain init detected are the ones the state reports on every node, imports an AMI onto OCI and boots from it, and drives stop and start, attach and detach, snapshot and restore with a guest-written marker read back over the serial console. On `vm-multi-ocibsd` it also starts the stopped instance on another node. It ends by terminating everything and polling OCI until nothing tagged with the instance's volumes or snapshot is left.
|
||||
|
||||
`e2e-oci.sh` deletes everything tagged with each recorded `cluster_id` once that topology's nodes are destroyed, including from `--sweep-only`, and fails the run if anything tagged remains. A standalone `validate-topology.sh` run does not sweep, so follow it with `./e2e-oci.sh --sweep-only`.
|
||||
|
||||
The workbook runs **on the node** against `127.0.0.1`, because the node certificate carries no SAN for its public address — a workbook driven from outside the VCN is still blocked.
|
||||
|
||||
### Validate infrastructure Terraform already created
|
||||
|
||||
@@ -45,6 +45,11 @@ locals {
|
||||
# after the boot disk (mulga-poc has oraclevda1 pointing at sdb1).
|
||||
data_device = "/dev/oracleoci/oraclevdb"
|
||||
|
||||
# The Block Volume Management plugin logs iSCSI sessions in and out by itself,
|
||||
# and on an ocibsd node those sessions are ocibsd's to manage. With the plugin
|
||||
# off, the node's data volume attaches paravirtualized, which needs no login.
|
||||
ocibsd_nodes = var.ebs_provider == "ocibsd"
|
||||
|
||||
# adopt and create differ only in who creates the dynamic group and policy; a node
|
||||
# authenticates identically either way, because the matching rule covers the whole
|
||||
# compartment and so names no instance.
|
||||
@@ -106,11 +111,12 @@ resource "oci_core_instance" "mulgadc" {
|
||||
user_data = base64encode(local.user_data)
|
||||
}
|
||||
|
||||
# Required for automatic UHP iSCSI multipath setup and login.
|
||||
# Required for automatic UHP iSCSI multipath setup and login, and disabled on
|
||||
# an ocibsd node, where ocibsd owns every iSCSI session (see ocibsd_nodes).
|
||||
agent_config {
|
||||
plugins_config {
|
||||
name = "Block Volume Management"
|
||||
desired_state = "ENABLED"
|
||||
desired_state = local.ocibsd_nodes ? "DISABLED" : "ENABLED"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +171,10 @@ resource "oci_core_volume" "mulgadc_data" {
|
||||
|
||||
resource "oci_core_volume_attachment" "mulgadc_data" {
|
||||
count = var.node_count
|
||||
attachment_type = "iscsi"
|
||||
attachment_type = local.ocibsd_nodes ? "paravirtualized" : "iscsi"
|
||||
# Required by OCI for Ultra High Performance iSCSI volume attachments.
|
||||
device = local.data_device
|
||||
is_agent_auto_iscsi_login_enabled = true
|
||||
is_agent_auto_iscsi_login_enabled = local.ocibsd_nodes ? null : true
|
||||
instance_id = oci_core_instance.mulgadc[count.index].id
|
||||
volume_id = oci_core_volume.mulgadc_data[count.index].id
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
#
|
||||
# SPX_REF Ref to prove. Default: the spinifex checkout's current branch.
|
||||
# OCI_TOPOLOGIES Space-separated. Default: "vm-single vm-multi bm
|
||||
# vm-single-principal". Set one name to test one thing: a
|
||||
# single VM topology is roughly a quarter of the full run.
|
||||
# vm-single-principal vm-single-ocibsd vm-multi-ocibsd". Set
|
||||
# one name to test one thing. The two ocibsd topologies back
|
||||
# EBS with OCI Block Volumes, need OCI_SOURCE=tree and an
|
||||
# external pool, and are recorded SKIPPED without them.
|
||||
# OCI_SOURCE tree (build SPX_REF) or release (the published installer).
|
||||
# OCI_VERSION With OCI_SOURCE=release, install this exact release tag and
|
||||
# ignore OCI_CHANNEL. Preferred for anything repeatable: a tag
|
||||
@@ -82,7 +84,7 @@ SPINIFEX_ROOT="$(cd "$HERE/../../.." && pwd)"
|
||||
# under the workspace. Deriving one from the other is only right in the first case.
|
||||
MULGA_ROOT="${MULGA_ROOT:-$SPINIFEX_ROOT/..}"
|
||||
|
||||
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal}"
|
||||
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd}"
|
||||
SOURCE="${OCI_SOURCE:-tree}"
|
||||
CHANNEL="${OCI_CHANNEL:-latest}"
|
||||
INSTALL_VERSION="${OCI_VERSION:-}"
|
||||
@@ -289,6 +291,35 @@ publish_summary() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ocibsd's volumes and backups live in no Terraform state, so a destroy leaves any
|
||||
# a failed run made. Each cluster_id a topology recorded is swept by its tag once
|
||||
# the nodes are gone, and anything still tagged afterwards fails the run.
|
||||
sweep_ocibsd() {
|
||||
local dir="$1" record="$1/ocibsd-clusters" cluster compartment region left
|
||||
[ -s "$record" ] || return 0
|
||||
left="$(mktemp)"
|
||||
while IFS=$'\t' read -r cluster compartment region; do
|
||||
[ -n "$cluster" ] || continue
|
||||
log "sweep: OCI volumes and backups of ocibsd cluster $cluster"
|
||||
if OCIBSD_COMPARTMENT="$compartment" python3 "$HERE/scripts/oci_env.py" \
|
||||
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$region" -- \
|
||||
python3 "$HERE/scripts/oci_volumes.py" sweep --cluster "$cluster" \
|
||||
< /dev/null >> "$ARTIFACT_DIR/sweep.log" 2>&1; then
|
||||
log "sweep: nothing tagged with ocibsd cluster $cluster remains"
|
||||
else
|
||||
log "sweep: OCI still holds volumes or backups of ocibsd cluster $cluster. See $ARTIFACT_DIR/sweep.log"
|
||||
echo "sweep ${dir##*/.validate-} ocibsd cluster $cluster: FAIL" >> "$VERDICT"
|
||||
printf '%s\t%s\t%s\n' "$cluster" "$compartment" "$region" >> "$left"
|
||||
fi
|
||||
done < "$record"
|
||||
# Kept only for what is left, so the next sweep retries exactly that.
|
||||
if [ -s "$left" ]; then
|
||||
mv "$left" "$record"
|
||||
else
|
||||
rm -f "$left" "$record"
|
||||
fi
|
||||
}
|
||||
|
||||
# A topology destroys itself from its own EXIT trap, so this only catches a run
|
||||
# the trap never reached: a killed process, a runner reclaimed mid-job, a reboot.
|
||||
# It works from the state files rather than by name filter, so it can only ever
|
||||
@@ -297,12 +328,16 @@ sweep() {
|
||||
local found=0 dir topology
|
||||
local sweep_env=(env)
|
||||
for dir in "$STATE_ROOT"/.validate-*; do
|
||||
[ -s "$dir/terraform.tfstate" ] || continue
|
||||
[ -d "$dir" ] || continue
|
||||
if [ ! -s "$dir/terraform.tfstate" ]; then
|
||||
sweep_ocibsd "$dir"
|
||||
continue
|
||||
fi
|
||||
python3 -c '
|
||||
import json, sys
|
||||
state = json.load(open(sys.argv[1]))
|
||||
sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
|
||||
' "$dir/terraform.tfstate" 2>/dev/null || continue
|
||||
' "$dir/terraform.tfstate" 2>/dev/null || { sweep_ocibsd "$dir"; continue; }
|
||||
topology="${dir##*/.validate-}"
|
||||
# The child reads OCI_STATE_ROOT from the environment, so it looks in the
|
||||
# same place this loop found the state rather than beside its own script.
|
||||
@@ -319,6 +354,7 @@ sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
|
||||
if "${sweep_env[@]}" "$HERE/validate-topology.sh" --topology "$topology" --destroy-only \
|
||||
>> "$ARTIFACT_DIR/sweep.log" 2>&1; then
|
||||
log "sweep: $topology destroyed"
|
||||
sweep_ocibsd "$dir"
|
||||
else
|
||||
log "sweep: $topology WOULD NOT DESTROY -- it is still billing. See $ARTIFACT_DIR/sweep.log"
|
||||
echo "sweep $topology: FAIL" >> "$VERDICT"
|
||||
@@ -342,6 +378,10 @@ sweep
|
||||
|
||||
if [ "$SWEEP_ONLY" = 1 ]; then
|
||||
log "--sweep-only: done"
|
||||
if grep -q FAIL "$VERDICT"; then
|
||||
cat "$VERDICT"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -394,6 +434,22 @@ for topology in $TOPOLOGIES; do
|
||||
build_principal_env
|
||||
run_env=(env "${PRINCIPAL_ENV[@]}")
|
||||
fi
|
||||
# ocibsd's files ship in the distro this checkout builds, and its volumes
|
||||
# need the credential the pool uses, so neither can be stood in for.
|
||||
case "$topology" in
|
||||
*-ocibsd)
|
||||
if [ "$SOURCE" != tree ]; then
|
||||
log "$topology: SKIPPED -- the ocibsd files ship only with a tree build"
|
||||
echo "$topology: SKIPPED (OCI_SOURCE=$SOURCE)" >> "$VERDICT"
|
||||
continue
|
||||
fi
|
||||
if [ "${OCI_NO_EXTERNAL_POOL:-0}" = 1 ]; then
|
||||
log "$topology: SKIPPED -- ocibsd needs the OCI credential the external pool installs"
|
||||
echo "$topology: SKIPPED (OCI_NO_EXTERNAL_POOL=1)" >> "$VERDICT"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
args=(--topology "$topology" --ssh-public-key "$SSH_PUBLIC_KEY" --ssh-private-key "$SSH_PRIVATE_KEY")
|
||||
[ "$DRY_RUN" = 1 ] && args+=(--dry-run)
|
||||
# Two separate choices. --no-external-pool is the one that forms without any
|
||||
@@ -460,5 +516,8 @@ if [ "$RUN_RC" = 0 ] && ! grep -q FAIL "$VERDICT"; then
|
||||
else
|
||||
log "FAIL"
|
||||
printf 'FAIL\te2e-oci\t%d.000s\n' "$SECONDS"
|
||||
# A failed sweep is a FAIL in the verdict with every topology passing, and is
|
||||
# still resources left billing, so it fails the run as well.
|
||||
RUN_RC=1
|
||||
fi
|
||||
exit "$RUN_RC"
|
||||
|
||||
@@ -29,26 +29,44 @@ resource "oci_identity_dynamic_group" "nodes" {
|
||||
}
|
||||
|
||||
# Narrower than it looks. The allocator creates, moves and deletes secondary
|
||||
# private IPs on a node's own VNIC and attaches public IPs to them, which is what
|
||||
# these four verbs cover; nothing here grants compute, storage or identity.
|
||||
# private IPs on a node's own VNIC and attaches public IPs to them, and ocibsd
|
||||
# creates, attaches and backs up EBS volumes. Nothing here grants identity.
|
||||
locals {
|
||||
network_grants = [
|
||||
"use vnics",
|
||||
"manage private-ips",
|
||||
"manage public-ips",
|
||||
|
||||
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
|
||||
# both of which only the subnets `use` verb carries. Without it every allocation
|
||||
# is refused as a 404, which reads as a missing resource rather than a missing grant.
|
||||
"use subnets",
|
||||
]
|
||||
|
||||
# ocibsd: volumes, backups and attachments, and attaching them to an instance.
|
||||
# Without these every ocibsd call is refused and its service fails at start.
|
||||
# A viperblockd deployment touches no OCI volume, so it is not granted them.
|
||||
ocibsd_grants = [
|
||||
"manage volume-family",
|
||||
"use instances",
|
||||
]
|
||||
|
||||
node_grants = concat(local.network_grants, var.ebs_provider == "ocibsd" ? local.ocibsd_grants : [])
|
||||
}
|
||||
|
||||
resource "oci_identity_policy" "nodes" {
|
||||
provider = oci.home
|
||||
count = var.instance_principal == "create" ? 1 : 0
|
||||
compartment_id = var.compartment_ocid
|
||||
name = "${var.deployment_name}-nodes-network"
|
||||
description = "Allows Spinifex nodes to manage the private and public IPs backing external addresses."
|
||||
description = "Allows Spinifex nodes to manage the IPs backing external addresses and the OCI volumes backing EBS."
|
||||
|
||||
statements = [
|
||||
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use vnics in compartment id ${var.compartment_ocid}",
|
||||
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage private-ips in compartment id ${var.compartment_ocid}",
|
||||
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage public-ips in compartment id ${var.compartment_ocid}",
|
||||
|
||||
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
|
||||
# both of which only the subnets `use` verb carries. Without it every allocation
|
||||
# is refused as a 404, which reads as a missing resource rather than a missing grant.
|
||||
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use subnets in compartment id ${var.compartment_ocid}",
|
||||
for grant in local.node_grants :
|
||||
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to ${grant} in compartment id ${var.compartment_ocid}"
|
||||
]
|
||||
lifecycle {
|
||||
|
||||
lifecycle {
|
||||
precondition {
|
||||
condition = local.tenancy_home_region != ""
|
||||
error_message = "OCI did not report a tenancy home region; cannot create instance-principal IAM resources."
|
||||
|
||||
@@ -31,3 +31,20 @@ output "nodes" {
|
||||
output "hosts_file" {
|
||||
value = join("\n", [for instance in oci_core_instance.mulgadc : instance.public_ip])
|
||||
}
|
||||
|
||||
# The api_key route's equivalent of the instance-principal policy, for a tenancy
|
||||
# admin to apply to the API user's group. Sensitive only to keep the compartment
|
||||
# OCID out of apply.log; read it with terraform output api_key_policy_statements.
|
||||
output "api_key_policy_statements" {
|
||||
sensitive = true
|
||||
value = [
|
||||
for grant in local.node_grants :
|
||||
"Allow group ${var.api_key_group_name} to ${grant} in compartment id ${var.compartment_ocid}"
|
||||
]
|
||||
}
|
||||
|
||||
# Where spx admin init should detect the nodes on ocibsd, so the run can check
|
||||
# what init read from the instance metadata and knows where to sweep afterwards.
|
||||
output "availability_domain" { value = local.compute_availability_domain }
|
||||
output "region" { value = var.region }
|
||||
output "ebs_provider" { value = var.ebs_provider }
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find and sweep the OCI volumes and backups ocibsd tags, which no Terraform state holds.
|
||||
|
||||
Run under oci_env.py for the credential, with the compartment in OCIBSD_COMPARTMENT.
|
||||
Standard library and the openssl binary only; the key reaches openssl through a pipe.
|
||||
|
||||
oci_volumes.py volumes --tag spinifex-volume-id=vol-0abc
|
||||
oci_volumes.py backups --tag spinifex-snapshot-id=snap-0abc
|
||||
oci_volumes.py sweep --cluster <cluster_id> [--timeout 900]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import email.utils
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
API = "/20160918"
|
||||
# Gone for good; OCI keeps the record for a while and it cannot be deleted again.
|
||||
GONE = {"TERMINATED"}
|
||||
# OCI's own answers to a delete it will accept later: an attachment or a backup
|
||||
# still in progress (409), or a precondition such as a busy lifecycle (412).
|
||||
RETRY_LATER = {409, 412, 429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
class OCIError(Exception):
|
||||
def __init__(self, status: int, body: str):
|
||||
super().__init__(f"OCI answered {status}: {body[:300]}")
|
||||
self.status = status
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, env: dict[str, str]):
|
||||
missing = [k for k in ("TF_VAR_tenancy_ocid", "TF_VAR_user_ocid", "TF_VAR_fingerprint", "TF_VAR_region") if not env.get(k)]
|
||||
if missing:
|
||||
raise ValueError(f"run this under oci_env.py: {', '.join(missing)} unset")
|
||||
self.key_id = f"{env['TF_VAR_tenancy_ocid']}/{env['TF_VAR_user_ocid']}/{env['TF_VAR_fingerprint']}"
|
||||
self.host = f"iaas.{env['TF_VAR_region']}.oraclecloud.com"
|
||||
if env.get("TF_VAR_private_key"):
|
||||
self.key = env["TF_VAR_private_key"].encode()
|
||||
elif env.get("TF_VAR_private_key_path"):
|
||||
self.key = Path(env["TF_VAR_private_key_path"]).read_bytes()
|
||||
else:
|
||||
raise ValueError("run this under oci_env.py: no private key in TF_VAR_private_key or TF_VAR_private_key_path")
|
||||
|
||||
def _sign(self, data: bytes) -> str:
|
||||
read_end, write_end = os.pipe()
|
||||
try:
|
||||
os.write(write_end, self.key)
|
||||
finally:
|
||||
os.close(write_end)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["openssl", "dgst", "-sha256", "-sign", f"/dev/fd/{read_end}"],
|
||||
input=data,
|
||||
capture_output=True,
|
||||
pass_fds=(read_end,),
|
||||
check=False,
|
||||
)
|
||||
finally:
|
||||
os.close(read_end)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"openssl could not sign with the OCI key: {result.stderr.decode(errors='replace').strip()}")
|
||||
return base64.b64encode(result.stdout).decode()
|
||||
|
||||
def request(self, method: str, path: str, query: dict[str, str] | None = None) -> tuple[dict[str, str], object]:
|
||||
target = API + path
|
||||
if query:
|
||||
target += "?" + urllib.parse.urlencode(query)
|
||||
date = email.utils.formatdate(usegmt=True)
|
||||
headers = {"date": date, "host": self.host}
|
||||
signed = ["(request-target)", "date", "host"]
|
||||
lines = [f"(request-target): {method.lower()} {target}", f"date: {date}", f"host: {self.host}"]
|
||||
# A PUT or POST also signs its body, which this signer does not do.
|
||||
if method not in ("GET", "DELETE"):
|
||||
raise ValueError(f"unsupported method {method}")
|
||||
signature = self._sign("\n".join(lines).encode())
|
||||
headers["authorization"] = (
|
||||
f'Signature version="1",keyId="{self.key_id}",algorithm="rsa-sha256",'
|
||||
f'headers="{" ".join(signed)}",signature="{signature}"'
|
||||
)
|
||||
req = urllib.request.Request(f"https://{self.host}{target}", method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read()
|
||||
return dict(resp.headers), json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
raise OCIError(error.code, error.read().decode(errors="replace")) from None
|
||||
|
||||
def list_all(self, kind: str, compartment: str) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
query = {"compartmentId": compartment, "limit": "100"}
|
||||
while True:
|
||||
headers, page = self.request("GET", f"/{kind}", query)
|
||||
items.extend(page or [])
|
||||
next_page = {k.lower(): v for k, v in headers.items()}.get("opc-next-page")
|
||||
if not next_page:
|
||||
return items
|
||||
query["page"] = next_page
|
||||
|
||||
|
||||
def matching(items: list[dict], tags: dict[str, str]) -> list[dict]:
|
||||
"""The live items carrying every tag, with no OCID beyond the item's own."""
|
||||
found = []
|
||||
for item in items:
|
||||
freeform = item.get("freeformTags") or {}
|
||||
if item.get("lifecycleState") in GONE:
|
||||
continue
|
||||
if all(freeform.get(k) == v for k, v in tags.items()):
|
||||
found.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"displayName": item.get("displayName"),
|
||||
"lifecycleState": item.get("lifecycleState"),
|
||||
"freeformTags": freeform,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def parse_tags(pairs: list[str]) -> dict[str, str]:
|
||||
tags = {}
|
||||
for pair in pairs:
|
||||
key, sep, value = pair.partition("=")
|
||||
if not sep or not key:
|
||||
raise ValueError(f"--tag wants key=value, got {pair!r}")
|
||||
tags[key] = value
|
||||
return tags
|
||||
|
||||
|
||||
def sweep(client: Client, compartment: str, cluster: str, timeout: int, interval: int) -> int:
|
||||
"""Delete every live volume and backup tagged with this cluster until none is left.
|
||||
|
||||
OCI refuses both deletes while a backup is CREATING, so a refusal is retried
|
||||
until the deadline, which is what makes a run killed mid-snapshot sweepable.
|
||||
"""
|
||||
tags = {"spinifex-cluster": cluster}
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
left = []
|
||||
for kind in ("volumeBackups", "volumes"):
|
||||
for item in matching(client.list_all(kind, compartment), tags):
|
||||
left.append((kind, item))
|
||||
if item["lifecycleState"] == "TERMINATING":
|
||||
continue
|
||||
try:
|
||||
client.request("DELETE", f"/{kind}/{item['id']}")
|
||||
print(f"sweep: deleting {kind[:-1]} {item['displayName']} ({item['lifecycleState']})")
|
||||
except OCIError as error:
|
||||
if error.status == 404:
|
||||
continue
|
||||
if error.status not in RETRY_LATER:
|
||||
raise
|
||||
print(f"sweep: {kind[:-1]} {item['displayName']} not deletable yet: {error.status}")
|
||||
if not left:
|
||||
print(f"sweep: nothing tagged spinifex-cluster={cluster} is left")
|
||||
return 0
|
||||
if time.monotonic() >= deadline:
|
||||
for kind, item in left:
|
||||
print(f"sweep: STILL PRESENT {kind[:-1]} {item['displayName']} {item['lifecycleState']}")
|
||||
print(f"sweep: {len(left)} tagged resource(s) left after {timeout}s")
|
||||
return 1
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("volumes", "backups"):
|
||||
listing = sub.add_parser(name, help=f"print the live {name} carrying every --tag, as JSON")
|
||||
listing.add_argument("--tag", action="append", default=[], required=True)
|
||||
sweeping = sub.add_parser("sweep", help="delete every live volume and backup tagged with --cluster")
|
||||
sweeping.add_argument("--cluster", required=True)
|
||||
sweeping.add_argument("--timeout", type=int, default=900)
|
||||
sweeping.add_argument("--interval", type=int, default=15)
|
||||
args = parser.parse_args()
|
||||
|
||||
compartment = os.environ.get("OCIBSD_COMPARTMENT", "")
|
||||
if not compartment.startswith("ocid1."):
|
||||
raise ValueError("OCIBSD_COMPARTMENT is not set to a compartment OCID")
|
||||
client = Client(dict(os.environ))
|
||||
|
||||
if args.command == "sweep":
|
||||
if not args.cluster.strip():
|
||||
raise ValueError("--cluster is empty, which would match nothing and report clean")
|
||||
return sweep(client, compartment, args.cluster, args.timeout, args.interval)
|
||||
kind = "volumes" if args.command == "volumes" else "volumeBackups"
|
||||
print(json.dumps(matching(client.list_all(kind, compartment), parse_tags(args.tag))))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OCIError, ValueError, OSError) as error:
|
||||
print(f"oci_volumes.py: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -21,6 +21,7 @@ STATE_DIR="${OCI_STATE_ROOT:-$HERE}/.identity"
|
||||
SSH_PUBLIC_KEY="${OCI_SSH_PUBLIC_KEY:-$HOME/.ssh/oci-spx.pub}"
|
||||
DESTROY=0
|
||||
DRY_RUN=0
|
||||
EBS_PROVIDER=viperblockd
|
||||
|
||||
log() { printf '[setup-identity] %s\n' "$*"; }
|
||||
die() {
|
||||
@@ -30,14 +31,17 @@ die() {
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
usage: ${0##*/} [--dry-run] [--destroy]
|
||||
usage: ${0##*/} [--dry-run] [--destroy] [--ebs-provider viperblockd|ocibsd]
|
||||
|
||||
Creates the OCI dynamic group and policy for instance-principal authentication.
|
||||
Needs a tenancy-admin OCI credential; the compartment-scoped user the rest of this
|
||||
configuration runs as cannot create either resource.
|
||||
|
||||
--dry-run Show the plan, change nothing.
|
||||
--destroy Remove them again. Every node using "adopt" loses its credential.
|
||||
--dry-run Show the plan, change nothing.
|
||||
--destroy Remove them again. Every node using "adopt" loses its credential.
|
||||
--ebs-provider ocibsd also grants manage volume-family and use instances. The
|
||||
policy is shared by every deployment in the compartment, so a
|
||||
later run without it removes those grants. Default viperblockd.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
@@ -46,11 +50,16 @@ while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--destroy) DESTROY=1; shift ;;
|
||||
--ebs-provider) EBS_PROVIDER="${2:?}"; shift 2 ;;
|
||||
-h | --help) usage ;;
|
||||
*) die "unknown option $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$EBS_PROVIDER" in
|
||||
viperblockd | ocibsd) ;;
|
||||
*) die "--ebs-provider must be viperblockd or ocibsd, got '$EBS_PROVIDER'" ;;
|
||||
esac
|
||||
[ -r "$SSH_PUBLIC_KEY" ] || die "no readable SSH public key at $SSH_PUBLIC_KEY (set OCI_SSH_PUBLIC_KEY)"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
@@ -75,7 +84,7 @@ if [ "$DESTROY" = 1 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
args=(-var 'instance_principal=create' -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
|
||||
args=(-var 'instance_principal=create' -var "ebs_provider=$EBS_PROVIDER" -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
|
||||
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
log "dry run: planning the dynamic group and policy only"
|
||||
|
||||
@@ -49,7 +49,7 @@ WORKBOOKS=""
|
||||
# set -u an unset array is an error rather than an empty one.
|
||||
HOSTS=()
|
||||
|
||||
TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
|
||||
TOPOLOGIES="bm vm-single vm-multi vm-single-principal vm-single-ocibsd vm-multi-ocibsd"
|
||||
|
||||
# What a topology is worth when nothing says otherwise, so a CI run of a named
|
||||
# topology is the same every time. Supplied as TF_VAR_, which terraform.auto.tfvars
|
||||
@@ -61,8 +61,14 @@ TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
|
||||
#
|
||||
# A case rather than an associative array: macOS ships bash 3.2, where declare -A
|
||||
# is an indexed assignment and the key is read as an arithmetic variable.
|
||||
#
|
||||
# TOPO_EBS_PROVIDER is what backs EBS. The -ocibsd topologies are their viperblock
|
||||
# twins on OCI Block Volumes, so a difference between a pair is the provider's.
|
||||
# They set no provider and rely on init's detection on an OCI VM; every other
|
||||
# topology names viperblockd in SPINIFEX_EBS_PROVIDER to keep its baseline.
|
||||
topo_defaults() {
|
||||
TOPO_FORCE_PRINCIPAL=0
|
||||
TOPO_EBS_PROVIDER=viperblockd
|
||||
case "$1" in
|
||||
bm) TOPO_DEFAULT_SHAPE="BM.Standard.E2.64"; TOPO_DEFAULT_NODES=1 ;;
|
||||
vm-single) TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1 ;;
|
||||
@@ -74,6 +80,10 @@ topo_defaults() {
|
||||
TOPO_DEFAULT_SHAPE="${OCI_PRINCIPAL_SHAPE:-VM.Standard.E6.Flex}"
|
||||
TOPO_DEFAULT_NODES=1
|
||||
TOPO_FORCE_PRINCIPAL=1 ;;
|
||||
vm-single-ocibsd)
|
||||
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1; TOPO_EBS_PROVIDER=ocibsd ;;
|
||||
vm-multi-ocibsd)
|
||||
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=3; TOPO_EBS_PROVIDER=ocibsd ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -86,7 +96,7 @@ die() {
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal> [options]
|
||||
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal|vm-single-ocibsd|vm-multi-ocibsd> [options]
|
||||
|
||||
Builds a topology, or installs on existing OCI hosts, then installs Spinifex,
|
||||
forms the cluster, and verifies the OCI address allocator.
|
||||
@@ -95,6 +105,13 @@ forms the cluster, and verifies the OCI address allocator.
|
||||
topology is the easiest expensive mistake here.
|
||||
vm-single-principal is vm-single authenticating as the
|
||||
instance, and implies --instance-principal.
|
||||
vm-single-ocibsd and vm-multi-ocibsd set no EBS
|
||||
provider, so init detects ocibsd on the OCI VM, and
|
||||
add the ebs provider gate; the others set
|
||||
SPINIFEX_EBS_PROVIDER=viperblockd. The ocibsd pair
|
||||
need --distro. Their OCI volumes and backups are
|
||||
in no Terraform state: the destroy here leaves any a
|
||||
failed run made, and e2e-oci.sh sweeps them by tag.
|
||||
--ssh-public-key PATH Public half installed on each node. Default $SSH_PUBLIC_KEY
|
||||
--ssh-private-key PATH Private half used to reach them. Default $SSH_PRIVATE_KEY
|
||||
--instance-principal Configure the pool with oci_auth="instance_principal"
|
||||
@@ -106,7 +123,9 @@ forms the cluster, and verifies the OCI address allocator.
|
||||
Create the dynamic group and policy before deployment.
|
||||
Needs a tenancy-admin profile. Opt-in because these are
|
||||
tenancy-level resources. Use --identity-oci-profile to
|
||||
use a different profile for this one-time setup.
|
||||
use a different profile for this one-time setup. The
|
||||
policy carries the volume grants only on an ocibsd
|
||||
topology, and it is shared, so run it from one of those.
|
||||
--identity-oci-profile NAME
|
||||
Tenancy-admin profile for --setup-instance-principal.
|
||||
Defaults to --oci-profile / OCI_PROFILE.
|
||||
@@ -209,6 +228,19 @@ fi
|
||||
|
||||
[ -z "$EXISTING_HOSTS_FILE" ] || [ -z "$EXISTING_STATE_FILE" ] \
|
||||
|| die "--existing-hosts-file and --existing-state-file are mutually exclusive"
|
||||
|
||||
# ocibsd needs a node credential, which a run with no pool never installs, and the
|
||||
# compartment and region from the state, which a hosts file does not carry, to
|
||||
# sweep and check its volumes. setup.sh stages its files from the tarball, and a
|
||||
# published release predates them.
|
||||
if [ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$DESTROY_ONLY" = 0 ]; then
|
||||
[ "$SKIP_POOL" != 1 ] \
|
||||
|| die "--no-external-pool installs no OCI credential, so ocibsd on $TOPOLOGY could not reach the OCI API"
|
||||
[ -z "$EXISTING_HOSTS_FILE" ] \
|
||||
|| die "$TOPOLOGY reads its compartment and availability domain from Terraform state; use --existing-state-file"
|
||||
[ -n "$DISTRO" ] || [ "$DRY_RUN" = 1 ] \
|
||||
|| die "$TOPOLOGY needs --distro: a published release predates the ocibsd files setup.sh stages and init installs"
|
||||
fi
|
||||
if [ -n "$EXISTING_HOSTS_FILE" ] || [ -n "$EXISTING_STATE_FILE" ]; then
|
||||
EXISTING_INFRA=1
|
||||
[ "$DESTROY_ONLY" = 0 ] \
|
||||
@@ -281,7 +313,7 @@ tf() {
|
||||
# deployment sizes itself in that file and the topology only supplies the
|
||||
# default a CI run wants.
|
||||
TF_VAR_compute_shape="$TOPO_DEFAULT_SHAPE" TF_VAR_node_count="$TOPO_DEFAULT_NODES" \
|
||||
python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
|
||||
TF_VAR_ebs_provider="$TOPO_EBS_PROVIDER" python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
|
||||
${OCI_PROFILE:+--profile "$OCI_PROFILE"} -- \
|
||||
terraform -chdir="$HERE" "$@"
|
||||
}
|
||||
@@ -383,18 +415,18 @@ identity_resources() {
|
||||
"$HERE/setup-identity.sh" --destroy
|
||||
elif [ "$DRY_RUN" = 1 ]; then
|
||||
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
|
||||
"$HERE/setup-identity.sh" --dry-run
|
||||
"$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
|
||||
else
|
||||
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
|
||||
"$HERE/setup-identity.sh"
|
||||
"$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
|
||||
fi
|
||||
else
|
||||
if [ "$action" = destroy ]; then
|
||||
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --destroy
|
||||
elif [ "$DRY_RUN" = 1 ]; then
|
||||
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run
|
||||
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
|
||||
else
|
||||
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh"
|
||||
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -443,8 +475,10 @@ mark_stage() { : > "$STATE_DIR/stage-$1.done"; }
|
||||
formation_complete() {
|
||||
local host
|
||||
stage_done formation || return 1
|
||||
# A line only init writes, not a non-empty file: an ocibsd API-key run seeds the
|
||||
# file with the credential before init, so a fresh node already has one.
|
||||
for host in "${HOSTS[@]}"; do
|
||||
ssh_node "$host" 'test -s /etc/spinifex/spinifex.toml' || return 1
|
||||
ssh_node "$host" "grep -q '^data_dir = ' /etc/spinifex/spinifex.toml" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
@@ -634,6 +668,7 @@ if [ "$DRY_RUN" = 1 ]; then
|
||||
| sed -E 's/ +=/ =/' | sort -u | sed 's/^/ /'
|
||||
log "full plan in $STATE_DIR/plan.txt"
|
||||
plan="install Spinifex on $NODES node(s), form the cluster"
|
||||
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && plan="$plan on ocibsd, run the ebs provider gate"
|
||||
[ "$SKIP_POOL" = 1 ] && plan="$plan, configure no external pool"
|
||||
if [ "$SKIP_WORKLOAD" = 1 ]; then
|
||||
plan="$plan, launch no guests"
|
||||
@@ -669,6 +704,29 @@ else
|
||||
|| die "read instance_principal='$PRINCIPAL_MODE' from the tfvars and the apply built '$built_principal'; set it in one place"
|
||||
fi
|
||||
|
||||
# Where ocibsd should keep the cluster's volumes, read back from what was built
|
||||
# rather than from this run's inputs, to check what init detected and to sweep.
|
||||
# Never logged: the compartment is an account's OCID.
|
||||
topo_output() {
|
||||
if [ "$EXISTING_INFRA" = 1 ]; then
|
||||
state_output "$1"
|
||||
else
|
||||
tf output -raw "${tf_state[@]}" "$1"
|
||||
fi
|
||||
}
|
||||
OCIBSD_COMPARTMENT=""
|
||||
OCIBSD_AD=""
|
||||
OCIBSD_REGION=""
|
||||
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
|
||||
built_provider="$(topo_output ebs_provider 2>/dev/null)" \
|
||||
|| die "the state has no ebs_provider output; it predates ocibsd, so rebuild it"
|
||||
[ "$built_provider" = ocibsd ] \
|
||||
|| die "$TOPOLOGY needs nodes built for ocibsd and the state says ebs_provider='$built_provider'; a tfvars override wins over the topology"
|
||||
OCIBSD_COMPARTMENT="$(topo_output compartment_ocid)" || die "could not read compartment_ocid from the state"
|
||||
OCIBSD_AD="$(topo_output availability_domain)" || die "could not read availability_domain from the state"
|
||||
OCIBSD_REGION="$(topo_output region)" || die "could not read region from the state"
|
||||
fi
|
||||
|
||||
# Written for every topology, not just the multi-node one that passes it to
|
||||
# install-node.sh: it is also how an operator gets the addresses afterwards
|
||||
# without a terraform invocation and the right -state path.
|
||||
@@ -768,6 +826,42 @@ REMOTE
|
||||
done
|
||||
record install PASS "$INSTALLED_VERSION"
|
||||
|
||||
|
||||
# spx admin init's EBS choice, written to every node once (ADR-0018:S3). The ocibsd
|
||||
# topologies set nothing, so init detects ocibsd on the OCI VM; the rest name
|
||||
# viperblockd, which keeps the viperblock baseline on OCI.
|
||||
INIT_EBS_PROVIDER=""
|
||||
[ "$TOPO_EBS_PROVIDER" = ocibsd ] || INIT_EBS_PROVIDER=viperblockd
|
||||
|
||||
# ocibsd defaults to the instance principal. An API-key run gives init the key
|
||||
# instead: the hook installs it first, and each node's config is seeded with
|
||||
# provider and credential, which init keeps as an existing config's values.
|
||||
API_KEY_EBS=0
|
||||
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$PRINCIPAL_MODE" = off ] && API_KEY_EBS=1
|
||||
PRESEEDED_EBS=0
|
||||
if [ "$API_KEY_EBS" = 1 ] && ! formation_complete; then
|
||||
log "running the credential hook before formation, so init can check the ocibsd credential"
|
||||
"$CREDENTIAL_HOOK" "$SSH_PRIVATE_KEY" "${HOSTS[@]}" > "$STATE_DIR/credential-hook.log" 2>&1 \
|
||||
|| die "the credential hook failed; see $STATE_DIR/credential-hook.log"
|
||||
for i in "${!HOSTS[@]}"; do
|
||||
# Named as init and install-node.sh name them: the host on one node,
|
||||
# node1..nodeN on several.
|
||||
if [ "$NODES" = 1 ]; then name='$(hostname -s)'; else name="node$((i + 1))"; fi
|
||||
ssh_node "${HOSTS[$i]}" "sed \"s/@NODE@/$name/\" | sudo tee /etc/spinifex/spinifex.toml >/dev/null" <<'TOML' \
|
||||
|| die "could not seed the ocibsd credential on ${HOSTS[$i]}"
|
||||
[nodes.@NODE@.ebs]
|
||||
provider = "ocibsd"
|
||||
|
||||
[nodes.@NODE@.ebs.oci]
|
||||
auth = "config_file"
|
||||
config_file = "/etc/spinifex/oci/config"
|
||||
config_profile = "spinifex"
|
||||
TOML
|
||||
done
|
||||
PRESEEDED_EBS=1
|
||||
record "ocibsd credential" PASS "API key installed and seeded before init"
|
||||
fi
|
||||
|
||||
# A single node is its own documented path: install-node.sh refuses fewer than two
|
||||
# hosts, because there is nothing to join. Both branches use the flags the guide
|
||||
# names, so what is validated is what the guide tells a customer to type.
|
||||
@@ -776,19 +870,29 @@ if formation_complete; then
|
||||
record formation SKIP "checkpoint verified"
|
||||
elif [ "$NODES" = 1 ]; then
|
||||
log "initializing the single node"
|
||||
ssh_node "${HOSTS[0]}" '
|
||||
set -e
|
||||
sudo spx admin init --node "$(hostname -s)" --nodes 1 \
|
||||
--region ap-southeast-2 --az ap-southeast-2a \
|
||||
--external-mode=nat --ipsec=false
|
||||
sudo systemctl start spinifex.target
|
||||
' > "$STATE_DIR/form.log" 2>&1 \
|
||||
ssh_node "${HOSTS[0]}" "INIT_EBS_PROVIDER=$INIT_EBS_PROVIDER PRESEEDED_EBS=$PRESEEDED_EBS bash -s" \
|
||||
> "$STATE_DIR/form.log" 2>&1 <<'REMOTE' \
|
||||
|| die "single-node init failed; see $STATE_DIR/form.log"
|
||||
set -e
|
||||
ebs_env=()
|
||||
[ -z "$INIT_EBS_PROVIDER" ] || ebs_env=("SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER")
|
||||
# The seeded config is the only one there, so replacing it needs --force.
|
||||
force=()
|
||||
[ "$PRESEEDED_EBS" = 0 ] || force=(--force)
|
||||
sudo env -u SPINIFEX_EBS_PROVIDER ${ebs_env[@]+"${ebs_env[@]}"} spx admin init ${force[@]+"${force[@]}"} \
|
||||
--node "$(hostname -s)" --nodes 1 \
|
||||
--region ap-southeast-2 --az ap-southeast-2a \
|
||||
--external-mode=nat --ipsec=false
|
||||
sudo systemctl start spinifex.target
|
||||
REMOTE
|
||||
mark_stage formation
|
||||
record formation PASS
|
||||
else
|
||||
log "forming the cluster"
|
||||
"$REPO_ROOT/scripts/install-node.sh" \
|
||||
# env -u first, so a SPINIFEX_EBS_PROVIDER in this shell cannot stand in for
|
||||
# detection on the ocibsd topologies.
|
||||
env -u SPINIFEX_EBS_PROVIDER ${INIT_EBS_PROVIDER:+SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER} \
|
||||
"$REPO_ROOT/scripts/install-node.sh" \
|
||||
--hosts-file "$STATE_DIR/hosts" \
|
||||
--user ubuntu \
|
||||
--identity "$SSH_PRIVATE_KEY" \
|
||||
@@ -800,6 +904,53 @@ else
|
||||
record formation PASS
|
||||
fi
|
||||
|
||||
# What init printed about its EBS choice, from the log formation left: init's own
|
||||
# output on one node, the init log install-node.sh keeps on the first host on several.
|
||||
init_ebs_line() {
|
||||
if [ "$NODES" = 1 ]; then
|
||||
grep -m1 '^EBS provider: ' "$STATE_DIR/form.log"
|
||||
else
|
||||
ssh_node "${HOSTS[0]}" "sudo grep -m1 '^EBS provider: ' /var/log/spx-init.log"
|
||||
fi
|
||||
}
|
||||
# The provider has to be the topology's for the reason the topology names: the
|
||||
# ocibsd pair detected on the VM, the rest told viperblockd by the environment,
|
||||
# so a baseline flipped by detection or an ocibsd run that was configured fails.
|
||||
case "$TOPO_EBS_PROVIDER:$API_KEY_EBS" in
|
||||
ocibsd:0) want_ebs='EBS provider: ocibsd (detected: OCI virtual machine ' ;;
|
||||
ocibsd:1) want_ebs='EBS provider: ocibsd (from the existing config)' ;;
|
||||
*) want_ebs='EBS provider: viperblockd (from SPINIFEX_EBS_PROVIDER)' ;;
|
||||
esac
|
||||
ebs_line="$(init_ebs_line)" || die "init printed no EBS provider line; see $STATE_DIR/form.log"
|
||||
case "$ebs_line" in
|
||||
"$want_ebs"*) record "ebs provider choice" PASS "${ebs_line#EBS provider: }" ;;
|
||||
*) die "init chose '$ebs_line', and $TOPOLOGY needs '$want_ebs'" ;;
|
||||
esac
|
||||
|
||||
# ocibsd tags every volume and backup with the cluster_id spx admin init generated,
|
||||
# and no Terraform state holds them, so e2e-oci.sh sweeps by that tag after the
|
||||
# destroy. Recorded before any volume exists, so a killed run is still sweepable.
|
||||
OCIBSD_CLUSTERS="$STATE_DIR/ocibsd-clusters"
|
||||
OCIBSD_CLUSTER_ID=""
|
||||
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
|
||||
OCIBSD_CLUSTER_ID="$(ssh_node "${HOSTS[0]}" 'sudo python3 -' <<'PY'
|
||||
import sys, tomllib
|
||||
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
|
||||
# A leader's config also lists the other nodes, without their EBS tables.
|
||||
ids = {n["ebs"]["oci"].get("cluster_id", "") for n in nodes.values() if "oci" in n.get("ebs", {})}
|
||||
if len(ids) != 1 or "" in ids:
|
||||
sys.exit("expected one cluster_id across [nodes.*.ebs.oci], found %r" % sorted(ids))
|
||||
print(ids.pop())
|
||||
PY
|
||||
)" || die "could not read the ocibsd cluster_id from ${HOSTS[0]}"
|
||||
[[ "$OCIBSD_CLUSTER_ID" =~ ^[0-9a-f]{32}$ ]] \
|
||||
|| die "${HOSTS[0]} reports cluster_id '$OCIBSD_CLUSTER_ID', which is not the 128-bit hex value init writes"
|
||||
# Appended: a record a failed sweep left behind must survive this run's.
|
||||
line="$(printf '%s\t%s\t%s' "$OCIBSD_CLUSTER_ID" "$OCIBSD_COMPARTMENT" "$OCIBSD_REGION")"
|
||||
grep -qxF "$line" "$OCIBSD_CLUSTERS" 2>/dev/null || printf '%s\n' "$line" >> "$OCIBSD_CLUSTERS"
|
||||
log "ocibsd cluster_id $OCIBSD_CLUSTER_ID, recorded for the sweep"
|
||||
fi
|
||||
|
||||
# Runs after formation and before the pool is configured, which is the only window
|
||||
# where a node has /etc/spinifex but has not yet started the allocator. Nothing in
|
||||
# this repository knows what it does: an API-key deployment needs a credential on
|
||||
@@ -809,7 +960,7 @@ if pool_complete; then
|
||||
log "OCI pool configuration already completed (resuming; pool setup skipped)"
|
||||
record pool SKIP "checkpoint verified"
|
||||
else
|
||||
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ]; then
|
||||
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ] && [ "$PRESEEDED_EBS" = 0 ]; then
|
||||
log "running the credential hook"
|
||||
# Arguments, not a file: the hook is told where the nodes are and how to reach
|
||||
# them, and decides for itself what to put there.
|
||||
@@ -936,9 +1087,9 @@ fi
|
||||
|
||||
# Parse the STATUS column, never grep for Ready: NotReady contains it, and that
|
||||
# read passed a single-node run whose only node was NotReady on 0.0.0.0. The table
|
||||
# is colourised, so the escapes come off first.
|
||||
# is colourised and drawn with box characters, so both come off first.
|
||||
count_ready() {
|
||||
sed 's/\x1b\[[0-9;]*m//g' "$STATE_DIR/nodes.txt" \
|
||||
sed -e 's/\x1b\[[0-9;]*m//g' -e 's/│/|/g' "$STATE_DIR/nodes.txt" \
|
||||
| awk -F'|' 'NR > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 == "Ready") n++ } END { print n + 0 }'
|
||||
}
|
||||
|
||||
@@ -962,10 +1113,382 @@ fi
|
||||
|
||||
if [ "$SKIP_WORKLOAD" = 1 ]; then
|
||||
log "--skip-workload: stopping before the workbook"
|
||||
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
|
||||
record "ebs provider" SKIPPED "--skip-workload"
|
||||
fi
|
||||
record workbooks SKIPPED "--skip-workload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The ebs provider gate, ocibsd only. The workbooks reach EBS only through RDS,
|
||||
# ECS and EKS, so this boots from an imported AMI, stops, attaches, snapshots,
|
||||
# restores and terminates, checking each against OCI's own view of the volumes.
|
||||
|
||||
GATE_IMAGE=""
|
||||
GATE_LOG="$STATE_DIR/ebs-gate.log"
|
||||
|
||||
# The AWS CLI on node 1, as the workbook driver runs it. Each argument is quoted
|
||||
# for the remote shell, so a JMESPath expression arrives as one word.
|
||||
node_aws() {
|
||||
ssh_node "${HOSTS[0]}" "AWS_PROFILE=spinifex aws --output text $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
# OCI's live volumes or backups carrying every key=value given, as JSON, read from
|
||||
# this runner with the credential the apply used; nothing on a node is trusted.
|
||||
oci_tagged() {
|
||||
local kind="$1" tag tags=()
|
||||
shift
|
||||
for tag in "$@"; do tags+=(--tag "$tag"); done
|
||||
OCIBSD_COMPARTMENT="$OCIBSD_COMPARTMENT" python3 "$HERE/scripts/oci_env.py" \
|
||||
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$OCIBSD_REGION" -- \
|
||||
python3 "$HERE/scripts/oci_volumes.py" "$kind" "${tags[@]}" 2>>"$GATE_LOG"
|
||||
}
|
||||
|
||||
oci_count() {
|
||||
oci_tagged "$@" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))'
|
||||
}
|
||||
|
||||
# get-console-output is base64 on the wire and the CLI may or may not decode it.
|
||||
console_text() {
|
||||
node_aws ec2 get-console-output --instance-id "$1" --query Output 2>/dev/null \
|
||||
| python3 -c '
|
||||
import base64, binascii, sys
|
||||
raw = sys.stdin.read().strip()
|
||||
try:
|
||||
sys.stdout.write(base64.b64decode(raw, validate=True).decode("utf-8", "replace"))
|
||||
except (binascii.Error, ValueError):
|
||||
sys.stdout.write(raw)
|
||||
' | tr -d '\r'
|
||||
}
|
||||
|
||||
# wait_console <instance> <ERE> <seconds> prints the last console line matching.
|
||||
wait_console() {
|
||||
local id="$1" pattern="$2" deadline=$((SECONDS + $3)) line
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
line="$(console_text "$id" | grep -E "$pattern" | tail -1 || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '%s\n' "$line"
|
||||
return 0
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# wait_new_boot <instance> <token> <boots seen> <seconds> prints the boot ID of
|
||||
# the first boot not yet seen that found the root marker, proving a restart kept it.
|
||||
wait_new_boot() {
|
||||
local id="$1" tok="$2" seen="$3" deadline=$((SECONDS + $4)) boot
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
for boot in $(console_text "$id" \
|
||||
| sed -n "s/.*SPXGATE boot=\([0-9a-f-]*\) root-present serial=[^ ]* $tok\$/\1/p"); do
|
||||
case " $seen " in
|
||||
*" $boot "*) ;;
|
||||
*) printf '%s\n' "$boot"; return 0 ;;
|
||||
esac
|
||||
done
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The node whose QEMU runs the instance, by process, as the multinode suite finds it.
|
||||
hosting_node() {
|
||||
local host found=""
|
||||
for host in "${HOSTS[@]}"; do
|
||||
if ssh_node "$host" "ps -eo args | grep -F -- '$1' | grep -q '[q]emu-system'"; then
|
||||
[ -z "$found" ] || die "$1 runs on both $found and $host"
|
||||
found="$host"
|
||||
fi
|
||||
done
|
||||
[ -n "$found" ] || die "no node runs a QEMU for $1"
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
wait_gone_from_oci() {
|
||||
local deadline=$((SECONDS + 600)) left vol
|
||||
while :; do
|
||||
left=0
|
||||
for vol in "$@"; do
|
||||
case "$vol" in
|
||||
snap-*) left=$((left + $(oci_count backups "spinifex-snapshot-id=$vol"))) ;;
|
||||
*) left=$((left + $(oci_count volumes "spinifex-volume-id=$vol"))) ;;
|
||||
esac
|
||||
done
|
||||
[ "$left" = 0 ] && return 0
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 15
|
||||
done
|
||||
}
|
||||
|
||||
# Reports on the serial console, which get-console-output reads: the root marker
|
||||
# at each boot, then each Spinifex disk (serial vol...) as it appears, writing a
|
||||
# marker to a blank one and reading one back from a restored one.
|
||||
write_gate_user_data() {
|
||||
cat > "$1" <<'USERDATA'
|
||||
#cloud-config
|
||||
write_files:
|
||||
- path: /usr/local/sbin/spx-ebs-gate
|
||||
permissions: "0755"
|
||||
content: |
|
||||
#!/bin/sh
|
||||
BOOT=$(cat /proc/sys/kernel/random/boot_id)
|
||||
say() { echo "SPXGATE boot=$BOOT $*" > /dev/ttyS0; }
|
||||
serial() { cat "/sys/block/$1/serial" 2>/dev/null || cat "/sys/block/$1/device/serial" 2>/dev/null || true; }
|
||||
src=$(findmnt -no SOURCE /)
|
||||
rootdisk=$(lsblk -no PKNAME "$src" | head -1)
|
||||
[ -n "$rootdisk" ] || rootdisk=${src##*/}
|
||||
mark=/var/lib/spx-ebs-gate/root
|
||||
if [ -s "$mark" ]; then
|
||||
say "root-present serial=$(serial "$rootdisk") $(cat "$mark")"
|
||||
else
|
||||
mkdir -p /var/lib/spx-ebs-gate
|
||||
cat /proc/sys/kernel/random/uuid > "$mark.tmp" && sync && mv "$mark.tmp" "$mark" && sync
|
||||
say "root-written serial=$(serial "$rootdisk") $(cat "$mark")"
|
||||
fi
|
||||
seen=" "
|
||||
while :; do
|
||||
next=" "
|
||||
for sys in /sys/block/*; do
|
||||
d=${sys##*/}
|
||||
[ "$d" != "$rootdisk" ] || continue
|
||||
s=$(serial "$d")
|
||||
case "$s" in vol*) ;; *) continue ;; esac
|
||||
case "$seen" in *" $d "*) next="$next$d "; continue ;; esac
|
||||
head=$(dd if="/dev/$d" bs=4096 count=1 iflag=direct 2>/dev/null | head -c 64 | tr -d '\000')
|
||||
case "$head" in
|
||||
"SPXGATE-DATA "*)
|
||||
say "data-present serial=$s ${head#SPXGATE-DATA }"; next="$next$d " ;;
|
||||
"")
|
||||
if blkid -p "/dev/$d" >/dev/null 2>&1; then
|
||||
say "data-foreign serial=$s"; next="$next$d "
|
||||
else
|
||||
tok=$(cat /proc/sys/kernel/random/uuid)
|
||||
if printf 'SPXGATE-DATA %s\n' "$tok" \
|
||||
| dd of="/dev/$d" bs=4096 count=1 conv=sync oflag=direct,sync 2>/dev/null; then
|
||||
say "data-written serial=$s $tok"; next="$next$d "
|
||||
fi
|
||||
fi ;;
|
||||
*) say "data-foreign serial=$s"; next="$next$d " ;;
|
||||
esac
|
||||
done
|
||||
seen=$next
|
||||
sleep 3
|
||||
done
|
||||
- path: /etc/systemd/system/spx-ebs-gate.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Spinifex EBS provider gate reporter
|
||||
After=local-fs.target
|
||||
[Service]
|
||||
ExecStart=/usr/local/sbin/spx-ebs-gate
|
||||
Restart=always
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
runcmd:
|
||||
- [systemctl, daemon-reload]
|
||||
- [systemctl, enable, --now, spx-ebs-gate.service]
|
||||
USERDATA
|
||||
}
|
||||
|
||||
# What the gate leaves for a failure it cannot explain from its own output: each
|
||||
# node's iSCSI sessions and records, block devices and their holders, and the
|
||||
# provider's and kernel's recent log. Best effort, so a failed read never masks
|
||||
# the gate's own error.
|
||||
gate_diagnose() {
|
||||
local host
|
||||
for host in "${HOSTS[@]}"; do
|
||||
{
|
||||
printf '\n=== diagnostics from %s ===\n' "$host"
|
||||
ssh_node "$host" 'sudo bash -s' <<'DIAG' 2>&1
|
||||
for c in "iscsiadm -m session -P 3" "iscsiadm -m node" "lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,HCTL" "ls -l /dev/disk/by-path"; do
|
||||
printf -- '--- %s\n' "$c"; $c 2>&1
|
||||
done
|
||||
printf -- '--- holders of iSCSI devices\n'
|
||||
for d in /dev/disk/by-path/*iqn.2015-12.com.oracleiaas:*; do
|
||||
[ -e "$d" ] || continue
|
||||
dev=$(readlink -f "$d"); printf '%s -> %s\n' "$d" "$dev"
|
||||
ls /sys/class/block/"${dev##*/}"/holders 2>&1; fuser -v "$dev" 2>&1
|
||||
done
|
||||
printf -- '--- journalctl -u spinifex-ocibsd\n'; journalctl -u spinifex-ocibsd -n 300 --no-pager 2>&1
|
||||
printf -- '--- journalctl -k\n'; journalctl -k -n 150 --no-pager 2>&1
|
||||
DIAG
|
||||
} >> "$GATE_LOG" || true
|
||||
done
|
||||
}
|
||||
gate_die() {
|
||||
gate_diagnose
|
||||
die "$@"
|
||||
}
|
||||
|
||||
ebs_provider_gate() {
|
||||
local host out ami snap_ami itype subnet iid az root_vol dot tok boot boots line
|
||||
local data_vol data_serial data_tok snap restored from to
|
||||
: > "$GATE_LOG"
|
||||
|
||||
log "ebs provider gate: units, masks, drop-ins and the detected placement on every node"
|
||||
for host in "${HOSTS[@]}"; do
|
||||
# The placement init read from the instance metadata must be where this run
|
||||
# built the nodes; compared on the node, so the compartment is never printed.
|
||||
ssh_node "$host" "WANT_COMPARTMENT='$OCIBSD_COMPARTMENT' WANT_AD='$OCIBSD_AD' bash -s" >> "$GATE_LOG" 2>&1 <<'REMOTE' \
|
||||
|| gate_die "$host is not an ocibsd node as installed; see $GATE_LOG"
|
||||
fail() { echo "FAIL $(hostname -s): $*"; exit 1; }
|
||||
[ "$(systemctl is-enabled spinifex-viperblock.service 2>&1)" = masked ] \
|
||||
|| fail "spinifex-viperblock.service is not masked"
|
||||
if systemctl is-active --quiet spinifex-viperblock.service; then fail "spinifex-viperblock.service is running"; fi
|
||||
systemctl is-active --quiet spinifex-ocibsd.service || fail "spinifex-ocibsd.service is not active"
|
||||
[ "$(systemctl is-enabled spinifex-ocibsd.service)" = enabled ] || fail "spinifex-ocibsd.service is not enabled"
|
||||
systemctl show -p DropInPaths --value spinifex-daemon.service \
|
||||
| grep -q /etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf \
|
||||
|| fail "the daemon's ocibsd device drop-in is not loaded"
|
||||
systemctl show -p DeviceAllow --value spinifex-daemon.service | grep -q 'block-sd rw' \
|
||||
|| fail "the daemon's device cgroup does not allow block-sd"
|
||||
test -f /etc/udev/rules.d/99-spinifex-ocibsd.rules || fail "no ocibsd udev rule"
|
||||
sudo WANT_COMPARTMENT="$WANT_COMPARTMENT" WANT_AD="$WANT_AD" python3 - <<"PY" \
|
||||
|| fail "a node in spinifex.toml is not on ocibsd where the nodes were built"
|
||||
import os, sys, tomllib
|
||||
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
|
||||
# Only tables with an ebs section: a leader also lists the other nodes, bare.
|
||||
# Each node's own table always has one, and this runs on every node.
|
||||
nodes = {n: v for n, v in nodes.items() if "ebs" in v}
|
||||
other = {n: v["ebs"].get("provider") for n, v in nodes.items() if v["ebs"].get("provider") != "ocibsd"}
|
||||
if not nodes or other:
|
||||
sys.exit("providers other than ocibsd: %r" % other)
|
||||
for n, v in sorted(nodes.items()):
|
||||
oci = v["ebs"].get("oci", {})
|
||||
if oci.get("compartment_id") != os.environ["WANT_COMPARTMENT"]:
|
||||
sys.exit("%s: compartment_id is not the compartment the nodes were built in" % n)
|
||||
if oci.get("availability_domain") != os.environ["WANT_AD"]:
|
||||
sys.exit("%s: availability_domain %r, and the nodes were built in %r" % (n, oci.get("availability_domain"), os.environ["WANT_AD"]))
|
||||
print("ebs providers:", ", ".join("%s=ocibsd" % n for n in sorted(nodes)))
|
||||
PY
|
||||
echo "ok $(hostname -s)"
|
||||
REMOTE
|
||||
done
|
||||
record "ebs provider units" PASS "ocibsd active, viperblock masked, device drop-in, placement from metadata on $NODES node(s)"
|
||||
|
||||
GATE_IMAGE=ubuntu-26.04-x86_64
|
||||
log "ebs provider gate: importing $GATE_IMAGE onto an OCI volume"
|
||||
out="$(ssh_node "${HOSTS[0]}" "sudo spx admin images import --name $GATE_IMAGE --config /etc/spinifex/spinifex.toml" 2>&1)" \
|
||||
|| { printf '%s\n' "$out" >> "$GATE_LOG"; gate_die "importing $GATE_IMAGE failed; see $GATE_LOG"; }
|
||||
printf '%s\n' "$out" >> "$GATE_LOG"
|
||||
ami="$(printf '%s\n' "$out" | sed -n 's/.*Image-ID (AMI): \(ami-[0-9a-f]*\).*/\1/p' | tail -1)"
|
||||
[ -n "$ami" ] || gate_die "the import printed no AMI ID; see $GATE_LOG"
|
||||
snap_ami="$(node_aws ec2 describe-images --image-ids "$ami" --query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId')"
|
||||
[ "$(oci_count backups "spinifex-snapshot-id=$snap_ami" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|
||||
|| gate_die "$ami's snapshot $snap_ami is not one OCI backup tagged with this cluster"
|
||||
record "ami import" PASS "$ami, snapshot $snap_ami is an OCI backup"
|
||||
|
||||
itype="$(node_aws ec2 describe-instance-types \
|
||||
--query 'sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus>=`2` && MemoryInfo.SizeInMiB>=`2048`], &MemoryInfo.SizeInMiB)[0].InstanceType')"
|
||||
{ [ -n "$itype" ] && [ "$itype" != None ]; } || gate_die "no instance type with 2 vCPU and 2 GiB on this cluster"
|
||||
subnet="$(node_aws ec2 describe-subnets --filters Name=default-for-az,Values=true \
|
||||
--query 'Subnets[0].SubnetId' 2>>"$GATE_LOG")" || subnet=""
|
||||
[ "$subnet" != None ] || subnet=""
|
||||
write_gate_user_data "$STATE_DIR/ebs-gate-user-data.yaml"
|
||||
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o LogLevel=ERROR -q "$STATE_DIR/ebs-gate-user-data.yaml" "ubuntu@${HOSTS[0]}:/tmp/ebs-gate-user-data.yaml" \
|
||||
|| gate_die "could not copy the gate's user-data"
|
||||
|
||||
log "ebs provider gate: booting $itype from $ami"
|
||||
iid="$(node_aws ec2 run-instances --image-id "$ami" --instance-type "$itype" --count 1 \
|
||||
${subnet:+--subnet-id "$subnet"} --user-data file:///tmp/ebs-gate-user-data.yaml \
|
||||
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ebs-provider-gate}]' \
|
||||
--query 'Instances[0].InstanceId')" || gate_die "run-instances from $ami failed"
|
||||
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never reached running"
|
||||
read -r az root_vol dot <<EOF
|
||||
$(node_aws ec2 describe-instances --instance-ids "$iid" \
|
||||
--query 'Reservations[0].Instances[0].[Placement.AvailabilityZone,BlockDeviceMappings[0].Ebs.VolumeId,BlockDeviceMappings[0].Ebs.DeleteOnTermination]')
|
||||
EOF
|
||||
[ "$dot" = True ] || gate_die "$iid's root volume $root_vol is not delete-on-termination"
|
||||
line="$(wait_console "$iid" 'SPXGATE boot=[0-9a-f-]+ root-written ' 900)" \
|
||||
|| gate_die "$iid never reported its root marker on the console"
|
||||
boot="$(printf '%s\n' "$line" | sed -n 's/.*boot=\([0-9a-f-]*\) .*/\1/p')"
|
||||
tok="${line##* }"
|
||||
boots="$boot"
|
||||
[ "$(oci_count volumes "spinifex-volume-id=$root_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|
||||
|| gate_die "$iid's root volume $root_vol is not one OCI volume tagged with this cluster"
|
||||
record "boot from ocibsd" PASS "$iid on $root_vol, an OCI volume tagged with this cluster"
|
||||
|
||||
log "ebs provider gate: stop and start $iid"
|
||||
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
|
||||
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
|
||||
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
|
||||
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran again"
|
||||
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|
||||
|| gate_die "$iid did not find its root marker after a stop and start"
|
||||
boots="$boots $boot"
|
||||
record "stop and start" PASS "the root marker survived"
|
||||
|
||||
log "ebs provider gate: attach and detach a data volume"
|
||||
data_vol="$(node_aws ec2 create-volume --availability-zone "$az" --size 1 --volume-type gp3 \
|
||||
--query VolumeId)" || gate_die "create-volume failed"
|
||||
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never became available"
|
||||
node_aws ec2 attach-volume --volume-id "$data_vol" --instance-id "$iid" --device /dev/sdf >/dev/null \
|
||||
|| gate_die "attach-volume $data_vol failed"
|
||||
node_aws ec2 wait volume-in-use --volume-ids "$data_vol" || gate_die "$data_vol never attached"
|
||||
data_serial="${data_vol//-/}"
|
||||
line="$(wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-written serial=$data_serial " 600)" \
|
||||
|| gate_die "the guest never wrote to $data_vol"
|
||||
data_tok="${line##* }"
|
||||
[ "$(oci_count volumes "spinifex-volume-id=$data_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|
||||
|| gate_die "$data_vol is not one OCI volume tagged with this cluster"
|
||||
node_aws ec2 detach-volume --volume-id "$data_vol" >/dev/null || gate_die "detach-volume $data_vol failed"
|
||||
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never detached"
|
||||
record "attach and detach" PASS "$data_vol written in the guest"
|
||||
|
||||
log "ebs provider gate: snapshot, restore and read back"
|
||||
snap="$(node_aws ec2 create-snapshot --volume-id "$data_vol" --query SnapshotId)" \
|
||||
|| gate_die "create-snapshot $data_vol failed"
|
||||
node_aws ec2 wait snapshot-completed --snapshot-ids "$snap" || gate_die "$snap never completed"
|
||||
[ "$(oci_count backups "spinifex-snapshot-id=$snap" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|
||||
|| gate_die "$snap is not one OCI backup tagged with this cluster"
|
||||
restored="$(node_aws ec2 create-volume --availability-zone "$az" --snapshot-id "$snap" \
|
||||
--volume-type gp3 --query VolumeId)" || gate_die "create-volume from $snap failed"
|
||||
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never became available"
|
||||
node_aws ec2 attach-volume --volume-id "$restored" --instance-id "$iid" --device /dev/sdg >/dev/null \
|
||||
|| gate_die "attach-volume $restored failed"
|
||||
node_aws ec2 wait volume-in-use --volume-ids "$restored" || gate_die "$restored never attached"
|
||||
wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-present serial=${restored//-/} $data_tok\$" 600 >/dev/null \
|
||||
|| gate_die "the guest did not read $data_vol's marker back from $restored"
|
||||
node_aws ec2 detach-volume --volume-id "$restored" >/dev/null || gate_die "detach-volume $restored failed"
|
||||
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never detached"
|
||||
record "snapshot and restore" PASS "$snap restored to $restored with the marker intact"
|
||||
|
||||
# A stopped instance restarts on its last node unless that node's daemon does
|
||||
# not answer, so the daemon is stopped there for the start. The guest is down
|
||||
# first, so no write is in flight; this is not a fence test.
|
||||
if [ "$NODES" -gt 1 ]; then
|
||||
from="$(hosting_node "$iid")"
|
||||
log "ebs provider gate: moving $iid off $from"
|
||||
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
|
||||
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
|
||||
ssh_node "$from" 'sudo systemctl stop spinifex-daemon.service' || gate_die "could not stop the daemon on $from"
|
||||
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
|
||||
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran on another node"
|
||||
ssh_node "$from" 'sudo systemctl start spinifex-daemon.service' || gate_die "could not restart the daemon on $from"
|
||||
to="$(hosting_node "$iid")"
|
||||
[ "$to" != "$from" ] || gate_die "$iid started on $from again, so nothing moved"
|
||||
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|
||||
|| gate_die "$iid did not find its root marker on $to"
|
||||
record "cross-node start" PASS "$iid moved from $from to $to with its root marker"
|
||||
fi
|
||||
|
||||
log "ebs provider gate: terminate and delete"
|
||||
node_aws ec2 terminate-instances --instance-ids "$iid" >/dev/null || gate_die "terminate-instances $iid failed"
|
||||
node_aws ec2 wait instance-terminated --instance-ids "$iid" || gate_die "$iid never terminated"
|
||||
node_aws ec2 delete-volume --volume-id "$data_vol" || gate_die "delete-volume $data_vol failed"
|
||||
node_aws ec2 delete-volume --volume-id "$restored" || gate_die "delete-volume $restored failed"
|
||||
node_aws ec2 delete-snapshot --snapshot-id "$snap" || gate_die "delete-snapshot $snap failed"
|
||||
wait_gone_from_oci "$root_vol" "$data_vol" "$restored" "$snap" \
|
||||
|| gate_die "OCI still holds a live volume or backup for $root_vol, $data_vol, $restored or $snap after 600s"
|
||||
record "terminate" PASS "no OCI volume or backup left for the instance, its volumes or the snapshot"
|
||||
}
|
||||
|
||||
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
|
||||
ebs_provider_gate
|
||||
fi
|
||||
|
||||
# The same driver the GitHub nightly runs (cell 17), on the same published
|
||||
# workbooks, so a workbook that passes on a hypervisor and fails on OCI is a
|
||||
# difference in OCI and not in the test. It owns its own per-workbook assertions
|
||||
@@ -1002,12 +1525,18 @@ scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev
|
||||
# to absorb parallel uploads. Three are appliances rather than distros: RDS, ECS
|
||||
# and EKS each boot their own, found by a spinifex:managed-by tag and never by name.
|
||||
log "importing the images the workbooks need"
|
||||
ssh_node "${HOSTS[0]}" '
|
||||
set -e
|
||||
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
|
||||
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
|
||||
done
|
||||
' > "$STATE_DIR/images.log" 2>&1 || die "image import failed; see $STATE_DIR/images.log"
|
||||
# The ebs provider gate has already imported its own, and is not asked twice.
|
||||
WORKBOOK_IMAGES=""
|
||||
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
|
||||
[ "$img" = "$GATE_IMAGE" ] || WORKBOOK_IMAGES="$WORKBOOK_IMAGES $img"
|
||||
done
|
||||
ssh_node "${HOSTS[0]}" "WORKBOOK_IMAGES='$WORKBOOK_IMAGES' bash -s" > "$STATE_DIR/images.log" 2>&1 <<'REMOTE' \
|
||||
|| die "image import failed; see $STATE_DIR/images.log"
|
||||
set -e
|
||||
for img in $WORKBOOK_IMAGES; do
|
||||
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
|
||||
done
|
||||
REMOTE
|
||||
|
||||
# Every workbook that is a workbook. The shared driver's own default is five, which
|
||||
# leaves ECS and all three EKS variants untested on every platform -- their
|
||||
|
||||
@@ -68,6 +68,28 @@ variable "instance_principal" {
|
||||
}
|
||||
}
|
||||
|
||||
# What backs EBS on these nodes. ocibsd attaches an OCI block volume per EBS
|
||||
# volume at runtime, so the Oracle Cloud Agent's Block Volume Management plugin
|
||||
# is disabled and the node's own data volume attaches paravirtualized instead.
|
||||
variable "ebs_provider" {
|
||||
description = "EBS provider the nodes are installed with: viperblockd, or ocibsd for OCI Block Volumes."
|
||||
type = string
|
||||
default = "viperblockd"
|
||||
|
||||
validation {
|
||||
condition = contains(["viperblockd", "ocibsd"], var.ebs_provider)
|
||||
error_message = "ebs_provider must be viperblockd or ocibsd."
|
||||
}
|
||||
}
|
||||
|
||||
# Named only in the api_key_policy_statements output, which a tenancy admin
|
||||
# applies to the group holding the API user that spx-oci-config.sh installs.
|
||||
variable "api_key_group_name" {
|
||||
description = "IAM group of the API-key user the nodes authenticate as when instance_principal is off."
|
||||
type = string
|
||||
default = "spinifex-nodes"
|
||||
}
|
||||
|
||||
variable "vcn_cidr" {
|
||||
description = "VCN address space. A /22 leaves room for the per-node secondary private IPs Spinifex allocates for external addresses."
|
||||
type = string
|
||||
|
||||
@@ -349,7 +349,8 @@ rm_path /etc/systemd/system/spinifex-*.service \
|
||||
/etc/systemd/system/spinifex.slice \
|
||||
/etc/systemd/system/spinifex-system.slice \
|
||||
/etc/systemd/system/spinifex-guests.slice \
|
||||
/etc/systemd/system/system.slice.d/spinifex-reserve.conf
|
||||
/etc/systemd/system/system.slice.d/spinifex-reserve.conf \
|
||||
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
|
||||
|
||||
# Drop-ins we wrote into *other people's* units. These are the dangerous ones:
|
||||
# they are not in a spinifex-named path, so a namespace glob misses them, and
|
||||
|
||||
@@ -42,6 +42,7 @@ ASSUME Capabilities \in [online_expansion : BOOLEAN,
|
||||
read_only_publish : BOOLEAN,
|
||||
volume_enumeration : BOOLEAN,
|
||||
snapshot_enumeration : BOOLEAN,
|
||||
snapshot_copy : BOOLEAN,
|
||||
owner_routing : BOOLEAN,
|
||||
exclusion : [scope : {"none", "node", "cluster"},
|
||||
claim_ttl_seconds : Nat,
|
||||
|
||||
@@ -17,6 +17,8 @@ The contract that every implementation of the `EBSProvider` interface must meet.
|
||||
A failed call leaves the state unchanged, but a timeout can arrive after the provider acted, and a crash can leave work half done.
|
||||
- Is the schema version part of the contract?
|
||||
Every method refuses an unsupported version with `unsupported_version`, and the conformance suite tests it, but the specification does not model versions.
|
||||
A block export arrives at contract version 2: a PublishVolume call below it, for a volume that only a host block device can export, is refused with `unsupported_version`.
|
||||
A snapshot's `state` in a ListSnapshots ref, and the `snapshot_id` filter, arrive at contract version 4: a ref carries no state below it, and a filtered call below it is refused with `unsupported_version` rather than answered with every snapshot.
|
||||
- Are `internal` and `unavailable` the only errors that a failed provider may cause?
|
||||
- Must a present but malformed ID be refused with `invalid_argument`?
|
||||
IDs are opaque here, so only an omitted one is refused.
|
||||
@@ -100,13 +102,10 @@ The contract that every implementation of the `EBSProvider` interface must meet.
|
||||
|
||||
### Snapshots
|
||||
|
||||
- Should a pending snapshot's move to `completed` or `error` be modelled, as a step that happens without a call?
|
||||
Without it a pending snapshot never completes, and no snapshot is ever in `error`.
|
||||
|
||||
#### CreateSnapshot
|
||||
|
||||
- May success return a `pending` snapshot?
|
||||
AWS starts a snapshot as `pending`, while the memory implementation returns `completed` and the NATS implementation waits for completion and refuses anything else.
|
||||
- May a provider forget a snapshot's source?
|
||||
The control plane mints every snapshot ID and records it before the first call, so the ID alone names the request. A provider that no longer knows a snapshot's source, such as qemu-nbd after a restart, answers a retry with the snapshot, and refuses with `already_exists` only when it knows the source differs. The specification models a provider that always knows the source.
|
||||
- Must a retry compare the volume handle?
|
||||
It is ignored, as in the memory implementation, so a volume deleted and recreated under the same ID returns the old snapshot.
|
||||
- Should a copy's ID count as a retry?
|
||||
@@ -133,7 +132,7 @@ The contract that every implementation of the `EBSProvider` interface must meet.
|
||||
- Is the handle check part of the contract?
|
||||
The Viperblock implementation ignores the handle and deletes anyway.
|
||||
- May a pending snapshot be deleted?
|
||||
AWS allows it.
|
||||
AWS allows it, while OCI refuses to delete a backup that is still being created, so the control plane never asks: it defers a customer's delete of a pending snapshot until the snapshot settles.
|
||||
- What does success promise?
|
||||
The Go docs say the snapshot is gone, but a snapshot whose handle did not match is left in place.
|
||||
- Should a snapshot that does not exist be refused with `not_found`?
|
||||
|
||||
@@ -13,8 +13,9 @@ CreateSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
|
||||
|
||||
CreateSnapshot(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. A retry of a call that *)
|
||||
(* already succeeded returns the same snapshot. *)
|
||||
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. The snapshot holds the *)
|
||||
(* volume's data as it was when the call returned, even while it is pending, and a pending snapshot settles later *)
|
||||
(* without a call (SettleSnapshot). A retry of a call that already succeeded returns the same snapshot. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET snapID == req.snapshot_id
|
||||
volID == req.volume_id
|
||||
@@ -69,13 +70,14 @@ CopySnapshot(req, val, err) ==
|
||||
volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
|
||||
\/ srcSnapID = dstSnapID
|
||||
\* Only compared, not looked up, since a snapshot outlives its volume.
|
||||
\/ /\ KeyExists(snapshots, srcSnapID)
|
||||
/\ snapshots[srcSnapID].source_volume_id /= volID,
|
||||
already_exists |-> KeyExists(snapshots, dstSnapID),
|
||||
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots)]
|
||||
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
|
||||
\/ srcSnapID = dstSnapID
|
||||
\* Only compared, not looked up, since a snapshot outlives its volume.
|
||||
\/ /\ KeyExists(snapshots, srcSnapID)
|
||||
/\ snapshots[srcSnapID].source_volume_id /= volID,
|
||||
already_exists |-> KeyExists(snapshots, dstSnapID),
|
||||
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots),
|
||||
unsupported_capability |-> ~Capabilities.snapshot_copy]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
@@ -97,27 +99,45 @@ CopySnapshot(req, val, err) ==
|
||||
|
||||
|
||||
ListSnapshotsRequests == [max_results : -1..(MaxListResults + 1),
|
||||
starting_token : Optional(ListTokens)]
|
||||
starting_token : Optional(ListTokens),
|
||||
snapshot_id : Optional(SnapshotIDs)]
|
||||
|
||||
\* The ref that ListSnapshots returns for a snapshot, carrying its current state.
|
||||
SnapshotRef(id) == [id |-> id,
|
||||
source_volume_id |-> snapshots[id].source_volume_id,
|
||||
handle |-> snapshots[id].handle,
|
||||
state |-> snapshots[id].state]
|
||||
|
||||
ListSnapshots(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* Some of the snapshots are listed, a page at a time, and nothing changes. A call with no starting token that *)
|
||||
(* returns no next token lists every snapshot. *)
|
||||
(* returns no next token lists every snapshot. A call that names a snapshot returns that snapshot in its current *)
|
||||
(* state, or nothing when it does not exist, so an accepted snapshot is never reported absent while it is pending. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET Errors ==
|
||||
LET checks == [unsupported_capability |-> ~Capabilities.snapshot_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
LET snapID == req.snapshot_id
|
||||
|
||||
Errors ==
|
||||
\* A call that names a snapshot reads only that one, so it needs no enumeration.
|
||||
LET checks == [unsupported_capability |-> snapID = None /\ ~Capabilities.snapshot_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request named one snapshot, so paging is ignored.
|
||||
\/ /\ Errors = {}
|
||||
/\ snapID /= None
|
||||
/\ val = [snapshots |-> IF KeyExists(snapshots, snapID) THEN {SnapshotRef(snapID)} ELSE {},
|
||||
next_token |-> None]
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ LET refs == {[id |-> id,
|
||||
source_volume_id |-> snapshots[id].source_volume_id,
|
||||
handle |-> snapshots[id].handle] : id \in DOMAIN snapshots}
|
||||
/\ snapID = None
|
||||
/\ LET refs == {SnapshotRef(id) : id \in DOMAIN snapshots}
|
||||
\* The page and the next token are the implementation's choice.
|
||||
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
|
||||
/\ Cardinality(page) <= PageSize(req.max_results)
|
||||
@@ -156,4 +176,18 @@ DeleteSnapshot(req, err) ==
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Steps without a call
|
||||
|
||||
SettleSnapshot(snapID) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A pending snapshot settles as completed or error, as the provider finishes it. Its data is unchanged, since it *)
|
||||
(* was fixed when CreateSnapshot returned, and a snapshot that has settled never changes state again. *)
|
||||
(*******************************************************************************************************************)
|
||||
/\ KeyExists(snapshots, snapID)
|
||||
/\ snapshots[snapID].state = "pending"
|
||||
/\ \E s \in {"completed", "error"} :
|
||||
snapshots' = [snapshots EXCEPT ![snapID].state = s]
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
=======================================================================================================================
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -94,6 +95,18 @@ type Agent struct {
|
||||
statsFn func(socketPath string) ([]ServerStatus, error)
|
||||
// For testing: override the active health prober (nginx/NLB).
|
||||
probeFn func(targets []HealthTarget) []ServerStatus
|
||||
|
||||
// Data-path probe: netcfg and sysfs locations, the ARP exchange, the
|
||||
// instance ID lookup and per-NIC miss counters (overridable in tests).
|
||||
netcfgPath string
|
||||
sysNetDir string
|
||||
probeWait time.Duration
|
||||
arpProbeFn arpProbeFn
|
||||
instanceIDFn func() (string, error)
|
||||
instanceID string
|
||||
idSkip int
|
||||
probeMu sync.Mutex
|
||||
probeStates map[string]*nicProbeState
|
||||
}
|
||||
|
||||
// New creates a new LB agent for the given load balancer.
|
||||
@@ -150,6 +163,12 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
|
||||
reloadNginxFn: reloadNginx,
|
||||
statsFn: queryHAProxyStats,
|
||||
probeFn: probeHealthTargets,
|
||||
netcfgPath: netcfgPath,
|
||||
sysNetDir: sysClassNet,
|
||||
probeWait: probeWait,
|
||||
arpProbeFn: arpProbe,
|
||||
instanceIDFn: fetchInstanceID,
|
||||
probeStates: map[string]*nicProbeState{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -207,7 +226,7 @@ func (a *Agent) tick() {
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.sendHeartbeat(servers)
|
||||
resp, err := a.sendHeartbeat(servers, a.dataPathEvidence())
|
||||
if err != nil {
|
||||
// Include the dial target so the serial console disambiguates a
|
||||
// transport failure (never reached AWSGW — "send request: dial ...")
|
||||
@@ -288,7 +307,7 @@ func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string,
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat with health report to the gateway.
|
||||
func (a *Agent) sendHeartbeat(servers []ServerStatus) (*heartbeatResponse, error) {
|
||||
func (a *Agent) sendHeartbeat(servers []ServerStatus, ev dataPathEvidence) (*heartbeatResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Action", "LBAgentHeartbeat")
|
||||
params.Set("Version", "2015-12-01")
|
||||
@@ -300,6 +319,7 @@ func (a *Agent) sendHeartbeat(servers []ServerStatus) (*heartbeatResponse, error
|
||||
params.Set("Servers.member."+idx+".Server", s.Server)
|
||||
params.Set("Servers.member."+idx+".Status", s.Status)
|
||||
}
|
||||
ev.addTo(params)
|
||||
|
||||
body, err := a.signedPost(params)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//go:build linux
|
||||
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// arpProbe sends one ARP request for gateway out of iface on an AF_PACKET
|
||||
// socket bound to it and waits for the gateway's reply on the same socket.
|
||||
func arpProbe(iface string, mac net.HardwareAddr, src, gateway netip.Addr, wait time.Duration) error {
|
||||
ifi, err := net.InterfaceByName(iface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interface %s: %w", iface, err)
|
||||
}
|
||||
|
||||
proto := htons(syscall.ETH_P_ARP)
|
||||
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(proto))
|
||||
if err != nil {
|
||||
return fmt.Errorf("packet socket: %w", err)
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
addr := &syscall.SockaddrLinklayer{Protocol: proto, Ifindex: ifi.Index}
|
||||
if err := syscall.Bind(fd, addr); err != nil {
|
||||
return fmt.Errorf("bind %s: %w", iface, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(wait)
|
||||
if err := syscall.Sendto(fd, buildARPRequest(mac, src, gateway), 0, addr); err != nil {
|
||||
return fmt.Errorf("send ARP request: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 128)
|
||||
for {
|
||||
left := time.Until(deadline)
|
||||
if left <= 0 {
|
||||
return errARPNoReply
|
||||
}
|
||||
tv := syscall.NsecToTimeval(int64(left))
|
||||
if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv); err != nil {
|
||||
return fmt.Errorf("set receive deadline: %w", err)
|
||||
}
|
||||
n, _, err := syscall.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("receive ARP reply: %w", err)
|
||||
}
|
||||
if isARPReplyFrom(buf[:n], mac, gateway) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func htons(v uint16) uint16 { return v<<8 | v>>8 }
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// arpProbe is only meaningful inside the Linux guest.
|
||||
func arpProbe(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
return errors.New("ARP probe is only supported on linux")
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// netcfgPath is the fw_cfg blob the microVM init script reads to configure NICs.
|
||||
netcfgPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw"
|
||||
|
||||
sysClassNet = "/sys/class/net"
|
||||
|
||||
// probeFailureThreshold is the number of consecutive missed probes after
|
||||
// which a NIC is reported as failing (about 15 s at the poll interval).
|
||||
probeFailureThreshold = 3
|
||||
|
||||
// arpExchangeWait bounds one ARP exchange. probeWait bounds how long a tick
|
||||
// waits for all probes, so a stuck probe never delays the heartbeat.
|
||||
arpExchangeWait = time.Second
|
||||
probeWait = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
// nicConfig is one NIC of the netcfg blob that has a gateway to probe.
|
||||
type nicConfig struct {
|
||||
MAC string
|
||||
Addr netip.Addr
|
||||
Gateway netip.Addr
|
||||
}
|
||||
|
||||
// NICReport is the per-NIC data-path evidence carried in the heartbeat.
|
||||
type NICReport struct {
|
||||
MAC string
|
||||
ProbeOK bool
|
||||
ConsecutiveFailures int64
|
||||
RxPackets int64
|
||||
TxPackets int64
|
||||
RxDropped int64
|
||||
TxDropped int64
|
||||
}
|
||||
|
||||
// arpProbeFn sends one ARP request for gateway out of iface and returns nil
|
||||
// when the gateway's reply arrives within timeout.
|
||||
type arpProbeFn func(iface string, mac net.HardwareAddr, src, gateway netip.Addr, timeout time.Duration) error
|
||||
|
||||
// parseNetcfg reads the KEY=value netcfg blob and returns the NICs that have a
|
||||
// gateway, in NIC order. The mgmt NIC carries no gateway and is skipped, as
|
||||
// is any NIC without a usable MAC, address or gateway.
|
||||
func parseNetcfg(blob []byte) []nicConfig {
|
||||
type raw struct{ mac, cidr, gw string }
|
||||
byIndex := map[int]*raw{}
|
||||
|
||||
sc := bufio.NewScanner(bytes.NewReader(blob))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok || !strings.HasPrefix(key, "NIC") {
|
||||
continue
|
||||
}
|
||||
idxStr, field, ok := strings.Cut(strings.TrimPrefix(key, "NIC"), "_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r := byIndex[idx]
|
||||
if r == nil {
|
||||
r = &raw{}
|
||||
byIndex[idx] = r
|
||||
}
|
||||
switch field {
|
||||
case "MAC":
|
||||
r.mac = strings.ToLower(val)
|
||||
case "CIDR":
|
||||
r.cidr = val
|
||||
case "GW":
|
||||
r.gw = val
|
||||
}
|
||||
}
|
||||
|
||||
idxs := make([]int, 0, len(byIndex))
|
||||
for i := range byIndex {
|
||||
idxs = append(idxs, i)
|
||||
}
|
||||
sort.Ints(idxs)
|
||||
|
||||
var out []nicConfig
|
||||
for _, i := range idxs {
|
||||
r := byIndex[i]
|
||||
if r.mac == "" || r.cidr == "" || r.gw == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(r.cidr)
|
||||
if err != nil || !prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
gw, err := netip.ParseAddr(r.gw)
|
||||
if err != nil || !gw.Is4() {
|
||||
continue
|
||||
}
|
||||
out = append(out, nicConfig{MAC: r.mac, Addr: prefix.Addr(), Gateway: gw})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ifaceByMAC returns the interface under sysNetDir whose address is mac.
|
||||
func ifaceByMAC(sysNetDir, mac string) (string, error) {
|
||||
entries, err := os.ReadDir(sysNetDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
b, err := os.ReadFile(filepath.Join(sysNetDir, e.Name(), "address"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(string(b)), mac) {
|
||||
return e.Name(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no interface with MAC %s", mac)
|
||||
}
|
||||
|
||||
// readCounters fills the sysfs statistics of iface into r.
|
||||
func readCounters(sysNetDir, iface string, r *NICReport) {
|
||||
read := func(name string) int64 {
|
||||
b, err := os.ReadFile(filepath.Join(sysNetDir, iface, "statistics", name))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
return v
|
||||
}
|
||||
r.RxPackets = read("rx_packets")
|
||||
r.TxPackets = read("tx_packets")
|
||||
r.RxDropped = read("rx_dropped")
|
||||
r.TxDropped = read("tx_dropped")
|
||||
}
|
||||
|
||||
var errARPNoReply = errors.New("no ARP reply from the gateway")
|
||||
|
||||
// buildARPRequest returns the Ethernet frame asking who has target.
|
||||
func buildARPRequest(mac net.HardwareAddr, src, target netip.Addr) []byte {
|
||||
f := make([]byte, 0, 42)
|
||||
f = append(f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)
|
||||
f = append(f, mac...)
|
||||
f = binary.BigEndian.AppendUint16(f, 0x0806)
|
||||
f = binary.BigEndian.AppendUint16(f, 1) // Ethernet
|
||||
f = binary.BigEndian.AppendUint16(f, 0x0800) // IPv4
|
||||
f = append(f, 6, 4)
|
||||
f = binary.BigEndian.AppendUint16(f, 1) // request
|
||||
f = append(f, mac...)
|
||||
s := src.As4()
|
||||
f = append(f, s[:]...)
|
||||
f = append(f, 0, 0, 0, 0, 0, 0)
|
||||
t := target.As4()
|
||||
f = append(f, t[:]...)
|
||||
return f
|
||||
}
|
||||
|
||||
// isARPReplyFrom reports whether frame is an ARP reply sent by sender to mac.
|
||||
func isARPReplyFrom(frame []byte, mac net.HardwareAddr, sender netip.Addr) bool {
|
||||
if len(frame) < 42 || binary.BigEndian.Uint16(frame[12:14]) != 0x0806 {
|
||||
return false
|
||||
}
|
||||
if binary.BigEndian.Uint16(frame[20:22]) != 2 || !bytes.Equal(frame[32:38], mac) {
|
||||
return false
|
||||
}
|
||||
s := sender.As4()
|
||||
return bytes.Equal(frame[28:32], s[:])
|
||||
}
|
||||
|
||||
// nicProbeState carries the miss counter of one NIC across ticks. A probe that
|
||||
// has not returned is still in flight, and counts as a miss without starting
|
||||
// another goroutine behind it.
|
||||
type nicProbeState struct {
|
||||
mu sync.Mutex
|
||||
failures int64
|
||||
inflight bool
|
||||
}
|
||||
|
||||
type probeResult struct {
|
||||
ok bool
|
||||
set bool
|
||||
}
|
||||
|
||||
// probeNICs probes every gateway-bearing data NIC once, in parallel, and
|
||||
// returns within probeWait however long a probe blocks.
|
||||
func (a *Agent) probeNICs() []NICReport {
|
||||
blob, err := os.ReadFile(a.netcfgPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nics := parseNetcfg(blob)
|
||||
if len(nics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
reports := make([]NICReport, len(nics))
|
||||
results := make([]chan probeResult, len(nics))
|
||||
for i, n := range nics {
|
||||
results[i] = make(chan probeResult, 1)
|
||||
st := a.probeState(n.MAC)
|
||||
|
||||
st.mu.Lock()
|
||||
busy := st.inflight
|
||||
if !busy {
|
||||
st.inflight = true
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if busy {
|
||||
results[i] <- probeResult{set: true}
|
||||
continue
|
||||
}
|
||||
|
||||
go func(n nicConfig, st *nicProbeState, out chan<- probeResult) {
|
||||
ok := a.probeOne(n)
|
||||
st.mu.Lock()
|
||||
st.inflight = false
|
||||
st.mu.Unlock()
|
||||
out <- probeResult{ok: ok, set: true}
|
||||
}(n, st, results[i])
|
||||
}
|
||||
|
||||
deadline := time.NewTimer(a.probeWait)
|
||||
defer deadline.Stop()
|
||||
expired := false
|
||||
for i, n := range nics {
|
||||
var res probeResult
|
||||
if expired {
|
||||
select {
|
||||
case res = <-results[i]:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case res = <-results[i]:
|
||||
case <-deadline.C:
|
||||
// Out of time: an unfinished probe is a miss for this tick.
|
||||
expired = true
|
||||
}
|
||||
}
|
||||
|
||||
st := a.probeState(n.MAC)
|
||||
st.mu.Lock()
|
||||
if res.ok {
|
||||
st.failures = 0
|
||||
} else {
|
||||
st.failures++
|
||||
}
|
||||
failures := st.failures
|
||||
st.mu.Unlock()
|
||||
|
||||
r := NICReport{
|
||||
MAC: n.MAC,
|
||||
ProbeOK: failures < probeFailureThreshold,
|
||||
ConsecutiveFailures: failures,
|
||||
}
|
||||
if iface, err := ifaceByMAC(a.sysNetDir, n.MAC); err == nil {
|
||||
readCounters(a.sysNetDir, iface, &r)
|
||||
}
|
||||
reports[i] = r
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
// probeOne runs one ARP exchange for n, false on any failure.
|
||||
func (a *Agent) probeOne(n nicConfig) bool {
|
||||
iface, err := ifaceByMAC(a.sysNetDir, n.MAC)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hw, err := net.ParseMAC(n.MAC)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return a.arpProbeFn(iface, hw, n.Addr, n.Gateway, arpExchangeWait) == nil
|
||||
}
|
||||
|
||||
func (a *Agent) probeState(mac string) *nicProbeState {
|
||||
a.probeMu.Lock()
|
||||
defer a.probeMu.Unlock()
|
||||
st := a.probeStates[mac]
|
||||
if st == nil {
|
||||
st = &nicProbeState{}
|
||||
a.probeStates[mac] = st
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testDataMAC = "02:00:00:aa:00:01"
|
||||
testMgmtMAC = "02:00:00:aa:00:02"
|
||||
testNetcfg = "NIC0_MAC=" + testDataMAC + "\nNIC0_CIDR=10.0.1.5/24\nNIC0_GW=10.0.1.1\nNIC0_DEFAULT=1\n" +
|
||||
"NIC1_MAC=" + testMgmtMAC + "\nNIC1_CIDR=10.255.0.9/24\nNIC1_DEFAULT=0\n"
|
||||
)
|
||||
|
||||
// datapathAgent returns a test agent whose netcfg and sysfs live in a temp dir.
|
||||
func datapathAgent(t *testing.T, gwURL, netcfg string) *Agent {
|
||||
t.Helper()
|
||||
a := newTestAgent(t, gwURL)
|
||||
dir := t.TempDir()
|
||||
|
||||
a.netcfgPath = filepath.Join(dir, "netcfg")
|
||||
if err := os.WriteFile(a.netcfgPath, []byte(netcfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.sysNetDir = filepath.Join(dir, "net")
|
||||
for name, mac := range map[string]string{"ens3": testDataMAC, "ens4": testMgmtMAC} {
|
||||
stats := filepath.Join(a.sysNetDir, name, "statistics")
|
||||
if err := os.MkdirAll(stats, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(a.sysNetDir, name, "address"), []byte(mac+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, v := range map[string]string{"rx_packets": "100", "tx_packets": "200", "rx_dropped": "3", "tx_dropped": "4"} {
|
||||
if err := os.WriteFile(filepath.Join(stats, file), []byte(v+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.instanceIDFn = func() (string, error) { return "i-0123456789abcdef0", nil }
|
||||
return a
|
||||
}
|
||||
|
||||
func TestParseNetcfg_SkipsMgmtNICAndMissingGateway(t *testing.T) {
|
||||
blob := testNetcfg +
|
||||
"NIC2_MAC=02:00:00:aa:00:03\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\nNIC2_DEFAULT=0\n" +
|
||||
"NIC3_MAC=02:00:00:aa:00:04\nNIC3_DHCP=1\nNIC3_DEFAULT=0\n" +
|
||||
"NIC4_CIDR=10.0.4.5/24\nNIC4_GW=10.0.4.1\n" +
|
||||
"NIC5_MAC=02:00:00:aa:00:06\nNIC5_CIDR=not-a-cidr\nNIC5_GW=10.0.5.1\n"
|
||||
|
||||
got := parseNetcfg([]byte(blob))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d NICs, want the 2 with a gateway: %+v", len(got), got)
|
||||
}
|
||||
if got[0].MAC != testDataMAC || got[0].Gateway != netip.MustParseAddr("10.0.1.1") || got[0].Addr != netip.MustParseAddr("10.0.1.5") {
|
||||
t.Errorf("first NIC = %+v", got[0])
|
||||
}
|
||||
if got[1].MAC != "02:00:00:aa:00:03" || got[1].Gateway != netip.MustParseAddr("10.0.2.1") {
|
||||
t.Errorf("second NIC = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetcfg_OrdersByNICIndexAndLowercasesMAC(t *testing.T) {
|
||||
blob := "NIC10_MAC=02:00:00:AA:00:0A\nNIC10_CIDR=10.0.9.5/24\nNIC10_GW=10.0.9.1\n" +
|
||||
"NIC2_MAC=02:00:00:AA:00:02\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\n"
|
||||
got := parseNetcfg([]byte(blob))
|
||||
if len(got) != 2 || got[0].MAC != "02:00:00:aa:00:02" || got[1].MAC != "02:00:00:aa:00:0a" {
|
||||
t.Fatalf("order or case wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfaceByMACAndCounters(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
|
||||
iface, err := ifaceByMAC(a.sysNetDir, "02:00:00:AA:00:01")
|
||||
if err != nil || iface != "ens3" {
|
||||
t.Fatalf("iface = %q, err = %v", iface, err)
|
||||
}
|
||||
if _, err := ifaceByMAC(a.sysNetDir, "02:00:00:ff:ff:ff"); err == nil {
|
||||
t.Error("an unknown MAC must not resolve to an interface")
|
||||
}
|
||||
|
||||
var r NICReport
|
||||
readCounters(a.sysNetDir, iface, &r)
|
||||
if r.RxPackets != 100 || r.TxPackets != 200 || r.RxDropped != 3 || r.TxDropped != 4 {
|
||||
t.Errorf("counters = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARPFrames_RequestShapeAndReplyMatching(t *testing.T) {
|
||||
mac, _ := net.ParseMAC(testDataMAC)
|
||||
src, gw := netip.MustParseAddr("10.0.1.5"), netip.MustParseAddr("10.0.1.1")
|
||||
|
||||
req := buildARPRequest(mac, src, gw)
|
||||
if len(req) != 42 || req[12] != 0x08 || req[13] != 0x06 || req[21] != 1 {
|
||||
t.Fatalf("request frame malformed: % x", req)
|
||||
}
|
||||
if string(req[38:42]) != string(gw.AsSlice()) {
|
||||
t.Errorf("target IP not the gateway: % x", req[38:42])
|
||||
}
|
||||
|
||||
reply := make([]byte, 42)
|
||||
copy(reply[0:6], mac)
|
||||
reply[12], reply[13] = 0x08, 0x06
|
||||
reply[21] = 2
|
||||
copy(reply[28:32], gw.AsSlice())
|
||||
copy(reply[32:38], mac)
|
||||
if !isARPReplyFrom(reply, mac, gw) {
|
||||
t.Error("a reply from the gateway to our MAC must match")
|
||||
}
|
||||
if isARPReplyFrom(reply, mac, netip.MustParseAddr("10.0.1.99")) {
|
||||
t.Error("a reply from another host must not match")
|
||||
}
|
||||
reply[21] = 1
|
||||
if isARPReplyFrom(reply, mac, gw) {
|
||||
t.Error("a request must not count as a reply")
|
||||
}
|
||||
if isARPReplyFrom(reply[:20], mac, gw) {
|
||||
t.Error("a short frame must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_ProbesOnlyTheGatewayNIC(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
var mu sync.Mutex
|
||||
var probed []string
|
||||
a.arpProbeFn = func(iface string, _ net.HardwareAddr, src, gw netip.Addr, _ time.Duration) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
probed = append(probed, fmt.Sprintf("%s %s>%s", iface, src, gw))
|
||||
return nil
|
||||
}
|
||||
|
||||
reports := a.probeNICs()
|
||||
if len(reports) != 1 || reports[0].MAC != testDataMAC || !reports[0].ProbeOK || reports[0].ConsecutiveFailures != 0 {
|
||||
t.Fatalf("reports = %+v", reports)
|
||||
}
|
||||
if len(probed) != 1 || probed[0] != "ens3 10.0.1.5>10.0.1.1" {
|
||||
t.Errorf("probed = %v, want only the data NIC", probed)
|
||||
}
|
||||
if reports[0].RxPackets != 100 || reports[0].TxDropped != 4 {
|
||||
t.Errorf("counters missing from report: %+v", reports[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_TwoMissesThenSuccessNeverReportsFailing(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
results := []error{errARPNoReply, errARPNoReply, nil, errARPNoReply, errARPNoReply, nil}
|
||||
var n atomic.Int32
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
return results[n.Add(1)-1]
|
||||
}
|
||||
|
||||
for i, want := range []int64{1, 2, 0, 1, 2, 0} {
|
||||
r := a.probeNICs()
|
||||
if len(r) != 1 || !r[0].ProbeOK || r[0].ConsecutiveFailures != want {
|
||||
t.Fatalf("tick %d: report = %+v, want ok with %d failures", i, r, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_ThreeMissesReportFailingAndRecoveryClears(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
var fail atomic.Bool
|
||||
fail.Store(true)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
if fail.Load() {
|
||||
return errARPNoReply
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 1; i <= 4; i++ {
|
||||
r := a.probeNICs()
|
||||
wantOK := i < 3 // failing from the third consecutive miss
|
||||
if r[0].ProbeOK != wantOK || r[0].ConsecutiveFailures != int64(i) {
|
||||
t.Fatalf("miss %d: report = %+v, want ProbeOK=%v", i, r[0], wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
fail.Store(false)
|
||||
if r := a.probeNICs(); !r[0].ProbeOK || r[0].ConsecutiveFailures != 0 {
|
||||
t.Errorf("a successful probe must clear the failure: %+v", r[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_BlockedProbeIsBoundedAndCountsAsMiss(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
a.probeWait = 50 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls atomic.Int32
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
start := time.Now()
|
||||
r := a.probeNICs()
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Fatalf("tick %d waited %s on a stuck probe", i, d)
|
||||
}
|
||||
if len(r) != 1 || r[0].ConsecutiveFailures != int64(i) {
|
||||
t.Fatalf("tick %d: report = %+v, want %d misses", i, r, i)
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("a stuck probe was started %d times, want 1 (no goroutine pile-up)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_BlockedProbeDoesNotDelayHeartbeatBeyondOneTick(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("Action") == "LBAgentHeartbeat" {
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
}
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.probeWait = 50 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
a.tick()
|
||||
if d := time.Since(start); d >= pollInterval {
|
||||
t.Fatalf("tick took %s with a stuck probe, want under one %s tick", d, pollInterval)
|
||||
}
|
||||
if got.Load() == nil {
|
||||
t.Fatal("the heartbeat was never sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_EncodesEvidenceWhenPresent(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return errARPNoReply }
|
||||
|
||||
a.tick()
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("no heartbeat received")
|
||||
}
|
||||
want := map[string]string{
|
||||
"InstanceID": "i-0123456789abcdef0",
|
||||
"NICs.member.1.MAC": testDataMAC,
|
||||
"NICs.member.1.ProbeOK": "true",
|
||||
"NICs.member.1.ConsecutiveFailures": "1",
|
||||
"NICs.member.1.RxPackets": "100",
|
||||
"NICs.member.1.TxPackets": "200",
|
||||
"NICs.member.1.RxDropped": "3",
|
||||
"NICs.member.1.TxDropped": "4",
|
||||
}
|
||||
for k, w := range want {
|
||||
if g := v.Get(k); g != w {
|
||||
t.Errorf("%s = %q, want %q", k, g, w)
|
||||
}
|
||||
}
|
||||
if v.Get("NICs.member.2.MAC") != "" {
|
||||
t.Error("the mgmt NIC must not be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_OmitsEvidenceWithoutNetcfg(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.netcfgPath = filepath.Join(t.TempDir(), "absent")
|
||||
a.instanceIDFn = func() (string, error) { return "", errors.New("must not be asked without NICs") }
|
||||
|
||||
a.tick()
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("no heartbeat received")
|
||||
}
|
||||
for k := range *v {
|
||||
if k == "InstanceID" || len(k) >= 4 && k[:4] == "NICs" {
|
||||
t.Errorf("unexpected evidence parameter %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataPathEvidence_InstanceIDRetriedUntilKnown(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
|
||||
var calls atomic.Int32
|
||||
a.instanceIDFn = func() (string, error) {
|
||||
if calls.Add(1) < 3 {
|
||||
return "", errors.New("metadata not ready")
|
||||
}
|
||||
return "i-late", nil
|
||||
}
|
||||
|
||||
// A failure backs the lookup off for imdsRetryTicks-1 ticks.
|
||||
ticks := func(n int) string {
|
||||
var id string
|
||||
for range n {
|
||||
id = a.dataPathEvidence().InstanceID
|
||||
}
|
||||
return id
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 1 {
|
||||
t.Fatalf("after %d ticks: id %q, calls %d; want no id and one lookup", imdsRetryTicks, got, calls.Load())
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 2 {
|
||||
t.Fatalf("second window: id %q, calls %d; want no id and two lookups", got, calls.Load())
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "i-late" || calls.Load() != 3 {
|
||||
t.Fatalf("third window: id %q, calls %d; want i-late and three lookups", got, calls.Load())
|
||||
}
|
||||
ticks(imdsRetryTicks)
|
||||
if calls.Load() != 3 {
|
||||
t.Errorf("metadata asked %d times, want 3 (stop once known)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_BlockedInstanceIDLookupDoesNotDelayHeartbeat(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("Action") == "LBAgentHeartbeat" {
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
}
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls atomic.Int32
|
||||
a.instanceIDFn = func() (string, error) {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return "", errors.New("unreachable")
|
||||
}
|
||||
|
||||
for i := range 3 {
|
||||
start := time.Now()
|
||||
a.tick()
|
||||
if d := time.Since(start); d >= pollInterval {
|
||||
t.Fatalf("tick %d took %s with IMDS blocked, want under one %s tick", i, d, pollInterval)
|
||||
}
|
||||
}
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("the heartbeat was never sent")
|
||||
}
|
||||
if v.Get("InstanceID") != "" {
|
||||
t.Error("InstanceID must be omitted while the lookup fails")
|
||||
}
|
||||
if v.Get("NICs.member.1.MAC") == "" {
|
||||
t.Error("NIC evidence must still be sent")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("blocked lookup started %d times in 3 ticks, want 1 (backoff)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceID_SharesOneDeadline(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3 * imdsWait)
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := imdsBase
|
||||
imdsBase = srv.URL + "/latest"
|
||||
defer func() { imdsBase = old }()
|
||||
|
||||
start := time.Now()
|
||||
if _, err := fetchInstanceID(); err == nil {
|
||||
t.Fatal("a hung metadata service must fail the lookup")
|
||||
}
|
||||
if d := time.Since(start); d > imdsWait+imdsWait/2 {
|
||||
t.Fatalf("lookup took %s, want about one %s deadline", d, imdsWait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARPProbe_NoResponderFailsClosed(t *testing.T) {
|
||||
mac, _ := net.ParseMAC(testDataMAC)
|
||||
// Nobody answers an ARP request sent on loopback, so a probe that returned
|
||||
// nil here would mask a dead data path. It fails either on the socket (no
|
||||
// CAP_NET_RAW) or on the missing reply.
|
||||
err := arpProbe("lo", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("a probe with no responder must fail")
|
||||
}
|
||||
if err := arpProbe("no-such-iface0", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond); err == nil {
|
||||
t.Fatal("a probe on a missing interface must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceID(t *testing.T) {
|
||||
serve := func(tokenStatus, idStatus int, wantToken string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/latest/api/token":
|
||||
w.WriteHeader(tokenStatus)
|
||||
fmt.Fprint(w, "tok123")
|
||||
case r.URL.Path == "/latest/meta-data/instance-id":
|
||||
if got := r.Header.Get("X-Aws-Ec2-Metadata-Token"); got != wantToken {
|
||||
t.Errorf("token header = %q, want %q", got, wantToken)
|
||||
}
|
||||
w.WriteHeader(idStatus)
|
||||
fmt.Fprint(w, " i-abc123 \n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
saved := imdsBase
|
||||
defer func() { imdsBase = saved }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tokenStatus, idStatus int
|
||||
wantToken string
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"token then id", http.StatusOK, http.StatusOK, "tok123", "i-abc123", false},
|
||||
{"token refused falls back to v1", http.StatusNotFound, http.StatusOK, "", "i-abc123", false},
|
||||
{"id endpoint error", http.StatusOK, http.StatusServiceUnavailable, "tok123", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := serve(tc.tokenStatus, tc.idStatus, tc.wantToken)
|
||||
defer srv.Close()
|
||||
imdsBase = srv.URL + "/latest"
|
||||
id, err := fetchInstanceID()
|
||||
if (err != nil) != tc.wantErr || id != tc.wantID {
|
||||
t.Fatalf("id = %q, err = %v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
imdsBase = "http://127.0.0.1:1/latest"
|
||||
if _, err := fetchInstanceID(); err == nil {
|
||||
t.Error("an unreachable metadata service must fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
imdsWait = time.Second
|
||||
imdsRetryTicks = 6
|
||||
)
|
||||
|
||||
// imdsBase is the in-VM metadata endpoint (a variable so tests can point it at a fake).
|
||||
var imdsBase = "http://169.254.169.254/latest"
|
||||
|
||||
// dataPathEvidence is the optional part of a heartbeat: which VM the agent runs
|
||||
// in and what each data NIC's gateway probe saw. An empty value adds nothing,
|
||||
// which is what an agent without a netcfg blob sends.
|
||||
type dataPathEvidence struct {
|
||||
InstanceID string
|
||||
NICs []NICReport
|
||||
}
|
||||
|
||||
// addTo encodes the evidence as additional heartbeat query parameters.
|
||||
func (e dataPathEvidence) addTo(params url.Values) {
|
||||
if e.InstanceID != "" {
|
||||
params.Set("InstanceID", e.InstanceID)
|
||||
}
|
||||
for i, n := range e.NICs {
|
||||
p := "NICs.member." + strconv.Itoa(i+1) + "."
|
||||
params.Set(p+"MAC", n.MAC)
|
||||
params.Set(p+"ProbeOK", strconv.FormatBool(n.ProbeOK))
|
||||
params.Set(p+"ConsecutiveFailures", strconv.FormatInt(n.ConsecutiveFailures, 10))
|
||||
params.Set(p+"RxPackets", strconv.FormatInt(n.RxPackets, 10))
|
||||
params.Set(p+"TxPackets", strconv.FormatInt(n.TxPackets, 10))
|
||||
params.Set(p+"RxDropped", strconv.FormatInt(n.RxDropped, 10))
|
||||
params.Set(p+"TxDropped", strconv.FormatInt(n.TxDropped, 10))
|
||||
}
|
||||
}
|
||||
|
||||
// dataPathEvidence gathers the probe results and instance ID for one heartbeat.
|
||||
// Neither step can fail the heartbeat: missing evidence is simply omitted.
|
||||
func (a *Agent) dataPathEvidence() dataPathEvidence {
|
||||
nics := a.probeNICs()
|
||||
if len(nics) == 0 {
|
||||
return dataPathEvidence{}
|
||||
}
|
||||
if a.instanceID == "" {
|
||||
a.lookupInstanceID()
|
||||
}
|
||||
return dataPathEvidence{InstanceID: a.instanceID, NICs: nics}
|
||||
}
|
||||
|
||||
// lookupInstanceID asks for the instance ID without ever holding up a tick: the
|
||||
// call is bounded by imdsWait and, after a failure, retried only every
|
||||
// imdsRetryTicks ticks.
|
||||
func (a *Agent) lookupInstanceID() {
|
||||
if a.idSkip > 0 {
|
||||
a.idSkip--
|
||||
return
|
||||
}
|
||||
type result struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
id, err := a.instanceIDFn()
|
||||
ch <- result{id, err}
|
||||
}()
|
||||
timer := time.NewTimer(imdsWait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.err == nil && r.id != "" {
|
||||
a.instanceID = r.id
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
a.idSkip = imdsRetryTicks - 1
|
||||
}
|
||||
|
||||
// fetchInstanceID reads the instance ID from the in-VM metadata service. The
|
||||
// token request and the read share one deadline.
|
||||
func fetchInstanceID() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), imdsWait)
|
||||
defer cancel()
|
||||
c := &http.Client{}
|
||||
|
||||
var token string
|
||||
if req, err := http.NewRequestWithContext(ctx, http.MethodPut, imdsBase+"/api/token", nil); err == nil {
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "60")
|
||||
if resp, err := c.Do(req); err == nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imdsBase+"/meta-data/instance-id", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token", token)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("instance-id: status %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := strings.TrimSpace(string(b))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("instance-id: empty")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// On a VMM without fw_cfg the executor attaches the same entries as a tar
|
||||
// archive on a second disk, which the runtime image's init extracts here in
|
||||
// the fw_cfg sysfs layout, so the fw_cfg readers work unchanged.
|
||||
const (
|
||||
ConfigDiskRoot = "/spinifex-cfg"
|
||||
ConfigDiskDir = ConfigDiskRoot + "/by_name/" + lambdav1.FwCfgPrefix
|
||||
ConfigDiskDevice = "/dev/vdb"
|
||||
)
|
||||
|
||||
// zeroChunk bounds each write while erasing the configuration disk.
|
||||
const zeroChunk = 64 << 10
|
||||
|
||||
// unmount is a var so tests without privilege can stand in for the tmpfs.
|
||||
var unmount = unix.Unmount
|
||||
|
||||
// SealConfigDisk overwrites the whole configuration disk with zeros, then
|
||||
// removes the extracted tree. The tree stays if the disk cannot be erased, so
|
||||
// SealFwCfg's check on root fails closed.
|
||||
func SealConfigDisk(device, root string) error {
|
||||
if err := zeroDevice(device); err != nil {
|
||||
return fmt.Errorf("hostlink: erase configuration disk: %w", err)
|
||||
}
|
||||
if err := unmount(root, 0); err != nil && !errors.Is(err, unix.EINVAL) {
|
||||
return fmt.Errorf("hostlink: unmount %s: %w", root, err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return fmt.Errorf("hostlink: remove %s: %w", root, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroDevice(device string) error {
|
||||
f, err := os.OpenFile(device, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err == nil {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
zeros := make([]byte, zeroChunk)
|
||||
for left := size; left > 0; {
|
||||
n := min(left, int64(len(zeros)))
|
||||
if _, err := f.Write(zeros[:n]); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//test:in-package reaches the unexported configDisk and fwCfgTree readers to seal and read a real archive
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// configDisk writes a fake disk holding secret and an extracted tree; the
|
||||
// stand-in unmount reports the tree is not a mount, as on a plain directory.
|
||||
func configDisk(t *testing.T, secret []byte) (device, root string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
device = filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, append(bytes.Repeat([]byte{0xa5}, 3*zeroChunk+7), secret...), 0o600))
|
||||
root = fwCfgTree(t)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "by_name", "key"), secret, 0o600))
|
||||
old := unmount
|
||||
unmount = func(string, int) error { return unix.EINVAL }
|
||||
t.Cleanup(func() { unmount = old })
|
||||
return device, root
|
||||
}
|
||||
|
||||
func TestSealConfigDisk(t *testing.T) {
|
||||
secret := []byte("guest-key-material")
|
||||
|
||||
t.Run("erases the disk and removes the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
before, err := os.Stat(device)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
|
||||
disk, err := os.ReadFile(device)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, disk, int(before.Size()))
|
||||
assert.Equal(t, make([]byte, len(disk)), disk, "configuration disk not fully erased")
|
||||
_, err = os.Stat(root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
t.Run("an unwritable disk keeps the tree and fails closed", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
require.NoError(t, os.Chmod(device, 0o400))
|
||||
err := SealFwCfg(root, func() error { return SealConfigDisk(device, root) })
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "still readable")
|
||||
_, err = os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("a failed unmount keeps the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
unmount = func(string, int) error { return unix.EBUSY }
|
||||
require.Error(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
_, err := os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -28,11 +28,12 @@ type Request struct {
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// Credential is one execution-role session. It is never logged or stored.
|
||||
// Credential holds one execution-role session and its function environment.
|
||||
type Credential struct {
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
SessionToken string `json:"session_token"`
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
SessionToken string `json:"session_token"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
}
|
||||
|
||||
// LogValue keeps every value out of logs.
|
||||
|
||||
@@ -4,5 +4,5 @@ package hostlink
|
||||
|
||||
// Protocol is the only ALPN protocol a test-link host and guest offer. It
|
||||
// differs from the release protocol because the first frame carries the
|
||||
// execution credential, which an older guest would ignore.
|
||||
const Protocol = "spinifex-lambda-testlink/2"
|
||||
// execution credential and function environment, which an older guest would ignore.
|
||||
const Protocol = "spinifex-lambda-testlink/3"
|
||||
|
||||
@@ -322,14 +322,27 @@ type ViperblockConfig struct {
|
||||
}
|
||||
|
||||
// EBS provider selectors. Viperblockd routes EBS calls to the viperblockd
|
||||
// daemon and qemunbd to the qemunbdd daemon, both over the versioned
|
||||
// ebs.provider.v1.* NATS contract. Embedded named the removed in-process
|
||||
// daemon, qemunbd to the qemunbdd daemon and ocibsd to the ocibsd daemon, all
|
||||
// over the versioned ebs.provider.v1.* NATS contract. Embedded named the removed in-process
|
||||
// engine and survives solely so a config that still selects it can be
|
||||
// rejected by name.
|
||||
const (
|
||||
EBSProviderEmbedded = "embedded"
|
||||
EBSProviderViperblockd = "viperblockd"
|
||||
EBSProviderQEMUNBD = "qemunbd"
|
||||
EBSProviderOCIBSD = "ocibsd"
|
||||
)
|
||||
|
||||
// OCI credential selections, as in oci_auth on an external pool and auth in
|
||||
// [nodes.<name>.ebs.oci]. An unset auth reads a config file.
|
||||
const (
|
||||
// OCIAuthConfigFile authenticates from an API-key config file. The default,
|
||||
// because it is what an operator already has from `oci setup config`.
|
||||
OCIAuthConfigFile = "config_file"
|
||||
// OCIAuthInstancePrincipal authenticates as the instance, from the
|
||||
// certificate its metadata service serves. No key material on any node and
|
||||
// nothing to rotate, at the cost of a dynamic group and an IAM policy.
|
||||
OCIAuthInstancePrincipal = "instance_principal"
|
||||
)
|
||||
|
||||
// Per-volume export tunables. Both are applied per nbdkit process, and
|
||||
@@ -353,10 +366,13 @@ const (
|
||||
// boundary, not one provider's settings, so a second provider never needs a
|
||||
// rename.
|
||||
type EBSConfig struct {
|
||||
// Provider is "viperblockd" or "qemunbd" and may be left unset. Volumes
|
||||
// are persisted in ebsmetadata.
|
||||
// Provider is "viperblockd", "qemunbd" or "ocibsd" and may be left unset.
|
||||
// Volumes are persisted in ebsmetadata.
|
||||
Provider string `json:"Provider" mapstructure:"provider"`
|
||||
|
||||
// OCI is the [nodes.<name>.ebs.oci] table, read only by ocibsd.
|
||||
OCI EBSOCIConfig `json:"OCI" mapstructure:"oci"`
|
||||
|
||||
// DefaultThreads is nbdkit's -t: worker threads per NBD connection, which
|
||||
// bounds how many requests are in flight inside viperblock for one volume.
|
||||
// QEMU opens a single connection per volume, so this is the whole
|
||||
@@ -370,9 +386,27 @@ type EBSConfig struct {
|
||||
CacheSizeMB *int `json:"CacheSizeMB" mapstructure:"cache_size_mb"`
|
||||
}
|
||||
|
||||
// EBSOCIConfig is where ocibsd keeps the cluster's volumes and how it
|
||||
// authenticates. Auth, ConfigFile and ConfigProfile mean what oci_auth,
|
||||
// oci_config_file and oci_config_profile mean on an oci external pool.
|
||||
type EBSOCIConfig struct {
|
||||
// CompartmentID and AvailabilityDomain are the same on every node: a
|
||||
// volume is created there whichever node answers, and attached anywhere.
|
||||
CompartmentID string `json:"CompartmentID" mapstructure:"compartment_id"`
|
||||
AvailabilityDomain string `json:"AvailabilityDomain" mapstructure:"availability_domain"`
|
||||
// ClusterID is generated once by spx admin init and tags every OCI
|
||||
// resource ocibsd creates, so one compartment can hold several clusters.
|
||||
ClusterID string `json:"ClusterID" mapstructure:"cluster_id"`
|
||||
Auth string `json:"Auth" mapstructure:"auth"`
|
||||
ConfigFile string `json:"ConfigFile" mapstructure:"config_file"`
|
||||
ConfigProfile string `json:"ConfigProfile" mapstructure:"config_profile"`
|
||||
}
|
||||
|
||||
func (c EBSOCIConfig) isZero() bool { return c == EBSOCIConfig{} }
|
||||
|
||||
// SupportedEBSProvider reports whether name is a provider a node may run.
|
||||
func SupportedEBSProvider(name string) bool {
|
||||
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD
|
||||
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD || name == EBSProviderOCIBSD
|
||||
}
|
||||
|
||||
// ResolvedProvider normalizes an empty Provider to EBSProviderViperblockd,
|
||||
@@ -889,6 +923,63 @@ func validateSingleEBSProvider(cc *ClusterConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOCIAuth checks an OCI credential selection, where prefix is what
|
||||
// the keys are named with ("oci_" on an external pool, "" in [ebs.oci]).
|
||||
func validateOCIAuth(prefix, auth, configFile, configProfile string) error {
|
||||
// Named explicitly rather than inferred, so a node that was meant to
|
||||
// use its own identity and has no dynamic group fails at config load
|
||||
// instead of falling back to a key file nobody installed.
|
||||
switch auth {
|
||||
case "", OCIAuthConfigFile, OCIAuthInstancePrincipal:
|
||||
default:
|
||||
return fmt.Errorf("%sauth must be %q or %q, got %q", prefix, OCIAuthConfigFile, OCIAuthInstancePrincipal, auth)
|
||||
}
|
||||
// Credentials come from the metadata service under instance principal,
|
||||
// so a config file named beside it is one of two sources of truth and
|
||||
// the one that would be silently ignored.
|
||||
if auth == OCIAuthInstancePrincipal && (configFile != "" || configProfile != "") {
|
||||
return fmt.Errorf("%[1]sconfig_file/%[1]sconfig_profile are not valid with %[1]sauth=\"instance_principal\" (the instance's own certificate is the credential)", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEBSOCI checks every node's [ebs.oci] table: present and complete
|
||||
// exactly when the node runs ocibsd, and naming one compartment and one AD
|
||||
// across the cluster, since any node may create a volume another attaches.
|
||||
func validateEBSOCI(cc *ClusterConfig) error {
|
||||
var first string
|
||||
var want EBSOCIConfig
|
||||
for _, name := range slices.Sorted(maps.Keys(cc.Nodes)) {
|
||||
ebs := cc.Nodes[name].EBS
|
||||
if ebs.ResolvedProvider() != EBSProviderOCIBSD {
|
||||
if !ebs.OCI.isZero() {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs.oci] is only valid with provider = %q", name, EBSProviderOCIBSD)
|
||||
}
|
||||
continue
|
||||
}
|
||||
oci := ebs.OCI
|
||||
if oci.CompartmentID == "" || oci.AvailabilityDomain == "" {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs.oci] compartment_id and availability_domain are required with provider = %q", name, EBSProviderOCIBSD)
|
||||
}
|
||||
if err := validateOCIAuth("", oci.Auth, oci.ConfigFile, oci.ConfigProfile); err != nil {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs.oci] %w", name, err)
|
||||
}
|
||||
if first == "" {
|
||||
first, want = name, oci
|
||||
continue
|
||||
}
|
||||
if oci.ClusterID != want.ClusterID {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs.oci] cluster_id %q differs from [nodes.%s.ebs.oci] cluster_id %q; every node must carry the cluster's one ID",
|
||||
name, oci.ClusterID, first, want.ClusterID)
|
||||
}
|
||||
if oci.CompartmentID != want.CompartmentID || oci.AvailabilityDomain != want.AvailabilityDomain {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs.oci] names compartment %q in %q but [nodes.%s.ebs.oci] names %q in %q; every node must name the same compartment and availability domain",
|
||||
first, want.CompartmentID, want.AvailabilityDomain, name, oci.CompartmentID, oci.AvailabilityDomain)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClusterConfig(cc *ClusterConfig) error {
|
||||
if viper.IsSet("network.external_dhcp") {
|
||||
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
|
||||
@@ -905,7 +996,7 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
// ebsmetadata one-way without the operator ever asking for it.
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q has been removed; set provider = %q or remove the key (this is a one-way switch: volumes move to ebsmetadata)", nodeName, EBSProviderEmbedded, EBSProviderViperblockd)
|
||||
default:
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD)
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q, %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD, EBSProviderOCIBSD)
|
||||
}
|
||||
// Range checks only. Both settings are deliberately unbounded above by
|
||||
// anything host-aware: they are operator tunables, and the host cost is
|
||||
@@ -923,6 +1014,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
if err := validateSingleEBSProvider(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEBSOCI(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateNorthstarCluster(cc); err != nil {
|
||||
return err
|
||||
@@ -993,19 +1087,8 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
if (p.OCIVNICID == "") == (p.OCIVNICIface == "") {
|
||||
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires exactly one of oci_vnic_id or oci_vnic_iface", p.Name)
|
||||
}
|
||||
// Named explicitly rather than inferred, so a node that was meant to
|
||||
// use its own identity and has no dynamic group fails at config load
|
||||
// instead of falling back to a key file nobody installed.
|
||||
switch p.OCIAuth {
|
||||
case "", "config_file", "instance_principal":
|
||||
default:
|
||||
return fmt.Errorf("config: [[network.external_pools]] %q: oci_auth must be \"config_file\" or \"instance_principal\", got %q", p.Name, p.OCIAuth)
|
||||
}
|
||||
// Credentials come from the metadata service under instance principal,
|
||||
// so a config file named beside it is one of two sources of truth and
|
||||
// the one that would be silently ignored.
|
||||
if p.OCIAuth == "instance_principal" && (p.OCIConfigFile != "" || p.OCIConfigProfile != "") {
|
||||
return fmt.Errorf("config: [[network.external_pools]] %q: oci_config_file/oci_config_profile are not valid with oci_auth=\"instance_principal\" (the instance's own certificate is the credential)", p.Name)
|
||||
if err := validateOCIAuth("oci_", p.OCIAuth, p.OCIConfigFile, p.OCIConfigProfile); err != nil {
|
||||
return fmt.Errorf("config: [[network.external_pools]] %q: %w", p.Name, err)
|
||||
}
|
||||
if p.BindBridge != "" || p.DHCPMAC != "" {
|
||||
return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name)
|
||||
|
||||
@@ -1053,6 +1053,7 @@ region = "us-east-1"
|
||||
{"unset defaults to viperblockd", "", EBSProviderViperblockd},
|
||||
{"viperblockd explicit", "[nodes.n1.ebs]\nprovider = \"viperblockd\"\n", EBSProviderViperblockd},
|
||||
{"qemunbd explicit", "[nodes.n1.ebs]\nprovider = \"qemunbd\"\n", EBSProviderQEMUNBD},
|
||||
{"ocibsd explicit", "[nodes.n1.ebs]\nprovider = \"ocibsd\"\n\n[nodes.n1.ebs.oci]\ncompartment_id = \"c\"\navailability_domain = \"ad\"\n", EBSProviderOCIBSD},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -1148,6 +1149,59 @@ func TestLoadConfig_EBSProvider_SingleProviderPerCluster(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_EBSOCI(t *testing.T) {
|
||||
oci := func(node, compartment, ad, extra string) string {
|
||||
return fmt.Sprintf("[nodes.%s.ebs]\nprovider = \"ocibsd\"\n\n[nodes.%s.ebs.oci]\ncompartment_id = %q\navailability_domain = %q\n%s", node, node, compartment, ad, extra)
|
||||
}
|
||||
const comp, ad = "ocid1.compartment.oc1..a", "Uocm:US-ASHBURN-AD-1"
|
||||
cases := []struct {
|
||||
name string
|
||||
ebs []string
|
||||
wantErr string
|
||||
}{
|
||||
{"one node", []string{oci("n1", comp, ad, "")}, ""},
|
||||
{"every node agrees, with unset peers", []string{oci("n1", comp, ad, ""), "", oci("n3", comp, ad, "auth = \"instance_principal\"\n")}, ""},
|
||||
{"config file credentials", []string{oci("n1", comp, ad, "auth = \"config_file\"\nconfig_file = \"/etc/spinifex/oci/config\"\nconfig_profile = \"SPX\"\n")}, ""},
|
||||
{"no compartment", []string{oci("n1", "", ad, "")}, "[nodes.n1.ebs.oci] compartment_id and availability_domain are required"},
|
||||
{"no availability domain", []string{oci("n1", comp, "", "")}, "[nodes.n1.ebs.oci] compartment_id and availability_domain are required"},
|
||||
{"no table at all", []string{"[nodes.n1.ebs]\nprovider = \"ocibsd\"\n"}, "compartment_id and availability_domain are required"},
|
||||
{"unknown auth", []string{oci("n1", comp, ad, "auth = \"api_key\"\n")}, `[nodes.n1.ebs.oci] auth must be "config_file" or "instance_principal", got "api_key"`},
|
||||
{"instance principal beside a config file", []string{oci("n1", comp, ad, "auth = \"instance_principal\"\nconfig_file = \"/x\"\n")},
|
||||
`config_file/config_profile are not valid with auth="instance_principal"`},
|
||||
{"nodes disagree on the AD", []string{oci("n1", comp, ad, ""), oci("n2", comp, "Uocm:US-ASHBURN-AD-2", "")},
|
||||
"every node must name the same compartment and availability domain"},
|
||||
{"nodes agree on the cluster ID", []string{oci("n1", comp, ad, "cluster_id = \"c1\"\n"), oci("n2", comp, ad, "cluster_id = \"c1\"\n")}, ""},
|
||||
{"nodes disagree on the cluster ID", []string{oci("n1", comp, ad, "cluster_id = \"c1\"\n"), oci("n2", comp, ad, "cluster_id = \"c2\"\n")},
|
||||
`[nodes.n2.ebs.oci] cluster_id "c2" differs from [nodes.n1.ebs.oci] cluster_id "c1"`},
|
||||
{"nodes disagree on the compartment", []string{oci("n1", comp, ad, ""), oci("n2", "ocid1.compartment.oc1..b", ad, "")},
|
||||
"every node must name the same compartment and availability domain"},
|
||||
{"the table on another provider", []string{"[nodes.n1.ebs]\nprovider = \"viperblockd\"\n\n[nodes.n1.ebs.oci]\ncompartment_id = \"x\"\n"},
|
||||
`[nodes.n1.ebs.oci] is only valid with provider = "ocibsd"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := "node = \"n1\"\n"
|
||||
for i, ebs := range tc.ebs {
|
||||
body += fmt.Sprintf("\n[nodes.n%d]\nregion = \"us-east-1\"\n%s", i+1, ebs)
|
||||
}
|
||||
resetViper(t)
|
||||
path := filepath.Join(t.TempDir(), "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, EBSProviderOCIBSD, cfg.Nodes["n1"].EBS.ResolvedProvider())
|
||||
got := cfg.Nodes["n1"].EBS.OCI
|
||||
assert.Equal(t, comp, got.CompartmentID)
|
||||
assert.Equal(t, ad, got.AvailabilityDomain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEBSConfig_ResolvedTunablesDefault(t *testing.T) {
|
||||
var c EBSConfig
|
||||
assert.Equal(t, DefaultNBDKitThreads, c.ResolvedThreads())
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -184,7 +183,7 @@ func NewStore(ctx context.Context, nc *nats.Conn, masterKey []byte) (*Store, err
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -171,7 +171,7 @@ func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream)
|
||||
if s == nil || !s.limits.RequestsPerMinuteEnabled {
|
||||
return
|
||||
}
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
if err != nil {
|
||||
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ func (s *Service) PutAccountQuota(ctx context.Context, accountID string, over Ov
|
||||
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
over.UpdatedBy = updatedBy
|
||||
|
||||
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
|
||||
CreateIfAbsent: true,
|
||||
Attempts: overrideMaxAttempts,
|
||||
Exhausted: func(string, int) error { return errOverridesContended },
|
||||
@@ -230,7 +230,7 @@ func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error
|
||||
return nil
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return nil
|
||||
case kvutil.IsRevisionConflict(err):
|
||||
case kvstore.IsRevisionConflict(err):
|
||||
// A concurrent write landed between the read and the guarded
|
||||
// delete; retry against whatever revision now holds.
|
||||
continue
|
||||
|
||||
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
|
||||
// dimension; the live-counted dimensions need no stored state.
|
||||
const KVBucketAccountUsage = "spinifex-account-usage"
|
||||
|
||||
// KVBucketQuotaReconcile is the leader-lock bucket for the gateway quota
|
||||
// reconcile. It is deliberately distinct from vpcd's network-reconcile lock so
|
||||
// the two independent loops never share one mutex and block each other.
|
||||
const KVBucketQuotaReconcile = "spinifex-quota-reconcile"
|
||||
// KeyReconcile is the sync-bucket leader lease for the gateway quota reconcile.
|
||||
// It is deliberately distinct from vpcd's network-reconcile lease so the two
|
||||
// independent loops never share one mutex and block each other.
|
||||
const KeyReconcile = "quota.reconcile"
|
||||
|
||||
// KVBucketAccountQuota holds the per-account limit overrides, keyed by
|
||||
// accountID. It is separate from the accounts bucket so a quota write never
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *Service) sealBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -136,7 +136,7 @@ func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*
|
||||
switch {
|
||||
case writeErr == nil:
|
||||
return &OverrideSeal{Revision: newRev}, nil
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
case kvstore.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
nsconfig "github.com/mulgadc/northstar/pkg/config"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
)
|
||||
@@ -48,7 +48,7 @@ func (w *Writer) PlatformDelegation(name string) (servers []string, zone string,
|
||||
// Delegate writes, or with remove withdraws, an NS set in the base zone that
|
||||
// delegates each name to this cluster's nameservers. It takes the zone-writer
|
||||
// election first, so it never races the reconcile's own writes to the zone.
|
||||
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, names []string, remove bool) (*ChangeResult, error) {
|
||||
func (w *Writer) Delegate(ctx context.Context, lease *kvsync.Lease, names []string, remove bool) (*ChangeResult, error) {
|
||||
if !w.Enabled() {
|
||||
return nil, errors.New("northstar is not configured on this node")
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
|
||||
if len(batch.Changes) == 0 {
|
||||
return &ChangeResult{}, nil
|
||||
}
|
||||
release, err := w.awaitWriterElection(ctx, nc, holder)
|
||||
release, err := w.awaitWriterElection(ctx, lease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
|
||||
return w.ApplyBatch(batch)
|
||||
}
|
||||
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
|
||||
if nc == nil {
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
|
||||
if lease == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
tick := time.NewTicker(delegateLockPoll)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
|
||||
return release, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
|
||||
return nil, fmt.Errorf("another writer has the zone: %w", ctx.Err())
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} {
|
||||
@@ -29,7 +29,7 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
assert.True(t, ok, "a name below the base domain is decided by the base zone")
|
||||
assert.Empty(t, servers)
|
||||
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
|
||||
require.NoError(t, err)
|
||||
@@ -43,11 +43,11 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
// servers written while the cluster published a larger set.
|
||||
func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.nameservers = w.nameservers[:1]
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net")
|
||||
require.NoError(t, err)
|
||||
@@ -66,7 +66,7 @@ func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
|
||||
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} {
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"ok.spx3.net", name}, false)
|
||||
require.Error(t, err, name)
|
||||
}
|
||||
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
|
||||
@@ -76,22 +76,45 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
|
||||
|
||||
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "node2"))
|
||||
require.True(t, ok)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another node is writing the zone")
|
||||
node1 := dnsLease(js, "node1")
|
||||
_, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
|
||||
_, err = w.Delegate(t.Context(), node1, []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
}
|
||||
|
||||
// The reconcile pass and a delegation on one node share the DNS lease, so a
|
||||
// delegation waits for this node's own pass as it would for another node's.
|
||||
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
shared := dnsLease(js, "node1")
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
|
||||
require.True(t, ok, "the reconcile pass holds the lease")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
)
|
||||
|
||||
// DefaultReconcileInterval is how often the drift backstop rebuilds managed
|
||||
@@ -33,6 +33,12 @@ const DefaultReconcileInterval = reconciler.DefaultResync
|
||||
// costs a cycle for nothing.
|
||||
const DefaultComputeTimeout = 2 * time.Minute
|
||||
|
||||
// NewReconcileLease returns the zone writer's election lease, shared by the
|
||||
// drift reconcile and Delegate so the two never write the zone together.
|
||||
func NewReconcileLease(store kvsync.KVStore, holder string) *kvsync.Lease {
|
||||
return reconcilelock.NewLeaderLease(store, KeyReconcile, holder)
|
||||
}
|
||||
|
||||
// DesiredFunc returns the full desired managed record set built from the live
|
||||
// resource inventory across all tenants. The daemon supplies it by enumerating
|
||||
// instances, load balancers, and EKS clusters.
|
||||
@@ -102,7 +108,8 @@ type Reconciler struct {
|
||||
// lands in. Distinct from both baseDomain and internalDomain, so pruning it
|
||||
// needs its own read the same way internalDomain does for EC2.
|
||||
serviceZone string
|
||||
nc *nats.Conn
|
||||
// lease elects the one node that writes the zones; nil skips the election.
|
||||
lease *kvsync.Lease
|
||||
// writer applies the converged batch. The reconcile owns the zone object on
|
||||
// the node that won the election, so the batch never leaves this process.
|
||||
writer *Writer
|
||||
@@ -111,7 +118,6 @@ type Reconciler struct {
|
||||
// computeTimeout bounds how long one pass spends building the desired set.
|
||||
// A field rather than a constant so a test can shrink it.
|
||||
computeTimeout time.Duration
|
||||
holder string
|
||||
sources []reconciler.Source
|
||||
}
|
||||
|
||||
@@ -121,18 +127,15 @@ type Reconciler struct {
|
||||
// changes should wake the loop. They are optional: with none supplied the loop
|
||||
// falls back to the interval alone, which is the behaviour that predates the
|
||||
// watch.
|
||||
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, lease *kvsync.Lease, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
r := &Reconciler{
|
||||
nc: nc,
|
||||
lease: lease,
|
||||
writer: writer,
|
||||
desired: desired,
|
||||
interval: DefaultReconcileInterval,
|
||||
computeTimeout: DefaultComputeTimeout,
|
||||
sources: sources,
|
||||
}
|
||||
if cfg != nil {
|
||||
r.holder = cfg.Node
|
||||
}
|
||||
zoneCfg, ok := zoneS3Config(cfg)
|
||||
if !ok || desired == nil || !writer.Enabled() {
|
||||
return r
|
||||
@@ -166,25 +169,24 @@ func (r *Reconciler) Run(ctx context.Context) {
|
||||
// No revisit deadline: the desired set is a function of the buckets
|
||||
// being watched, so a change is the only reason to run early.
|
||||
Reconcile: func(ctx context.Context) (time.Duration, error) {
|
||||
r.reconcileOnce(ctx)
|
||||
return 0, nil
|
||||
return r.reconcileOnce(ctx), nil
|
||||
},
|
||||
Resync: r.interval,
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileOnce computes the converging batch and applies it, on whichever node
|
||||
// wins this cycle's election. The read that finds the stale records and the
|
||||
// wins this cycle's election, and returns the election's revisit when it lost. The read that finds the stale records and the
|
||||
// write that removes them now happen in one process, so a record re-added
|
||||
// between them is no longer deleted by a batch that predates it.
|
||||
func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||||
func (r *Reconciler) reconcileOnce(ctx context.Context) time.Duration {
|
||||
if !r.enabled {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
if r.nc != nil {
|
||||
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder)
|
||||
if r.lease != nil {
|
||||
release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
|
||||
if !elected {
|
||||
return
|
||||
return retry
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
@@ -193,18 +195,19 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||||
// A corrupt zone is handled in readZone; anything reaching here left the
|
||||
// desired state unapplied, so surface it rather than burying it in WARN.
|
||||
slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err)
|
||||
return
|
||||
return 0
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
slog.Debug("dns reconcile: converging", "changes", len(batch))
|
||||
res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch})
|
||||
if err != nil {
|
||||
slog.Warn("dns reconcile: apply failed, retrying next cycle", "changes", len(batch), "error", err)
|
||||
return
|
||||
return 0
|
||||
}
|
||||
slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
|
||||
return 0
|
||||
}
|
||||
|
||||
// computeBatchBounded runs computeBatch with a deadline, so a pass that never
|
||||
|
||||
@@ -8,41 +8,50 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// electingReconcilerFor is reconcilerFor with a live connection, so a pass runs
|
||||
// the election instead of skipping it.
|
||||
func electingReconcilerFor(w *Writer, nc *nats.Conn, holder string, desired func() DesiredSet) *Reconciler {
|
||||
// electingReconcilerFor is reconcilerFor with a lease, so a pass runs the
|
||||
// election instead of skipping it.
|
||||
func electingReconcilerFor(w *Writer, lease *kvsync.Lease, desired func() DesiredSet) *Reconciler {
|
||||
r := reconcilerFor(w, desired)
|
||||
r.nc = nc
|
||||
r.holder = holder
|
||||
r.lease = lease
|
||||
return r
|
||||
}
|
||||
|
||||
// dnsLease returns holder's DNS lease on a store of its own, standing in for
|
||||
// a separate process.
|
||||
func dnsLease(js jetstream.JetStream, holder string) *kvsync.Lease {
|
||||
return NewReconcileLease(kvstore.OpenSyncBucket(js), holder)
|
||||
}
|
||||
|
||||
// The zone lock is gone, and what replaces it is that a zone has one writer:
|
||||
// the node holding the reconcile lease. A node that loses the election must
|
||||
// therefore not touch the object at all — if it did, two nodes could be
|
||||
// read-modify-writing the same zone with nothing serialising them.
|
||||
func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
w, objects := newTestWriter(t)
|
||||
before := objects[testBase+".toml"]
|
||||
require.NotEmpty(t, before)
|
||||
|
||||
// A peer holds the lease for the whole pass, so this node cannot win it.
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "peer")
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "peer"))
|
||||
require.True(t, elected, "the peer has to hold the lease or this test proves nothing")
|
||||
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
r.reconcileOnce(t.Context())
|
||||
revisit := r.reconcileOnce(t.Context())
|
||||
|
||||
assert.Equal(t, before, objects[testBase+".toml"],
|
||||
"a node that did not win the election must leave the zone object exactly as it found it")
|
||||
assert.Positive(t, revisit, "the loser asks to retry so a dead holder is taken over")
|
||||
assert.LessOrEqual(t, revisit, reconcilelock.LeaderTTL/3, "the retry comes within a renewal interval")
|
||||
|
||||
release()
|
||||
}
|
||||
@@ -50,10 +59,10 @@ func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
|
||||
// The counterpart, so the test above is not passing because the pass was broken
|
||||
// for some other reason: with the lease free, the same reconciler writes.
|
||||
func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
w, objects := newTestWriter(t)
|
||||
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
r.reconcileOnce(t.Context())
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestReconciler_RunReturnsWhenDisabled(t *testing.T) {
|
||||
// path of Run: the startup pass must fire before any watch or resync activity,
|
||||
// and cancelling ctx must still let the loop return.
|
||||
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
var passes atomic.Int64
|
||||
@@ -235,8 +235,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
nc: nc,
|
||||
holder: "test-node",
|
||||
lease: dnsLease(js, "test-node"),
|
||||
desired: func() DesiredSet {
|
||||
passes.Add(1)
|
||||
return DesiredSet{}
|
||||
@@ -268,7 +267,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
// acquirable afterwards, not merely on the call returning, since it is the
|
||||
// leadership and not the pass that the rest of the cluster waits on.
|
||||
func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
blocked := make(chan struct{})
|
||||
@@ -281,8 +280,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
nc: nc,
|
||||
holder: "stuck-node",
|
||||
lease: dnsLease(js, "stuck-node"),
|
||||
computeTimeout: 100 * time.Millisecond,
|
||||
desired: func() DesiredSet {
|
||||
<-blocked
|
||||
@@ -302,7 +300,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
t.Fatal("reconcileOnce never returned: a stuck pass still holds the lease forever")
|
||||
}
|
||||
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "next-node")
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "next-node"))
|
||||
require.True(t, elected, "another node must be able to take the next cycle")
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ const (
|
||||
// PUT so every northstar instance reloads just that zone immediately, instead
|
||||
// of waiting for the next S3 sync poll. No queue group: all servers consume.
|
||||
SubjectZoneReload = "dns.zone.reload"
|
||||
// KVBucketDNSReconcile elects the one node that writes the zones for a drift
|
||||
// cycle. Its own bucket, so the election never blocks another reconcile loop.
|
||||
KVBucketDNSReconcile = "spinifex-dns-reconcile"
|
||||
// KeyReconcile elects the one node that writes the zones for a drift cycle.
|
||||
// Its own key, so the election never blocks another reconcile loop.
|
||||
KeyReconcile = "dns.reconcile"
|
||||
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
|
||||
PrivateZone = "compute.internal"
|
||||
// DefaultTTL is applied when a change omits a TTL.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID
|
||||
switch {
|
||||
case writeErr == nil:
|
||||
return &AccountSettingsSeal{Revision: newRev}, nil
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
case kvstore.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -57,7 +57,7 @@ func NewAccountSettingsServiceImplWithNATS(ctx context.Context, cfg *config.Conf
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
settingsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
|
||||
settingsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create account settings KV bucket: %w", err)
|
||||
}
|
||||
@@ -115,13 +115,13 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
|
||||
return errors.New(awserrors.ErrorBlocked)
|
||||
case errors.Is(err, errSettingsContended):
|
||||
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID)
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
case errors.Is(err, kvstore.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrWrite):
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err)
|
||||
@@ -133,7 +133,7 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
|
||||
// changed is always true, so every success is a leader-accepted write over an
|
||||
// active value; a seal landing between the read and the write fails the CAS.
|
||||
func (s *AccountSettingsServiceImpl) mutateSettings(ctx context.Context, accountID string, set func(*AccountSettingsRecord)) error {
|
||||
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.settingsKV, settingsKey(accountID), kvstore.CASConfig{
|
||||
CreateIfAbsent: true,
|
||||
Exhausted: func(string, int) error { return errSettingsContended },
|
||||
}, func(rec *AccountSettingsRecord) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
)
|
||||
|
||||
// ReasonRecordRemains marks a resource whose record the owner still holds.
|
||||
const ReasonRecordRemains = "record-remains"
|
||||
|
||||
// ComputeRemainder reads what compute the account still holds, changing nothing.
|
||||
type ComputeRemainder interface {
|
||||
ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error)
|
||||
}
|
||||
|
||||
// StorageRemainder reads what volumes and tag residue the account still holds,
|
||||
// changing nothing.
|
||||
type StorageRemainder interface {
|
||||
StorageRemaining(ctx context.Context, accountID string) (*Outcome, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ ComputeRemainder = (*ComputeCleanup)(nil)
|
||||
_ StorageRemainder = (*StorageCleanup)(nil)
|
||||
)
|
||||
|
||||
func validRemainderAccount(accountID string) error {
|
||||
if accountID == "" {
|
||||
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
|
||||
}
|
||||
if !awsidentifiers.IsAccountID(accountID) {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeRemaining lists every instance the account still holds and every spot
|
||||
// request that can still launch. A read that does not complete is an incomplete
|
||||
// reason, never a shorter list. The result is a snapshot, not evidence of absence.
|
||||
func (c *ComputeCleanup) ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error) {
|
||||
if err := validRemainderAccount(accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil || c.spot == nil || c.instances == nil || c.teardown == nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Compute cleanup is not fully wired.")
|
||||
}
|
||||
res := &Outcome{AccountID: accountID}
|
||||
defer res.finish()
|
||||
|
||||
scan, err := c.spot.ScanAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "ComputeRemaining: spot scan failed", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonSpotInventoryIncomplete)
|
||||
} else {
|
||||
for _, r := range slices.Concat(scan.Active, scan.Terminal) {
|
||||
if !terminalSpotState(r.State) {
|
||||
res.Items = append(res.Items, Item{ResourceID: r.RequestID, Kind: KindSpotRequest, State: r.State, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inventory, err := c.instances.AccountInstances(ctx, accountID)
|
||||
if err != nil || !inventoryUsable(inventory) {
|
||||
slog.WarnContext(ctx, "ComputeRemaining: instance inventory incomplete", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonInstanceInventoryIncomplete)
|
||||
} else {
|
||||
for _, reservation := range inventory.Reservations {
|
||||
for _, in := range reservation.Instances {
|
||||
state := ""
|
||||
if in.State != nil {
|
||||
state = aws.StringValue(in.State.Name)
|
||||
}
|
||||
res.Items = append(res.Items, Item{ResourceID: aws.StringValue(in.InstanceId), Kind: KindInstance, State: state, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "ComputeRemaining: teardown unread", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonTeardownUnread)
|
||||
} else {
|
||||
for _, o := range outstanding {
|
||||
if !res.hasInstance(o.InstanceID) {
|
||||
res.Items = append(res.Items, Item{ResourceID: o.InstanceID, Kind: KindInstance, State: o.State, Disposition: DispositionResidue, Reason: ReasonTerminating})
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// StorageRemaining lists every volume record the account still holds, deleting
|
||||
// ones included, and the tag entries of resources whose owner holds no record.
|
||||
func (c *StorageCleanup) StorageRemaining(ctx context.Context, accountID string) (*Outcome, error) {
|
||||
if err := validRemainderAccount(accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil || c.volumes == nil || c.instances == nil || c.teardown == nil || c.spot == nil || c.tags == nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Storage cleanup is not fully wired.")
|
||||
}
|
||||
res := &Outcome{AccountID: accountID}
|
||||
defer res.finish()
|
||||
|
||||
volumes, err := c.volumes.VolumeInventory(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "StorageRemaining: volume inventory incomplete", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonVolumeInventoryIncomplete)
|
||||
}
|
||||
for _, v := range volumes {
|
||||
reason := ReasonRecordRemains
|
||||
if v.State == ec2.VolumeStateDeleting {
|
||||
reason = ReasonVolumeDeleting
|
||||
}
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionResidue, Reason: reason})
|
||||
}
|
||||
|
||||
live, err := c.instances.AccountInstances(ctx, accountID)
|
||||
if err != nil || !inventoryUsable(live) {
|
||||
slog.WarnContext(ctx, "StorageRemaining: no complete instance view", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonHolderProofIncomplete)
|
||||
return res, nil
|
||||
}
|
||||
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "StorageRemaining: teardown unread", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonHolderProofIncomplete)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
tagged, err := c.tags.ScanAccountResourceIDs(ctx, accountID, storageTagPrefixes...)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "StorageRemaining: tag index read failed", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonTagIndexUnread)
|
||||
return res, nil
|
||||
}
|
||||
owned, resolved := c.ownedResourceIDs(ctx, accountID, live, outstanding)
|
||||
for _, id := range tagged {
|
||||
switch {
|
||||
case !resolved[tagPrefix(id)]:
|
||||
res.incomplete(ReasonTagOwnerUnresolved)
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved})
|
||||
case owned[id]:
|
||||
// The resource itself is reported; its tags are not residue.
|
||||
default:
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestComputeRemainingReportsEveryHeldInstanceAndOpenSpot(t *testing.T) {
|
||||
r := newComputeRig()
|
||||
r.spot.scans = []*ec2spotinstance.AccountScan{{
|
||||
Active: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa1", State: ec2.SpotInstanceStateOpen}},
|
||||
Terminal: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa2", State: ec2.SpotInstanceStateClosed}},
|
||||
}}
|
||||
r.teardown.outstanding = []ec2instance.OutstandingInstance{{InstanceID: "i-0aaaaaaaaaaaaaa99", State: ec2.InstanceStateNameTerminated}}
|
||||
|
||||
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.NotNil(t, itemFor(res, runningID))
|
||||
assert.NotNil(t, itemFor(res, stoppedID))
|
||||
assert.NotNil(t, itemFor(res, "i-0aaaaaaaaaaaaaa99"))
|
||||
assert.NotNil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa1"))
|
||||
assert.Nil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa2"), "a closed request launches nothing")
|
||||
assert.Empty(t, r.terminator.terminated, "a re-read changes nothing")
|
||||
}
|
||||
|
||||
func TestComputeRemainingCleanWhenNothingHeld(t *testing.T) {
|
||||
r := newComputeRig()
|
||||
r.inventory.out = listing(nil)
|
||||
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusClean, res.Status)
|
||||
}
|
||||
|
||||
func TestComputeRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*computeRig){
|
||||
"instances": func(r *computeRig) { r.inventory.out, r.inventory.err = nil, errors.New("node silent") },
|
||||
"spot": func(r *computeRig) { r.spot.scanErr = errors.New("spot unreadable") },
|
||||
"teardown": func(r *computeRig) { r.teardown.err = errors.New("no responder") },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newComputeRig()
|
||||
r.inventory.out = listing(nil)
|
||||
mutate(r)
|
||||
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.NotEmpty(t, res.IncompleteReasons)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageRemainingReportsVolumeRecordsAndTagResidue(t *testing.T) {
|
||||
r := newStorageRig()
|
||||
r.volumes.vols = []accountinventory.VolumeItem{
|
||||
volume(freeVol, ec2.VolumeStateAvailable, ""),
|
||||
volume("vol-0aaaaaaaaaaaaaaa2", ec2.VolumeStateDeleting, ""),
|
||||
}
|
||||
r.tags.ids = []string{"i-0aaaaaaaaaaaaaaaf", freeVol}
|
||||
|
||||
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.Equal(t, ReasonRecordRemains, itemFor(res, freeVol).Reason)
|
||||
assert.Equal(t, ReasonVolumeDeleting, itemFor(res, "vol-0aaaaaaaaaaaaaaa2").Reason)
|
||||
assert.Equal(t, KindTag, itemFor(res, "i-0aaaaaaaaaaaaaaaf").Kind, "tags of a resource with no record are residue")
|
||||
assert.Empty(t, r.deleter.deleted, "a re-read deletes nothing")
|
||||
assert.Empty(t, r.tags.deleted, "a re-read clears no tag")
|
||||
}
|
||||
|
||||
func TestStorageRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*storageRig){
|
||||
"volumes": func(r *storageRig) { r.volumes.failAt = -1 },
|
||||
"instances": func(r *storageRig) { r.instances.out, r.instances.err = nil, errors.New("node silent") },
|
||||
"teardown": func(r *storageRig) { r.teardown.err = errors.New("no responder") },
|
||||
"tags": func(r *storageRig) { r.tags.scanErr = errors.New("tag index unreadable") },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newStorageRig()
|
||||
r.volumes.vols = nil
|
||||
mutate(r)
|
||||
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.NotEmpty(t, res.IncompleteReasons)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// AccountTeardownOutstandingSubject serves the instances of an account whose
|
||||
// teardown is not complete, from the shared record store.
|
||||
const AccountTeardownOutstandingSubject = "ec2.AccountTeardownOutstanding"
|
||||
|
||||
var accountTeardownOutstandingTimeout = 15 * time.Second
|
||||
|
||||
// AccountTeardownOutstanding reads the account's unfinished instance teardowns.
|
||||
// No responder, a timeout or an error reply is an error, never an empty list.
|
||||
func AccountTeardownOutstanding(ctx context.Context, natsConn *nats.Conn, accountID string) ([]ec2instance.OutstandingInstance, error) {
|
||||
out, err := natsmsg.NATSRequest[ec2instance.TeardownOutstandingOutput](ctx, natsConn, AccountTeardownOutstandingSubject,
|
||||
&ec2instance.TeardownOutstandingInput{}, accountTeardownOutstandingTimeout, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read outstanding teardown: %w", err)
|
||||
}
|
||||
return out.Instances, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func serveTeardownOutstanding(t *testing.T, nc *nats.Conn, reply []byte) {
|
||||
t.Helper()
|
||||
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(msg *nats.Msg) { _ = msg.Respond(reply) })
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, nc.Flush())
|
||||
}
|
||||
|
||||
func TestAccountTeardownOutstanding_ReturnsTheServedInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
reply, err := json.Marshal(ec2instance.TeardownOutstandingOutput{Instances: []ec2instance.OutstandingInstance{{InstanceID: "i-1"}}})
|
||||
require.NoError(t, err)
|
||||
serveTeardownOutstanding(t, nc, reply)
|
||||
|
||||
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "i-1", got[0].InstanceID)
|
||||
}
|
||||
|
||||
// A read that cannot be answered must never look like an empty list, because an
|
||||
// empty list lets the storage cleanup believe no QEMU still holds a volume.
|
||||
func TestAccountTeardownOutstanding_UnansweredReadIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("no responder", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, nats.ErrNoResponders)
|
||||
})
|
||||
t.Run("error reply", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
serveTeardownOutstanding(t, nc, awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal))
|
||||
|
||||
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
t.Run("unreadable reply", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
serveTeardownOutstanding(t, nc, []byte("not json"))
|
||||
|
||||
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
// Not parallel: it shortens the package timeout.
|
||||
func TestAccountTeardownOutstanding_TimeoutIsAnError(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(*nats.Msg) {})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, nc.Flush())
|
||||
previous := accountTeardownOutstandingTimeout
|
||||
accountTeardownOutstandingTimeout = 200 * time.Millisecond
|
||||
t.Cleanup(func() { accountTeardownOutstandingTimeout = previous })
|
||||
|
||||
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -65,6 +66,20 @@ func ModifyInstanceAttribute(ctx context.Context, input *ec2.ModifyInstanceAttri
|
||||
|
||||
slog.InfoContext(ctx, "ModifyInstanceAttribute: Processing request", "instance_id", *input.InstanceId)
|
||||
|
||||
// Termination protection on a running instance lives on its owner, so it
|
||||
// goes there first. A nil value is not a value and keeps the queue path.
|
||||
if input.DisableApiTermination != nil && input.DisableApiTermination.Value != nil {
|
||||
cmd := ec2v1.EC2InstanceCommand{
|
||||
ID: *input.InstanceId,
|
||||
Attributes: ec2v1.EC2CommandAttributes{SetDisableApiTermination: true},
|
||||
DisableApiTerminationData: &ec2v1.DisableApiTerminationData{Value: *input.DisableApiTermination.Value},
|
||||
}
|
||||
if _, err := routeInstanceMutation[ec2.ModifyInstanceAttributeOutput](ctx, natsConn, *input.InstanceId, accountID, cmd, "ec2.ModifyInstanceAttribute", input); err != nil {
|
||||
return ec2.ModifyInstanceAttributeOutput{}, err
|
||||
}
|
||||
return ec2.ModifyInstanceAttributeOutput{}, nil
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "ModifyInstanceAttribute: Failed to marshal request", "instance_id", *input.InstanceId, "err", err)
|
||||
|
||||
@@ -3,10 +3,13 @@ package instance
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -226,3 +229,53 @@ func TestModifyInstanceAttribute_ValidationFailure(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestModifyInstanceAttribute_DisableApiTerminationRouting(t *testing.T) {
|
||||
const id = "i-0123456789abcdef0"
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
var gotValues []bool
|
||||
var mu sync.Mutex
|
||||
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(id), func(msg *nats.Msg) {
|
||||
var cmd ec2v1.EC2InstanceCommand
|
||||
if json.Unmarshal(msg.Data, &cmd) == nil && cmd.Attributes.SetDisableApiTermination && cmd.DisableApiTerminationData != nil {
|
||||
mu.Lock()
|
||||
gotValues = append(gotValues, cmd.DisableApiTerminationData.Value)
|
||||
mu.Unlock()
|
||||
}
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = []byte(`{}`)
|
||||
reply.Header.Set(ec2v1.HandledHeader, "1")
|
||||
_ = msg.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
var queue atomic.Int32
|
||||
_, err = nc.Subscribe("ec2.ModifyInstanceAttribute", func(msg *nats.Msg) {
|
||||
queue.Add(1)
|
||||
_ = msg.Respond([]byte(`{}`))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, v := range []bool{true, false} {
|
||||
_, err := ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(id),
|
||||
DisableApiTermination: &ec2.AttributeBooleanValue{Value: aws.Bool(v)},
|
||||
}, nc, "acct")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
mu.Lock()
|
||||
assert.Equal(t, []bool{true, false}, gotValues, "explicit true and false both reach the owner")
|
||||
mu.Unlock()
|
||||
assert.Zero(t, queue.Load())
|
||||
|
||||
// A nil value is not a value: queue path, no owner command.
|
||||
_, err = ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(id),
|
||||
DisableApiTermination: &ec2.AttributeBooleanValue{},
|
||||
}, nc, "acct")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), queue.Load())
|
||||
mu.Lock()
|
||||
assert.Len(t, gotValues, 2, "no owner command for a nil value")
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user