mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 20:57:02 +00:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6daa340044 | |||
| b4d4fa65f8 | |||
| 2e2465dd25 | |||
| 5d2c3e749f | |||
| fb0f8e9291 | |||
| bdfa01459c | |||
| f707df92e7 | |||
| f1ebb74acc | |||
| bde54ffd2f | |||
| d01e9b400d | |||
| 77b46481f1 | |||
| a16b667cbb | |||
| 7f634496b6 | |||
| 3a849c4c18 | |||
| 2f609efa14 | |||
| eccbf48273 | |||
| 84df82c26f | |||
| 29770dc798 | |||
| 09392d18a1 | |||
| 142ca6eec3 | |||
| 36949804e8 | |||
| a43febc187 | |||
| e7cb02dedf | |||
| be84b73abe | |||
| 3dbe352d1b | |||
| fcfcc1dcff | |||
| cd4363ba53 | |||
| 90739c889b | |||
| a4e5494e66 | |||
| efdd255435 | |||
| ed8a6f2fb2 | |||
| 0fb5c83a26 | |||
| 07bda82f1e | |||
| 49b94444ba | |||
| 1c21123c68 | |||
| 15c9308e41 | |||
| 3da9d9e60b | |||
| 04644bfbc9 | |||
| 71301983bd | |||
| 359eb7a386 | |||
| ef782a2758 | |||
| a7a774a7fc | |||
| bd14e2d67d | |||
| b5879f44ff | |||
| 0f025e8332 | |||
| 4c4ca253b4 | |||
| 3a3807f08e | |||
| 26f12e7107 | |||
| 5865fc418e | |||
| 02c02f6632 | |||
| 9075b13fe8 | |||
| 600fb41012 | |||
| 236dd2e87d | |||
| 96bcb4746d | |||
| 316a1b7c03 | |||
| 7fdabe1207 | |||
| 9498481161 | |||
| f3bebacbe5 | |||
| 2a19384fe1 | |||
| b6e50c488c | |||
| 5ad2346b3b | |||
| 4981828497 | |||
| 838de354c2 | |||
| 29aef59dae | |||
| 6644e6dc9f | |||
| a0b4d840e2 | |||
| bec3a78167 | |||
| 966a28f546 | |||
| c861642d44 | |||
| 72d234e95a | |||
| c09f0b5f11 | |||
| bc51816ad8 | |||
| ebc36d6f0c | |||
| 261b695e53 | |||
| e4d90b30d3 | |||
| bf3b830152 | |||
| b087d5d469 | |||
| fd9d69f05b | |||
| 75471404b6 | |||
| 848a662aac | |||
| ab45ea84ad | |||
| f50630ed4b | |||
| abb3da8af2 | |||
| 7e008c4689 | |||
| d91312d2f9 | |||
| c5d96ee56c | |||
| 788483807f | |||
| 429c5f13e5 | |||
| 49d3990252 | |||
| dbcda95b3c | |||
| 562e19aeca | |||
| 19c35326a3 | |||
| 68b1e18c72 | |||
| 9f9b7be9f7 | |||
| 68d3018b3e | |||
| 1a24217b2c | |||
| c09f15b50f | |||
| de4be25d80 |
+6
-6
@@ -6,10 +6,10 @@
|
||||
# Set the matching branch protection rule on `dev` and `main` to
|
||||
# "Require review from Code Owners" or this file is advisory only.
|
||||
|
||||
# Network layer is governed by ADR-0006 invariant tests.
|
||||
# Network layer is governed by its invariant tests.
|
||||
# Changes here must respect those clauses
|
||||
# see spinifex/network/invariants/
|
||||
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
|
||||
/spinifex/network/ @joshsiv-mulga
|
||||
/spinifex/vpcd/ @joshsiv-mulga
|
||||
/spinifex/network/invariants/ @joshsiv-mulga
|
||||
# see spinifex/domains/network/invariants/
|
||||
# mulga docs/development/proposals/region-local-architecture/0006-spinifex-network-layer-contract.md (proposal, not accepted)
|
||||
/spinifex/domains/network/ @joshsiv-mulga
|
||||
/spinifex/runtime/roles/vpcd/ @joshsiv-mulga
|
||||
/spinifex/domains/network/invariants/ @joshsiv-mulga
|
||||
|
||||
@@ -8,7 +8,7 @@ Actions job summary and emits one `::error` annotation per failed test case.
|
||||
```yaml
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report # or your own checkout path
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "Go E2E results"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<line x1="510.0" y1="212.0" x2="510.0" y2="220.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28.0" y="224.0" width="964.0" height="94.0" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="48.0" y="251.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="700" text-anchor="start" letter-spacing="1.5">AWS GATEWAY</text>
|
||||
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
|
||||
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/runtime/roles/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
|
||||
<rect x="46.0" y="287.0" width="68.2" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="80.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">TLS 1.3</text>
|
||||
<rect x="123.2" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -51,7 +51,7 @@
|
||||
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
|
||||
<rect x="16" y="154" width="868" height="104" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
|
||||
<text x="32" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY · port 9999</text>
|
||||
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/services/awsgw/awsgw.go</text>
|
||||
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/runtime/roles/awsgw/awsgw.go</text>
|
||||
|
||||
<!-- Two-column feature bullets -->
|
||||
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
+26
-6
@@ -23,11 +23,6 @@ updates:
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 3
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-patch"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
@@ -35,7 +30,7 @@ updates:
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: dev
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
directory: /spinifex/runtime/roles/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
@@ -50,3 +45,28 @@ updates:
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Base images are pinned by digest, so this is what moves them. The golang
|
||||
# version is deliberate and changes with go.mod; only its digest moves here.
|
||||
- package-ecosystem: docker
|
||||
target-branch: dev
|
||||
directories:
|
||||
- /build
|
||||
- /scripts
|
||||
- /docs/terraform-workbooks/demo-app
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 3
|
||||
ignore:
|
||||
- dependency-name: golang
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-patch"
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -5,3 +5,17 @@
|
||||
## Changes
|
||||
|
||||
<!-- The notable changes, as bullets. -->
|
||||
|
||||
## Account-scoped lifecycle impact
|
||||
|
||||
<!-- Complete this section only when the PR changes account-scoped resource lifecycle, account termination, resource cleanup, or a lifecycle boundary. Otherwise write: N/A. -->
|
||||
|
||||
- Lifecycle owner and resource family:
|
||||
- Owner-local ordering, recovery, retained-state, or force impact:
|
||||
- Cross-domain dependency or ownership impact:
|
||||
- Evidence added or rechecked:
|
||||
- ADR-0008 lifecycle register / ADR-0001 migration-record / bead updates:
|
||||
|
||||
## Community Contribution License
|
||||
|
||||
- [ ] All community contributions in this pull request are licensed to Mulga under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request I represent that I have the right to license these contributions to Mulga under the Apache License 2.0.
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolve the tag of each sibling repo (viperblock, predastore, northstar,
|
||||
# bluebottle) a release build checks out. A release ships what go.mod requires,
|
||||
# so each sibling is built at the version go.mod names, never a branch tip. A
|
||||
# pseudo-version has no tag to check out, so it fails the release rather than
|
||||
# silently building something else.
|
||||
#
|
||||
# Takes the path to spinifex's go.mod and writes viperblock=<tag>,
|
||||
# predastore=<tag>, northstar=<tag> and bluebottle=<tag> to $GITHUB_OUTPUT for
|
||||
# the checkout steps to consume.
|
||||
set -euo pipefail
|
||||
|
||||
gomod="${1:?usage: $0 <path to spinifex go.mod>}"
|
||||
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
|
||||
|
||||
for repo in viperblock predastore northstar bluebottle; do
|
||||
module="github.com/mulgadc/${repo}"
|
||||
version=$(awk -v m="$module" '$1 == m { print $2; exit }' "$gomod")
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "::error::${gomod} does not require ${module}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ || "$version" =~ [0-9]{14}-[0-9a-f]{12}$ ]]; then
|
||||
echo "::error::${module} is required at ${version}, which is not a release tag; tag ${repo} and bump go.mod before releasing"
|
||||
exit 1
|
||||
fi
|
||||
echo "${repo}: building against ${version}"
|
||||
echo "${repo}=${version}" >>"$GITHUB_OUTPUT"
|
||||
done
|
||||
@@ -0,0 +1,137 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/apt-packages.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
'.github/workflows/dev-release.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'docker/setup-buildx-action@v4.4.1'
|
||||
'.github/workflows/e2e-baremetal.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/e2e-cloudvendor-nightly.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'hashicorp/setup-terraform@v4.0.1'
|
||||
'.github/workflows/e2e-ddil.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/e2e-nightly.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/download-artifact@v8.0.2'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/e2e-suites.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/e2e.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/download-artifact@v8.0.2'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/gpu-e2e.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/image-scripts.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
'.github/workflows/perf-3node.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
'.github/workflows/publish-system-ami.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
'.github/workflows/release.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/download-artifact@v8.0.2'
|
||||
- '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'
|
||||
'.github/workflows/segscan-oracle.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
'.github/workflows/spinifex.yml':
|
||||
- 'actions/cache@v6.1.0'
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
- 'golangci/golangci-lint-action@v9.3.0'
|
||||
'.github/workflows/ui.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-node@v7.1.0'
|
||||
- 'pnpm/action-setup@v6.1.0'
|
||||
dependencies:
|
||||
'actions/cache@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
owner_id: 44036562
|
||||
repo_id: 215566462
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'actions/create-github-app-token@v3.3.0':
|
||||
ref: 'v3.3.0'
|
||||
commit: 'sha1-64ab729a6294000f5aa4a4bc0b73d1f32bc45510'
|
||||
owner_id: 44036562
|
||||
repo_id: 642580244
|
||||
'actions/download-artifact@v8.0.2':
|
||||
ref: 'v8.0.2'
|
||||
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
|
||||
owner_id: 44036562
|
||||
repo_id: 192626254
|
||||
'actions/setup-go@v7.0.0':
|
||||
ref: 'v7.0.0'
|
||||
commit: 'sha1-b7ad1dad31e06c5925ef5d2fc7ad053ef454303e'
|
||||
owner_id: 44036562
|
||||
repo_id: 192624594
|
||||
'actions/setup-node@v7.1.0':
|
||||
ref: 'v7.1.0'
|
||||
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
owner_id: 44036562
|
||||
repo_id: 189476904
|
||||
'actions/upload-artifact@v7.0.2':
|
||||
ref: 'v7.0.2'
|
||||
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625955
|
||||
'docker/setup-buildx-action@v4.4.1':
|
||||
ref: 'v4.4.1'
|
||||
commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069'
|
||||
owner_id: 5429470
|
||||
repo_id: 288485773
|
||||
'golangci/golangci-lint-action@v9.3.0':
|
||||
ref: 'v9.3.0'
|
||||
commit: 'sha1-ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a'
|
||||
owner_id: 35628013
|
||||
repo_id: 232539879
|
||||
'hashicorp/setup-terraform@v4.0.1':
|
||||
ref: 'v4.0.1'
|
||||
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
|
||||
owner_id: 761456
|
||||
repo_id: 258271226
|
||||
'pnpm/action-setup@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
|
||||
owner_id: 21320719
|
||||
repo_id: 262289747
|
||||
'softprops/action-gh-release@v3.0.3':
|
||||
ref: 'v3.0.3'
|
||||
commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64'
|
||||
owner_id: 2242
|
||||
repo_id: 204253808
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Resolve runtime package list
|
||||
run: ./scripts/lint-apt-packages.sh ${{ matrix.image }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# Full history and tags, because the tag name is git describe and a shallow
|
||||
# clone has no tag to describe against.
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 0
|
||||
@@ -42,40 +42,41 @@ jobs:
|
||||
# dev, not each repository's default branch, which is what release.yml takes.
|
||||
# A dev build of spinifex against main of the others is a pairing nobody runs.
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: dev
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: dev
|
||||
path: bluebottle
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
# The tag carries the describe string so the commit is recoverable from the
|
||||
# version alone, and the -dev suffix is what release.yml's own convention
|
||||
@@ -167,11 +168,7 @@ jobs:
|
||||
|
||||
# Kept rather than unbounded: the Worker reads the first page of releases to
|
||||
# resolve the channel, so enough prereleases would eventually push the real
|
||||
# ones off it, and each build carries two assets.
|
||||
#
|
||||
# Twenty, not five. A pinned dev tag is how an external evaluator installs a
|
||||
# specific build, and five is about a week of them -- short enough that the tag
|
||||
# someone was given stops resolving while they are still using it.
|
||||
# ones off it. Five leaves a few recent builds to roll back to.
|
||||
- name: Prune old dev prereleases
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
env:
|
||||
@@ -179,7 +176,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[20:] | .[].tag_name' \
|
||||
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[5:] | .[].tag_name' \
|
||||
> stale.txt
|
||||
while read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
|
||||
@@ -77,18 +77,18 @@ jobs:
|
||||
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -151,7 +151,8 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install go-junit-report on runner
|
||||
run: go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
run: GOWORK=off go install github.com/jstemmer/go-junit-report/v2
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
|
||||
# AMI. Built here because libguestfs needs source and network, which the
|
||||
@@ -221,20 +222,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: ${{ env.BM_CELL }}
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis (cell ${{ env.BM_CELL }})"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-${{ env.BM_CELL }}-${{ github.run_id }}
|
||||
|
||||
@@ -76,11 +76,11 @@ jobs:
|
||||
- name: Put the Terraform state outside the checkout
|
||||
run: echo "OCI_STATE_ROOT=$RUNNER_WORKSPACE/oci-state" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
@@ -92,14 +92,14 @@ jobs:
|
||||
|
||||
# build-install-artifacts.sh lives in mulga. The workbooks and their driver
|
||||
# are in spinifex, so only the one directory is needed here.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
path: mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
|
||||
- uses: hashicorp/setup-terraform@v4
|
||||
- uses: hashicorp/setup-terraform@v4.0.1
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
# collect step above chose, so terraform.tfstate cannot reach an artifact.
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: e2e-oci-${{ github.run_id }}
|
||||
path: |
|
||||
|
||||
@@ -38,19 +38,19 @@ jobs:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
name: Checkout Spinifex
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
name: Checkout Mulga (tofu-cluster)
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VMs, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: DDIL Real Multi-Node
|
||||
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
|
||||
title: "DDIL scenario results"
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
|
||||
cell: ddil
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
|
||||
- name: Upload DDIL Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: ddil-results-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/ddil-results
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-ddil-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
cells: ${{ steps.pick.outputs.cells }}
|
||||
any: ${{ steps.pick.outputs.any }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/e2e-cells.json
|
||||
@@ -128,21 +128,21 @@ jobs:
|
||||
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
# links each cell's telemetry to the job log that produced it. Runs
|
||||
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
|
||||
@@ -795,7 +795,8 @@ jobs:
|
||||
- name: Install go-junit-report
|
||||
if: always()
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Convert test logs to JUnit XML
|
||||
@@ -812,20 +813,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: ${{ matrix.cell }}
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis (cell ${{ matrix.cell }})"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
@@ -837,7 +838,7 @@ jobs:
|
||||
# pulling nineteen journals it has no use for.
|
||||
- name: Upload junit only
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: junit-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
@@ -868,7 +869,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-cell-${{ matrix.cell }}-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -973,20 +974,20 @@ jobs:
|
||||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
|
||||
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
ref: ${{ env.GIT_BRANCH }}
|
||||
path: mulga/spinifex
|
||||
@@ -998,25 +999,25 @@ jobs:
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: mulga/spinifex
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: mulga/viperblock
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: mulga/predastore
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: mulga/northstar
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
@@ -1151,7 +1152,7 @@ jobs:
|
||||
/tmp/smoke-$GITHUB_RUN_ID.log | tail -1)
|
||||
[ -n "$qemu" ] && cp "$qemu" /tmp/cell21-artifacts-$GITHUB_RUN_ID/qemu.log || true
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-21-${{ github.run_id }}
|
||||
@@ -1220,20 +1221,20 @@ jobs:
|
||||
# disks, which is what the tmpfs assertion below is about.
|
||||
echo "WORK_DIR=$E2E_ROOT/work/cell22-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
ref: ${{ env.GIT_BRANCH }}
|
||||
path: mulga/spinifex
|
||||
@@ -1245,25 +1246,25 @@ jobs:
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: mulga/spinifex
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: mulga/viperblock
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: mulga/predastore
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: mulga/northstar
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
@@ -1317,7 +1318,7 @@ jobs:
|
||||
cp "$WORK_DIR"/*-serial.log /tmp/cell22-artifacts-$GITHUB_RUN_ID/ 2>/dev/null ||
|
||||
echo "no per-node serial logs — the run passed and cleaned up after itself"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-cell-22-${{ github.run_id }}
|
||||
@@ -1369,7 +1370,7 @@ jobs:
|
||||
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -1384,7 +1385,7 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
@@ -1437,7 +1438,7 @@ jobs:
|
||||
# Report-only. The baseline comparison and the -10% gate are mulga-wpjsx;
|
||||
# until the run-to-run spread is known from real nights, a threshold would
|
||||
# be a guess, and a gate that cries wolf gets muted.
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: predastore-perf-${{ github.run_id }}
|
||||
@@ -1496,7 +1497,7 @@ jobs:
|
||||
echo "STRESS_RESULTS_ROOT=$E2E_ROOT/predastore-stress-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -1508,7 +1509,7 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
@@ -1563,7 +1564,7 @@ jobs:
|
||||
mkdir -p "$STRESS_RESULTS_ROOT"
|
||||
make e2e-stress 2>&1 | tee "$STRESS_RESULTS_ROOT/e2e-stress.log"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: predastore-stress-${{ github.run_id }}
|
||||
@@ -1600,13 +1601,13 @@ jobs:
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts/e2e-run-summary.py
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Collect junit
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
pattern: junit-cell-*-${{ github.run_id }}
|
||||
path: junit
|
||||
|
||||
@@ -103,25 +103,25 @@ jobs:
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
# the env: the suite legs share this env and its baked telemetry.env, so
|
||||
# the provisioner is the only job the daemon telemetry can belong to.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single env · provision
|
||||
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-single-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -323,18 +323,19 @@ jobs:
|
||||
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-single-${{ github.run_id }}-${{ matrix.suite }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install go-junit-report
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run ${{ matrix.suite }}
|
||||
@@ -390,7 +391,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "E2E ${{ matrix.suite }} results"
|
||||
@@ -400,20 +401,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: single
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E ${{ matrix.suite }} failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-single-${{ matrix.suite }}-${{ github.run_id }}
|
||||
@@ -444,7 +445,7 @@ jobs:
|
||||
# Reuse the persisted mulga/ tofu-cluster workspace from single_provision.
|
||||
# Re-checkout only spinifex (for ci-fmt + diag scripts); checking out mulga
|
||||
# again would wipe the local tofu state and orphan the VM.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -467,7 +468,7 @@ jobs:
|
||||
| tar -C "${ARTIFACT_DIR}/lb-console" -xf - 2>/dev/null
|
||||
true
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-single-diag-${{ github.run_id }}
|
||||
@@ -523,7 +524,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-single-td-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -571,25 +572,25 @@ jobs:
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -606,7 +607,7 @@ jobs:
|
||||
|
||||
# See the matching note on single_provision.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi env · provision
|
||||
|
||||
@@ -714,7 +715,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-multi-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -737,18 +738,19 @@ jobs:
|
||||
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-${{ github.run_id }}-${{ matrix.suite }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install go-junit-report
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run ${{ matrix.suite }}
|
||||
@@ -804,7 +806,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "Multi-Node E2E ${{ matrix.suite }} results"
|
||||
@@ -814,20 +816,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: multi
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "Multi-Node E2E ${{ matrix.suite }} failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-multi-${{ matrix.suite }}-${{ github.run_id }}
|
||||
@@ -854,7 +856,7 @@ jobs:
|
||||
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
|
||||
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -908,7 +910,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-multi-td-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
|
||||
+27
-25
@@ -54,7 +54,7 @@ jobs:
|
||||
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
|
||||
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
sparse-checkout: .github/scripts
|
||||
@@ -142,25 +142,25 @@ jobs:
|
||||
# stale suite-status.txt / analysis dirs can't leak into this attempt.
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
|
||||
# the VM, so a telemetry doc points back at this job's log directly.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single-Node E2E
|
||||
|
||||
@@ -372,7 +372,8 @@ jobs:
|
||||
|
||||
- name: Install go-junit-report on runner
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Ship test binaries to VM
|
||||
@@ -512,7 +513,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "E2E results"
|
||||
@@ -522,20 +523,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: single
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
@@ -579,7 +580,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-single-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -642,25 +643,25 @@ jobs:
|
||||
# stale suite-status.txt / analysis dirs can't leak into this attempt.
|
||||
rm -rf "${ARTIFACT_DIR}"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -677,7 +678,7 @@ jobs:
|
||||
|
||||
# See the matching note on e2e_single.
|
||||
- name: Resolve CI job id
|
||||
uses: ./spinifex/.github/actions/otel-job-id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Multi-Node E2E
|
||||
|
||||
@@ -758,7 +759,8 @@ jobs:
|
||||
|
||||
- name: Install go-junit-report on runner
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Ship test binaries to node1
|
||||
@@ -965,7 +967,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "Multi-Node E2E results"
|
||||
@@ -975,20 +977,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: ./spinifex/.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: multi
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./spinifex/.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "Multi-Node E2E failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-multi-artifacts-${{ github.run_id }}
|
||||
@@ -1035,7 +1037,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-multi-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
@@ -1105,14 +1107,14 @@ jobs:
|
||||
# the aggregation script below treats an absent suite-status.txt as
|
||||
# "no failures from that leg".
|
||||
- name: Download single-node artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
path: ${{ github.workspace }}/es-summary-single
|
||||
|
||||
- name: Download multi-node artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-multi-artifacts-${{ github.run_id }}
|
||||
|
||||
@@ -95,10 +95,10 @@ jobs:
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
run: sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -187,7 +187,8 @@ jobs:
|
||||
- name: Install go-junit-report on runner
|
||||
if: steps.gate.outputs.paused != 'true'
|
||||
run: |
|
||||
go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
# Version from go.mod's tool directive, checked against go.sum.
|
||||
GOWORK=off go install github.com/jstemmer/go-junit-report/v2
|
||||
echo "$HOME/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Ship GPU e2e test binary to wattle
|
||||
@@ -243,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Publish test summary
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
uses: ./.github/actions/junit-report
|
||||
uses: $/.github/actions/junit-report
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
title: "GPU E2E results"
|
||||
@@ -251,20 +252,20 @@ jobs:
|
||||
- name: Ship test results to Elasticsearch
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/junit-to-es
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: gpu
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
uses: ./.github/actions/e2e-analyze
|
||||
uses: $/.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "GPU E2E failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always() && steps.gate.outputs.paused != 'true'
|
||||
with:
|
||||
name: gpu-e2e-artifacts-${{ github.run_id }}
|
||||
|
||||
@@ -29,13 +29,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Run image script tests + shellcheck
|
||||
run: make test-images
|
||||
|
||||
eks_mkosi_image:
|
||||
name: Build mkosi EKS image
|
||||
# A fork PR's code must never run on a persistent self-hosted runner: it
|
||||
# shares disk, caches and credentials with the release jobs.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: [self-hosted, ci-single]
|
||||
concurrency:
|
||||
group: eks-mkosi-image-${{ github.ref }}
|
||||
@@ -43,10 +46,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
# Self-hosted: the module + build cache already persists on local
|
||||
|
||||
@@ -80,25 +80,25 @@ jobs:
|
||||
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
|
||||
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-3node-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
id: predastore
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
# Report-only, matching cell-23: the baseline comparison and any
|
||||
# regression gate are future work once run-to-run spread is known from
|
||||
# real bare-metal runs.
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: predastore-perf-3node-${{ github.run_id }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
|
||||
- name: Upload tofu log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-perf3node-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
|
||||
@@ -17,28 +17,12 @@ name: Publish System AMIs
|
||||
# spx admin images import --name spinifex-rds-postgres
|
||||
# spx admin images import --name spinifex-rds-mariadb
|
||||
#
|
||||
# Publishing overwrites the mirror artifact, so it is a deliberate action, not
|
||||
# something every push should trigger: it runs manually, or from release.yml
|
||||
# once a tag has passed its gates. The `variant` input scopes which images
|
||||
# build — the Ubuntu GPU legs are 16G and much slower (NVIDIA driver download +
|
||||
# DKMS build), so publishing only the CPU AMIs stays cheap.
|
||||
# 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.
|
||||
# The `variant` input scopes which images build — the Ubuntu GPU legs are 16G
|
||||
# and much slower (NVIDIA driver download + DKMS build).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
variant:
|
||||
description: "Which images to build"
|
||||
type: choice
|
||||
default: all
|
||||
options:
|
||||
- all
|
||||
- cpu
|
||||
- gpu
|
||||
- rds
|
||||
dry_run:
|
||||
description: "Build + convert only; skip the R2 upload"
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
variant:
|
||||
@@ -94,40 +78,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: dev
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
# Self-hosted runner: the Go module + build cache already persists on
|
||||
|
||||
@@ -5,48 +5,57 @@ on:
|
||||
tags: [ "v*" ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
distro-amd64:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
@@ -57,7 +66,7 @@ jobs:
|
||||
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
|
||||
|
||||
- name: Upload amd64 Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: |
|
||||
@@ -69,36 +78,44 @@ jobs:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Build arm64 Tarball
|
||||
env:
|
||||
@@ -106,7 +123,7 @@ jobs:
|
||||
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
|
||||
|
||||
- name: Upload arm64 Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: |
|
||||
@@ -116,21 +133,23 @@ jobs:
|
||||
release:
|
||||
needs: [ distro-amd64, distro-arm64 ]
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download amd64 Artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: artifacts
|
||||
|
||||
- name: Download arm64 Artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: artifacts
|
||||
|
||||
- name: Create Draft GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v3.0.3
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
@@ -140,7 +159,7 @@ jobs:
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Upload Tarball for Verify Job
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/
|
||||
@@ -167,14 +186,14 @@ jobs:
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -183,13 +202,13 @@ jobs:
|
||||
|
||||
# Only the shared shell helpers: this job builds nothing from source, but
|
||||
# it drives the same libvirt provider the e2e cells retry around.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
path: spinifex
|
||||
|
||||
- name: Download Release Assets
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
@@ -231,6 +250,8 @@ jobs:
|
||||
promote:
|
||||
needs: [ verify, baremetal ]
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -263,6 +284,8 @@ jobs:
|
||||
needs: [ verify, baremetal ]
|
||||
if: failure()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -280,6 +303,8 @@ jobs:
|
||||
iso:
|
||||
needs: release
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
permissions:
|
||||
contents: write
|
||||
# ISO build (~60-90m) overlaps the parallel baremetal run (~120m); the
|
||||
# upload gate then waits for baremetal to finish, so the job must outlast it.
|
||||
timeout-minutes: 180
|
||||
@@ -294,7 +319,7 @@ jobs:
|
||||
sudo umount -l "${ROOTFS}/proc" 2>/dev/null || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
@@ -302,37 +327,45 @@ jobs:
|
||||
repositories: mulga
|
||||
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
- name: Checkout Mulga iso-builder
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -343,7 +376,7 @@ jobs:
|
||||
run: sudo make -C mulga/scripts/iso-builder clean
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# tests/integration/segscan_oracle_test.go needs the mulga umbrella repo's
|
||||
# scripts/segscan checked out alongside spinifex (it is a separate Go module,
|
||||
# `package main`, and cannot be imported — see spinifex/testutil/segscanoracle).
|
||||
# `package main`, and cannot be imported — see internal/testkit/segscanoracle).
|
||||
# spinifex.yml's jobs check out only spinifex/viperblock/predastore and run on
|
||||
# every PR; adding a second-repo checkout there would make every spinifex PR
|
||||
# depend on mulga main being healthy and pay that checkout cost on every run,
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
@@ -38,34 +38,34 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: bluebottle
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
repositories: mulga
|
||||
|
||||
- name: Checkout Mulga (scripts/segscan only)
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
path: mulga
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
cache-dependency-path: "spinifex/go.sum"
|
||||
go-version-file: "spinifex/go.mod"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
@@ -40,28 +40,28 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
# the last go.sum change and never refreshed it. Both caches are managed
|
||||
# below instead.
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
cache: false
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
|
||||
# dependencies out of the key and re-downloading on every run.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
@@ -173,28 +173,28 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
# the last go.sum change and never refreshed it. Both caches are managed
|
||||
# below instead.
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
cache: false
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
|
||||
# dependencies out of the key and re-downloading on every run.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 2
|
||||
@@ -275,28 +275,28 @@ jobs:
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: ${{ steps.siblings.outputs.viperblock }}
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: ${{ steps.siblings.outputs.predastore }}
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: ${{ steps.siblings.outputs.northstar }}
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
# the last go.sum change and never refreshed it. Both caches are managed
|
||||
# below instead.
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "spinifex/go.mod"
|
||||
cache: false
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
|
||||
# dependencies out of the key and re-downloading on every run.
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
# a PR reads the base branch's entry without spending a minute uploading
|
||||
# one only that PR would ever read.
|
||||
- name: Restore Go build cache
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
@@ -338,9 +338,9 @@ jobs:
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
uses: golangci/golangci-lint-action@v9.3.0
|
||||
with:
|
||||
version: v2.13.0
|
||||
version: v2.14.0
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Govulncheck
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
|
||||
+21
-12
@@ -6,14 +6,14 @@ on:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- spinifex/runtime/roles/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- spinifex/runtime/roles/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -26,25 +26,25 @@ jobs:
|
||||
runs-on: ubuntu-26.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: spinifex/services/spinifexui/frontend
|
||||
working-directory: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: spinifex/services/spinifexui/frontend
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
with:
|
||||
package_json_file: spinifex/services/spinifexui/frontend/package.json
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: spinifex/services/spinifexui/frontend/pnpm-lock.yaml
|
||||
node-version-file: spinifex/services/spinifexui/frontend/.nvmrc
|
||||
cache-dependency-path: spinifex/runtime/roles/spinifexui/frontend/pnpm-lock.yaml
|
||||
node-version-file: spinifex/runtime/roles/spinifexui/frontend/.nvmrc
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -58,5 +58,14 @@ jobs:
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Check for generated route tree drift
|
||||
run: git diff --exit-code -- src/routeTree.gen.ts
|
||||
# spx embeds the committed dist/, so a bundle that differs from a clean
|
||||
# build of this source would ship JavaScript CI never built. Porcelain
|
||||
# rather than git diff, so added and deleted chunks count too.
|
||||
- name: Check for generated file drift
|
||||
run: |
|
||||
drift=$(git status --porcelain -- src/routeTree.gen.ts src/lib/cn-tables.gen.ts dist)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "::error::Generated files differ from a clean build. Run pnpm build and commit the result."
|
||||
echo "$drift"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ bin/
|
||||
dist/
|
||||
build/microvm/vmlinuz
|
||||
build/microvm/initramfs.cpio.gz
|
||||
!spinifex/services/spinifexui/frontend/dist/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
*.test
|
||||
*.out
|
||||
|
||||
+4
-3
@@ -71,17 +71,18 @@ linters:
|
||||
- pkg: math/rand$
|
||||
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
|
||||
|
||||
# The EC2 control plane reaches block storage through ebsprovider, not
|
||||
# The control plane reaches block storage through the EBS provider, not
|
||||
# by importing the engine. viperblock.New starts a chunk uploader, so
|
||||
# every construction is a writer against a volume viperblockd serves.
|
||||
# Nothing under handlers/ may import viperblock, in any form, tests
|
||||
# No domain or legacy handler may import viperblock, in any form, tests
|
||||
# included. A types-only import counts: one became an engine import.
|
||||
ec2-control-plane:
|
||||
files:
|
||||
- '**/spinifex/domains/**'
|
||||
- '**/spinifex/handlers/**'
|
||||
deny:
|
||||
- pkg: github.com/mulgadc/viperblock
|
||||
desc: Use the ebsprovider.EBSProvider interface; the control plane must not construct or name viperblock types.
|
||||
desc: Use the providers/ebs EBSProvider interface; the control plane must not construct or name viperblock types.
|
||||
|
||||
embeddedstructfieldcheck:
|
||||
forbid-mutex: true
|
||||
|
||||
+4
-3
@@ -16,6 +16,7 @@ Every AWS call enters through a SigV4-authenticated gateway, is published to NAT
|
||||
| [`docs/COMMANDS.md`](docs/COMMANDS.md) | The `spx admin` CLI and the systemd units it manages. |
|
||||
| [`docs/coverage/`](docs/coverage/README.md) | Which AWS API operations each service implements. |
|
||||
| [`scripts/README.md`](scripts/README.md) | Install, dev-environment, and image-build scripts. |
|
||||
| [`docs/PACKAGE_BOUNDARY_MIGRATION.md`](docs/PACKAGE_BOUNDARY_MIGRATION.md) | ADR-0001 implementation inventory and incremental source moves. |
|
||||
|
||||
User-facing docs are published at [docs.mulgadc.com](https://docs.mulgadc.com).
|
||||
|
||||
@@ -53,11 +54,11 @@ AWS_PROFILE=spinifex aws ec2 describe-instance-types
|
||||
|
||||
A list of instance types means the cluster is up.
|
||||
|
||||
`make preflight` also needs `shellcheck` and `golangci-lint` (CI pins v2.13.0):
|
||||
`make preflight` also needs `shellcheck` and `golangci-lint`
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y shellcheck
|
||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.13.0
|
||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.14.0
|
||||
```
|
||||
|
||||
The web console additionally needs Node.js 24 and `pnpm`.
|
||||
@@ -75,7 +76,7 @@ The web console additionally needs Node.js 24 and `pnpm`.
|
||||
|
||||
Follow the services with `journalctl -u 'spinifex-*' -f`.
|
||||
|
||||
The web console lives in `spinifex/services/spinifexui/frontend/` (`pnpm install`, `pnpm dev`, `pnpm lint`, `pnpm test`). `spx` embeds its checked-in `dist/`, so run `make build-ui` and commit the rebuilt `dist/` with your change.
|
||||
The web console lives in `spinifex/runtime/roles/spinifexui/frontend/` (`pnpm install`, `pnpm dev`, `pnpm lint`, `pnpm test`). `spx` embeds its checked-in `dist/`, so run `make build-ui` and commit the rebuilt `dist/` with your change.
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
|
||||
@@ -54,12 +54,12 @@ build: go_build build-installer build-lb-agent generate-aws-model-coverage
|
||||
# Build spinifex-ui frontend (requires pnpm)
|
||||
build-ui:
|
||||
@echo -e "\n....Building spinifex-ui frontend...."
|
||||
cd spinifex/services/spinifexui/frontend && pnpm build
|
||||
cd spinifex/runtime/roles/spinifexui/frontend && pnpm build
|
||||
|
||||
# GO commands
|
||||
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSION) -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=$(COMMIT)
|
||||
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/spinifex/operator/cli.Version=$(VERSION) -X github.com/mulgadc/spinifex/spinifex/operator/cli.Commit=$(COMMIT)
|
||||
|
||||
go_build:
|
||||
@echo -e "\n....Building $(GO_PROJECT_NAME)"
|
||||
@@ -155,7 +155,7 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
|
||||
# -Otarget keeps each gate's output in one block instead of interleaving them.
|
||||
# manifest-check manifest-lint test-package-check removed, can add back later
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
|
||||
@@ -220,7 +220,7 @@ aws-model-coverage: generate-aws-model-coverage
|
||||
@go run ./cmd/aws-model-coverage
|
||||
|
||||
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
|
||||
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
|
||||
# checked out alongside spinifex (see internal/testkit/segscanoracle), which
|
||||
# is not the default local or CI layout, so this is a separate target from
|
||||
# test-integration rather than folded into it. Skips itself when segscan's
|
||||
# source isn't found.
|
||||
@@ -248,7 +248,7 @@ manifest-lint-update:
|
||||
# Run unit tests
|
||||
test:
|
||||
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
|
||||
LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/...
|
||||
LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/...
|
||||
|
||||
# Empty locally, where reusing a cached result between runs is the point.
|
||||
# CI passes -count=1 so a green run means the suite executed against that commit
|
||||
@@ -259,24 +259,13 @@ GOTESTFLAGS ?=
|
||||
COVERPROFILE ?= coverage.out
|
||||
test-cover:
|
||||
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_COVQ)
|
||||
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
|
||||
|
||||
# Refresh the README coverage badge. Measured as awesome-go documents
|
||||
# (-coverpkg=./... over ./...), with cmd/ entrypoints left out of the total.
|
||||
coverage-badge:
|
||||
@echo -e "\n....Measuring whole-module coverage for the README badge...."
|
||||
LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 600s -covermode=atomic -coverpkg=./... -coverprofile=coverage-badge.out ./...
|
||||
@grep -v '/cmd/' coverage-badge.out > coverage-badge-nocmd.out
|
||||
@pct=$$(go tool cover -func=coverage-badge-nocmd.out | tail -1 | awk '{print $$NF}' | tr -d '%'); \
|
||||
color=$$(awk -v p="$$pct" 'BEGIN { print (p >= 80) ? "brightgreen" : (p >= 70) ? "yellow" : "red" }'); \
|
||||
sed -i -E "s|badge/coverage-[0-9.]+%25-[a-z]+|badge/coverage-$${pct}%25-$${color}|" README.md; \
|
||||
echo "Coverage $${pct}% ($${color}); README badge updated"
|
||||
|
||||
# Run unit tests with race detector
|
||||
test-race:
|
||||
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
|
||||
|
||||
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
|
||||
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
|
||||
@@ -376,7 +365,7 @@ reinstall:
|
||||
|
||||
clean:
|
||||
rm -f ./bin/$(GO_PROJECT_NAME)
|
||||
rm -rf spinifex/services/spinifexui/frontend/dist
|
||||
rm -rf spinifex/runtime/roles/spinifexui/frontend/dist
|
||||
|
||||
install-system:
|
||||
@echo -e "\n....Installing system dependencies for $(ARCH)...."
|
||||
@@ -390,9 +379,9 @@ install-system:
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.27.0 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.27.0.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
@@ -475,7 +464,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover coverage-badge test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -4,225 +4,136 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
<a href="https://pkg.go.dev/github.com/mulgadc/spinifex"><img src="https://img.shields.io/badge/pkg.go.dev-reference-007D9C?style=flat-square&logo=go&logoColor=white" alt="Go Reference"></a>
|
||||
<a href="https://github.com/mulgadc/spinifex/blob/main/Makefile"><img src="https://img.shields.io/badge/coverage-82.9%25-brightgreen?style=flat-square" alt="Coverage"></a>
|
||||
<a href="https://pkg.go.dev/github.com/mulgadc/spinifex"><img src="https://img.shields.io/badge/pkg.go.dev-Reference-007D9C?style=flat-square&logo=go&logoColor=white" alt="Go Reference"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
|
||||
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/home-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
|
||||
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/Site-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-is-spinifex">What is Spinifex?</a> ·
|
||||
<a href="#the-platform">Platform</a> ·
|
||||
<a href="#three-ways-to-deploy">Deploy</a> ·
|
||||
<a href="#aws-compatibility">AWS Compatibility</a> ·
|
||||
<a href="#live-demo">Live Demo</a> ·
|
||||
<a href="#core-components">Components</a> ·
|
||||
<a href="#architecture-at-a-glance">Architecture</a> ·
|
||||
<a href="#self-hosted-installation">Installation</a> ·
|
||||
<a href="https://docs.mulgadc.com">Docs</a>
|
||||
<a href="#try-it-live">Live Demo</a> ·
|
||||
<a href="#services">Services</a> ·
|
||||
<a href="#deploy-anywhere">Deploy</a> ·
|
||||
<a href="#install">Install</a> ·
|
||||
<a href="#architecture">Architecture</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
# Spinifex: the open, AWS-compatible cloud you run yourself
|
||||
|
||||
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line. The exact build you ship to AWS, Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
|
||||
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line.
|
||||
|
||||
## What is Spinifex?
|
||||
|
||||
Spinifex is an open-source infrastructure platform that recreates the AWS service surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises environments. Run cloud-native software without a hyperscaler.
|
||||
|
||||
Built for teams that need:
|
||||
|
||||
- The AWS tooling you already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
|
||||
- Full control of the stack: your hardware, your network, your data, your keys
|
||||
- A cloud that keeps running offline, through disconnection, with no external control plane
|
||||
- An open core (AGPL-3.0), auditable and yours to keep, with no lock-in
|
||||
- **Your AWS tooling, unchanged.** The AWS CLI, SDKs, and Terraform work as they are.
|
||||
- **Full control of the stack.** Your hardware, data, and keys, with no external control plane, running through disconnection.
|
||||
- **Open core under AGPL-3.0.** Auditable, yours to keep, with no lock-in.
|
||||
|
||||
You change *where* your software runs, not *what* it is.
|
||||
|
||||
## The Platform
|
||||
## Try It Live
|
||||
|
||||
Get a real Spinifex endpoint in under a minute, with no card and no install. The sandbox is yours for 72 hours, with access keys, the web console, and EC2, EBS, S3, VPC, IAM, EKS, and RDS ready to call. Point your existing AWS CLI or Terraform straight at it.
|
||||
|
||||
**[Create your sandbox →](https://mulgadc.com/signup)**
|
||||
|
||||
## Same Commands, Your Cloud
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-images
|
||||
aws ec2 run-instances --image-id ami-xxxxxxxx --instance-type t3.micro
|
||||
aws s3 mb s3://my-bucket
|
||||
```
|
||||
|
||||
For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/nginx-webserver).
|
||||
|
||||
## Why Not LocalStack or Floci?
|
||||
|
||||
[LocalStack](https://github.com/localstack/localstack) and [Floci](https://github.com/floci-io/floci) are emulators for local development and CI testing. An emulator returns a plausible response; Spinifex boots the machine. A call launches a real KVM guest, attaches a replicated Viperblock volume, or writes an erasure-coded object into Predastore, so Spinifex can run your production workloads on hardware you own. Test against an emulator, then run in production on Spinifex.
|
||||
|
||||
## Services
|
||||
|
||||
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
|
||||
|
||||
| Service | What it is | Built on | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| **EC2** | Compute | QEMU/KVM, with spot instances and GPU passthrough | Available |
|
||||
| **EBS** | Block storage | [Viperblock](https://github.com/mulgadc/viperblock): replicated, with a WAL on local NVMe | Available |
|
||||
| **S3** | Object storage | [Predastore](https://github.com/mulgadc/predastore): Reed-Solomon erasure coding | Available |
|
||||
| **VPC** | Networking | OVN, with security groups | Available |
|
||||
| **IAM** | Identity & auth | SigV4 across every service | Available |
|
||||
| **ALB / NLB** | Load balancers | HAProxy (L7), nginx `stream` (L4), with ACM certificates | Available |
|
||||
| **EKS** | Kubernetes | k3s, with HA control planes and managed node groups | Available |
|
||||
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
|
||||
| **ECS** | Container service | Spinifex instances | Available |
|
||||
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
|
||||
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
|
||||
| **Lambda** | Serverless functions | | Q1 2027 |
|
||||
|
||||
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
|
||||
|
||||
## Deploy Anywhere
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/platform.svg" alt="The Spinifex stack: your apps and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU and GPU hardware, deployable to Neocloud, on-premise, or the edge." width="900">
|
||||
</p>
|
||||
|
||||
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
|
||||
- **Neocloud:** lift and shift onto partner GPU capacity (H100 / H200 / B200). Change an endpoint, keep the software.
|
||||
- **On-premise:** a multi-node HA cluster in your own data centre, so the stack, the data, and the jurisdiction stay yours.
|
||||
- **Edge:** air-gapped sites, vehicles, vessels, factories, and clinics, with compute next to the data.
|
||||
|
||||
## Three Ways To Deploy
|
||||
## Install
|
||||
|
||||
The same AWS-compatible platform runs in three shapes. Pick the one that matches your hardware.
|
||||
Spinifex runs on Ubuntu 26.04 or Debian 13. The full guides are at [docs.mulgadc.com](https://docs.mulgadc.com).
|
||||
|
||||
### Neocloud
|
||||
>*Prerequisite:* Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) for setup details.
|
||||
|
||||
Lift and shift onto a partner Neocloud without rewriting workloads. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
|
||||
|
||||
### On-premise
|
||||
|
||||
Host Spinifex on your own hardware in your own data centre. You get a multi-node HA cluster integrated with your storage and networking, so the stack, the data, and the jurisdiction stay yours.
|
||||
|
||||
### Edge
|
||||
|
||||
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels, factories, and clinics. Compute next to the data, running through disconnection, on hardware you own. The same AWS APIs your software already uses.
|
||||
|
||||
## AWS Compatibility
|
||||
|
||||
Spinifex speaks the AWS API surface. The AWS SDKs, AWS CLI, and Terraform all work unchanged, whether you deploy at the edge, on-premise, or on a partner Neocloud.
|
||||
|
||||
| Service | What it is | Status |
|
||||
| --- | --- | --- |
|
||||
| **EC2** | Compute | Available |
|
||||
| **EBS** | Block Storage | Available |
|
||||
| **S3** | Object Storage | Available |
|
||||
| **VPC** | Networking | Available |
|
||||
| **IAM** | Identity & Auth | Available |
|
||||
| **ALB / NLB** | Load Balancers | Available |
|
||||
| **EKS** | Kubernetes | Available |
|
||||
| **ECR** | Container Registry | Available |
|
||||
| **ECS** | Container Service | Available |
|
||||
| **RDS** | Databases | Available |
|
||||
| **Bedrock** | AI Deployment | Q4 2026 |
|
||||
|
||||
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
|
||||
|
||||
## Live Demo
|
||||
|
||||
Create a live sandbox in under a minute. The fastest way to experience the stack. Try Spinifex against one of your own workloads on infrastructure we run for you.
|
||||
|
||||
* Live in under a minute. No card, no install.
|
||||
* Yours for 72 hours to test and experiment.
|
||||
* Real AWS-compatible endpoint, access keys and console.
|
||||
* EC2, EBS, S3, VPC, IAM, EKS and RDS, ready to call.
|
||||
* Point your existing AWS CLI or Terraform straight at it.
|
||||
|
||||
Sign up at [https://mulgadc.com/signup](https://mulgadc.com/signup)
|
||||
|
||||
## Core Components
|
||||
|
||||
### Spinifex (Compute and Platform Services – EC2, EKS, ECS, RDS)
|
||||
|
||||
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing an EC2 compatible API. It manages lifecycle operations like start, stop, and terminate through QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs with the AWS CLI, SDKs, or Terraform, without Kubernetes or a heavyweight orchestrator.
|
||||
|
||||
The managed services build on that same instance layer. An EKS node group, an ECS container instance, and an RDS database are Spinifex VMs on your own hardware, each driven through its own AWS API.
|
||||
|
||||
- **EC2** – VM lifecycle on bare metal, cloud-init and IMDS metadata, key pairs, tags, spot instances, capacity reservations, and GPU passthrough
|
||||
- **EKS** – Managed Kubernetes on k3s, with HA control planes, managed node groups, cluster add-ons, an OIDC provider, and IAM access entries
|
||||
- **ECS** – Task and service scheduling across container instances, with awsvpc ENIs, capacity providers, rolling deployments, and GPU-aware placement
|
||||
- **ECR** – Private container registry backed by Predastore, with content-addressed blobs deduplicated per account and lifecycle policies
|
||||
- **RDS** – Managed PostgreSQL and MariaDB, with parameter groups, automated backups, and DNS endpoints served by Northstar
|
||||
- **ALB / NLB** – Layer 7 load balancing on HAProxy and layer 4 on nginx `stream`, with health checks and ACM-issued listener certificates
|
||||
- **VPC and IAM** – OVN-backed networking and security groups, with roles, policies, and SigV4 auth applied across every service
|
||||
|
||||
### Viperblock (Block Storage – EBS Alternative)
|
||||
|
||||
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It's built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
|
||||
|
||||
- Fast, durable virtual disks
|
||||
- Replication for resilience
|
||||
- Exposed over NBD or embedded in VMs
|
||||
- High performance WAL logs on local NVMe drives, reducing IO traffic to S3
|
||||
- In-memory read/write block cache
|
||||
|
||||
### Predastore (Object Storage – S3-Compatible)
|
||||
|
||||
[Predastore](https://github.com/mulgadc/predastore) is a fully S3-compatible object storage system. It supports the AWS S3 API, including Signature V4 authentication, multipart uploads, and Terraform provisioning. Predastore chunks data and distributes it across nodes using Reed-Solomon erasure coding, so it survives node loss and suits large-scale or low-bandwidth sites.
|
||||
|
||||
- S3-compatible API and auth
|
||||
- Multipart uploads, streaming reads/writes
|
||||
- Data redundancy with Reed-Solomon encoding
|
||||
|
||||
### Northstar (Authoritative DNS)
|
||||
|
||||
[Northstar](https://github.com/mulgadc/northstar) is a lightweight authoritative DNS server that handles both internal service discovery and public-facing DNS for a Spinifex cluster. Zones are human-readable TOML files, served from the local filesystem or synced from Predastore, keeping DNS configuration in the same object storage as the rest of your infrastructure.
|
||||
|
||||
- Authoritative DNS over UDP, TCP, and DNS-over-TLS
|
||||
- Service discovery for cluster components via SRV records
|
||||
- Zone files stored locally or in S3-compatible storage
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
|
||||
</p>
|
||||
|
||||
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Just systemd units, a NATS cluster, and your hardware. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
|
||||
|
||||
## Self-hosted Installation
|
||||
|
||||
Installation requires an Ubuntu 26.04 or Debian 13 system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for installing Spinifex.
|
||||
|
||||
### Bare Metal ISO
|
||||
|
||||
The recommended installation is a [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) for bare-metal hardware.
|
||||
**Bare metal (recommended):** download the [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) and follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb).
|
||||
|
||||
```bash
|
||||
curl -fLO https://iso.mulgadc.com/spinifex.iso
|
||||
```
|
||||
|
||||
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware.
|
||||
|
||||
### Single Node Install
|
||||
|
||||
>*Prerequisite:* Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) for setup details.
|
||||
**Single node:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
|
||||
sudo systemctl start spinifex.target
|
||||
|
||||
export AWS_PROFILE=spinifex
|
||||
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
**From source:** see the [Source Install](https://docs.mulgadc.com/docs/install-source) guide.
|
||||
|
||||
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation.
|
||||
|
||||
## Spinifex UI
|
||||
|
||||
Spinifex ships with a built-in web console, an optional alternative to the AWS CLI, SDKs, and Terraform. If you know the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
|
||||
## Web Console
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console dashboard" width="900">
|
||||
</p>
|
||||
|
||||
Each node serves the console on port `3000` over TLS, and it comes up with `spinifex.target`:
|
||||
Spinifex ships with a built-in web console for your instances, volumes, buckets, networks, and IAM. See [Launching the Web UI](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui).
|
||||
|
||||
- **Same API, different surface.** The UI issues the same AWS SigV4 calls the CLI does, so RBAC, audit trails, and IAM policies apply the same way.
|
||||
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed. There is no separate user database.
|
||||
- **Self-hosted, works offline.** The Spinifex binary embeds the UI and each node serves it directly, with no external CDN or analytics calls.
|
||||
## Architecture
|
||||
|
||||
For the full walkthrough, covering first-time TLS certificate trust, login, and a feature tour, see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
|
||||
<p align="center">
|
||||
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
|
||||
</p>
|
||||
|
||||
## Development Philosophy
|
||||
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless, so you scale horizontally by starting more. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
|
||||
|
||||
### Built by Engineers, For Engineers
|
||||
## How We Build Spinifex
|
||||
|
||||
Spinifex is developed by experienced infrastructure engineers with deep AWS expertise, including former AWS team members who understand the intricacies of building production-grade cloud services. Our team brings decades of combined experience from AWS, enterprise infrastructure, and edge computing environments.
|
||||
Spinifex is built by infrastructure engineers with deep AWS experience, including former AWS team members, drawing on decades of combined work across AWS, enterprise infrastructure, and edge computing. Engineers architect and implement it, with **Claude Code** accelerating parts of the work.
|
||||
|
||||
### AI-Assisted Development
|
||||
|
||||
Spinifex is architected and implemented by experienced engineers, with **Claude Code** accelerating certain development tasks. The split keeps the quality and reliability standards that production infrastructure demands:
|
||||
|
||||
**How We Use Claude Code:**
|
||||
|
||||
- **Code Generation**: Boilerplate AWS API structures and handlers
|
||||
- **Documentation**: Comprehensive development guides and API documentation
|
||||
- **Testing**: Test case generation and validation scenarios
|
||||
- **Refactoring**: Large-scale code restructuring and optimization
|
||||
|
||||
**What Remains Human-Driven:**
|
||||
|
||||
- **Architecture Decisions**: Core system design and scalability choices
|
||||
- **Security Implementation**: Authentication, encryption, and threat modeling
|
||||
- **Performance Optimization**: Real-world performance tuning and benchmarking
|
||||
- **Production Operations**: Deployment strategies and operational procedures
|
||||
| Claude Code assists with | Engineers own |
|
||||
| --- | --- |
|
||||
| Boilerplate AWS API structures and handlers | Architecture and scalability decisions |
|
||||
| Development guides and API documentation | Authentication, encryption, and threat modelling |
|
||||
| Test case generation and validation scenarios | Real-world performance tuning and benchmarking |
|
||||
| Large-scale restructuring and refactoring | Deployment strategy and production operations |
|
||||
|
||||
## Trademarks
|
||||
|
||||
@@ -230,4 +141,4 @@ Spinifex is architected and implemented by experienced engineers, with **Claude
|
||||
|
||||
## License
|
||||
|
||||
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE), with a commercial license available. You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
|
||||
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE), with a commercial license available.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package instancestate
|
||||
|
||||
// allowance is one recorded production consumer of raw instance state.
|
||||
type allowance struct {
|
||||
File, Symbol string
|
||||
Inv string // inventory item in docs/PACKAGE_BOUNDARY_MIGRATION.md
|
||||
}
|
||||
|
||||
// allowlist mirrors the ADR-0007 slice 0 inventory in
|
||||
// docs/PACKAGE_BOUNDARY_MIGRATION.md. It only shrinks: delete an entry as soon
|
||||
// as its use is gone, and never add one to make a new consumer pass.
|
||||
var allowlist = []allowance{
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.LocalStatePath", Inv: "INV-01"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.ReadLocalState", Inv: "INV-01"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.MarshalLocalState", Inv: "INV-01"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.WriteLocalStateBytes", Inv: "INV-01"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.InitKVBucket", Inv: "INV-02"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.InitTerminatedInstanceBucket", Inv: "INV-02"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.WriteNodeMarkerBestEffort", Inv: "INV-03"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.WriteRunningSet", Inv: "INV-03"},
|
||||
{File: "spinifex/daemon/instance_record_repair.go", Symbol: "JetStreamManager.WriteRunningSet", Inv: "INV-03"},
|
||||
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-04"},
|
||||
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.LoadState", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.DeleteStoppedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ClaimStoppedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ListStoppedInstances", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.UpdateTerminatedInstance", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-05"},
|
||||
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.DeleteTerminatedInstance", Inv: "INV-05"},
|
||||
|
||||
{File: "spinifex/runtime/compute/vm/migrate.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/migrate.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/shutdown.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/orphan_qemu_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.UpdateTerminatedInstance", Inv: "INV-06"},
|
||||
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.DeleteTerminatedInstance", Inv: "INV-06"},
|
||||
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ListStoppedInstances", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ClaimStoppedInstance", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-07"},
|
||||
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.DeleteStoppedInstance", Inv: "INV-07"},
|
||||
|
||||
{File: "spinifex/daemon/daemon_handlers_image.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-08"},
|
||||
{File: "spinifex/daemon/daemon_handlers_image.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-08"},
|
||||
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "vm.InstanceRecord", Inv: "INV-10"},
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-10"},
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ClaimRecoverableInstance", Inv: "INV-10"},
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ReleaseRecoveredInstance", Inv: "INV-10"},
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.AbandonRecovery", Inv: "INV-10"},
|
||||
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.LoadInstanceRecord", Inv: "INV-10"},
|
||||
|
||||
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "daemon.InstanceRecordPrefix", Inv: "INV-11"},
|
||||
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-11"},
|
||||
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-11"},
|
||||
|
||||
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "vm.InstanceRecord", Inv: "INV-12"},
|
||||
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-12"},
|
||||
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstanceRecords", Inv: "INV-12"},
|
||||
|
||||
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.LocalState", Inv: "INV-13"},
|
||||
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.LocalStatePath", Inv: "INV-13"},
|
||||
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.ReadLocalState", Inv: "INV-13"},
|
||||
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-13"},
|
||||
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "JetStreamManager.InitKVBucket", Inv: "INV-13"},
|
||||
|
||||
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "vm.InstanceRecord", Inv: "INV-14"},
|
||||
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "vm.VMFromRecord", Inv: "INV-14"},
|
||||
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "JetStreamManager.LoadInstanceRecord", Inv: "INV-14"},
|
||||
|
||||
{File: "spinifex/domains/network/reconcile/intent.go", Symbol: `literal "spinifex-instance-state"`, Inv: "INV-15"},
|
||||
{File: "spinifex/domains/network/reconcile/intent.go", Symbol: "vm.InstanceRecord", Inv: "INV-15"},
|
||||
|
||||
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-16"},
|
||||
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "daemon.InstanceRecordPrefix", Inv: "INV-16"},
|
||||
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "vm.InstanceRecord", Inv: "INV-16"},
|
||||
|
||||
{File: "spinifex/runtime/compute/cache/cache.go", Symbol: "vm.InstanceRecord", Inv: "INV-17"},
|
||||
{File: "spinifex/runtime/compute/cache/cache.go", Symbol: "vm.VMFromRecord", Inv: "INV-17"},
|
||||
|
||||
{File: "spinifex/domains/admission/quota/records.go", Symbol: "vm.InstanceRecord", Inv: "INV-18"},
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package instancestate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type report struct {
|
||||
unrecorded []use
|
||||
stale []allowance
|
||||
malformed []string
|
||||
}
|
||||
|
||||
// check compares the scanned uses with allow.
|
||||
func check(s scan, allow []allowance) report {
|
||||
var rep report
|
||||
allowed := map[use]bool{}
|
||||
for _, a := range allow {
|
||||
u := use{File: a.File, Symbol: a.Symbol}
|
||||
switch {
|
||||
case allowed[u]:
|
||||
rep.malformed = append(rep.malformed, "duplicate entry "+u.String())
|
||||
case a.Inv == "":
|
||||
rep.malformed = append(rep.malformed, "entry "+u.String()+" cites no inventory item")
|
||||
}
|
||||
allowed[u] = true
|
||||
}
|
||||
for _, u := range s.sorted() {
|
||||
if !allowed[u] {
|
||||
rep.unrecorded = append(rep.unrecorded, u)
|
||||
}
|
||||
}
|
||||
for _, a := range allow {
|
||||
if _, ok := s[use{File: a.File, Symbol: a.Symbol}]; !ok {
|
||||
rep.stale = append(rep.stale, a)
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// TestADR0007_S6_NoNewRawInstanceStateConsumers is the blocking ratchet: no
|
||||
// production file may start naming the daemon's instance-state bucket, keys,
|
||||
// local file, raw record or JetStreamManager instance methods.
|
||||
func TestADR0007_S6_NoNewRawInstanceStateConsumers(t *testing.T) {
|
||||
root, err := moduleRoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, files, err := scanModule(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Guards against a walk that silently finds nothing and passes vacuously.
|
||||
if files < 500 || len(s) < 20 {
|
||||
t.Fatalf("scanned %d files and found %d uses under %s; the walk has lost the tree", files, len(s), root)
|
||||
}
|
||||
if len(allowlist) == 0 {
|
||||
t.Fatal("the allowlist is empty, so the stale check would pass vacuously")
|
||||
}
|
||||
rep := check(s, allowlist)
|
||||
|
||||
t.Run("NoUnrecordedConsumer", func(t *testing.T) {
|
||||
for _, u := range rep.unrecorded {
|
||||
t.Errorf("ADR-0007 S6 (consumers receive narrow state capabilities rather than raw stores): %s at %s; "+
|
||||
"consume an instance-domain capability instead of the daemon's raw instance state", u, s[u])
|
||||
}
|
||||
})
|
||||
t.Run("Allowlist_NoStaleEntries", func(t *testing.T) {
|
||||
for _, a := range rep.stale {
|
||||
t.Errorf("allowlist entry %s uses %s (%s) no longer matches; delete it and update the inventory",
|
||||
a.File, a.Symbol, a.Inv)
|
||||
}
|
||||
})
|
||||
t.Run("Allowlist_WellFormed", func(t *testing.T) {
|
||||
for _, m := range rep.malformed {
|
||||
t.Errorf("allowlist_test.go: %s", m)
|
||||
}
|
||||
t.Logf("%d allow-listed uses", len(allowlist))
|
||||
})
|
||||
t.Run("OwnerFilesExist", func(t *testing.T) {
|
||||
for _, f := range ownerFiles {
|
||||
if _, err := os.Stat(filepath.Join(root, f)); err != nil {
|
||||
t.Errorf("owner file %s is gone; remove it from ownerFiles and re-scan its consumers: %v", f, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const faultSource = `package %s
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
spxd "github.com/mulgadc/spinifex/spinifex/daemon"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
type holder struct {
|
||||
LoadInstanceRecord func() // a field name, not a use
|
||||
m *spxd.JetStreamManager
|
||||
}
|
||||
|
||||
func (h holder) WriteRunningSet() {} // a declaration, not a use
|
||||
|
||||
func use(h holder, raw []byte, jsManager *spxd.JetStreamManager, other interface{ LoadState() }) {
|
||||
_ = spxd.InstanceStateBucket
|
||||
_ = spxd.LocalStatePath("")
|
||||
var r vm.InstanceRecord
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
_ = vm.VMFromRecord(&r)
|
||||
_ = (&vm.VM{}).Record()
|
||||
_, _ = h.m.LoadInstanceRecord("i-1")
|
||||
_, _, _ = jsManager.LoadState("node")
|
||||
other.LoadState()
|
||||
_ = "spinifex-instance-state"
|
||||
cluster, _ := spxd.NewJetStreamManager(nil)
|
||||
_ = cluster.InitClusterStateBucket()
|
||||
_ = cluster.WriteRunningSet("node", nil)
|
||||
}
|
||||
`
|
||||
|
||||
func parseFault(t *testing.T, rel, pkg string) scan {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, rel, fmt.Sprintf(faultSource, pkg), parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := scan{}
|
||||
scanFile(fset, rel, f, out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestADR0007_S6_RatchetDetectsFaults injects a new consumer, a stale entry
|
||||
// and a malformed entry, and checks the matcher's declaration exclusions.
|
||||
func TestADR0007_S6_RatchetDetectsFaults(t *testing.T) {
|
||||
const rel = "spinifex/domains/widget/w.go"
|
||||
s := parseFault(t, rel, "widget")
|
||||
|
||||
want := []string{
|
||||
"daemon.InstanceStateBucket", "daemon.LocalStatePath",
|
||||
"vm.InstanceRecord", "vm.VMFromRecord", "VM.Record()",
|
||||
"JetStreamManager.LoadInstanceRecord", "JetStreamManager.LoadState",
|
||||
"JetStreamManager.WriteRunningSet",
|
||||
`literal "spinifex-instance-state"`,
|
||||
}
|
||||
for _, sym := range want {
|
||||
if _, ok := s[use{File: rel, Symbol: sym}]; !ok {
|
||||
t.Errorf("injected use of %s was not detected; got %v", sym, s.sorted())
|
||||
}
|
||||
}
|
||||
if len(s) != len(want) {
|
||||
t.Errorf("want exactly %d uses (the manager type, its constructor, cluster-state methods, field names, method declarations and an unrelated LoadState excluded), got %v",
|
||||
len(want), s.sorted())
|
||||
}
|
||||
|
||||
stale := allowance{File: "spinifex/domains/gone/g.go", Symbol: "vm.InstanceRecord", Inv: "INV-00"}
|
||||
recorded := allowance{File: rel, Symbol: "vm.InstanceRecord", Inv: "INV-00"}
|
||||
rep := check(s, []allowance{recorded, stale, {File: rel, Symbol: "vm.VMFromRecord"}, recorded})
|
||||
if len(rep.unrecorded) != len(want)-2 {
|
||||
t.Errorf("want %d unrecorded uses, got %v", len(want)-2, rep.unrecorded)
|
||||
}
|
||||
if len(rep.stale) != 1 || rep.stale[0] != stale {
|
||||
t.Errorf("want stale entry %+v, got %+v", stale, rep.stale)
|
||||
}
|
||||
if len(rep.malformed) != 2 {
|
||||
t.Errorf("want a missing-inventory and a duplicate entry reported, got %v", rep.malformed)
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the owning packages the symbols are bare identifiers, and the scan
|
||||
// must see those too.
|
||||
func TestADR0007_S6_RatchetSeesBareIdentifiersInOwnerPackages(t *testing.T) {
|
||||
src := map[string]string{
|
||||
"spinifex/daemon/new_consumer.go": "package daemon\n\nfunc f() string { return InstanceStateBucket + LocalStatePath(\"\") }\n",
|
||||
"spinifex/runtime/compute/vm/new_reader.go": "package vm\n\nfunc g(r *InstanceRecord) *VM { return VMFromRecord(r) }\n",
|
||||
}
|
||||
want := map[string][]string{
|
||||
"spinifex/daemon/new_consumer.go": {"daemon.InstanceStateBucket", "daemon.LocalStatePath"},
|
||||
"spinifex/runtime/compute/vm/new_reader.go": {"vm.InstanceRecord", "vm.VMFromRecord"},
|
||||
}
|
||||
for rel, code := range src {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, rel, code, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := scan{}
|
||||
scanFile(fset, rel, f, out)
|
||||
for _, sym := range want[rel] {
|
||||
if _, ok := out[use{File: rel, Symbol: sym}]; !ok {
|
||||
t.Errorf("%s: bare use of %s not detected; got %v", rel, sym, out.sorted())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Package instancestate freezes the production consumers of the daemon's raw
|
||||
// instance-state persistence, so ADR-0007 S6 and S7 start from a known set
|
||||
// that can only shrink.
|
||||
package instancestate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/build/constraint"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
daemonPath = "github.com/mulgadc/spinifex/spinifex/daemon"
|
||||
vmPath = "github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
daemonDir = "spinifex/daemon"
|
||||
vmDir = "spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
// daemonSymbols are the daemon's instance-state bucket, key and local-file
|
||||
// identifiers. The JetStreamManager type and constructor are not listed: they
|
||||
// also serve cluster state, so only the instance-state methods below count.
|
||||
var daemonSymbols = set(
|
||||
"InstanceStateBucket", "InstanceStateBucketVersion",
|
||||
"TerminatedInstanceBucket", "TerminatedInstanceBucketVersion",
|
||||
"InstanceRecordPrefix", "InstanceStatePrefix", "NodePresencePrefix",
|
||||
"StoppedInstancePrefix", "TerminatedInstancePrefix",
|
||||
"LocalState", "LocalStateSchemaVersion", "LocalStatePath", "ReadLocalState",
|
||||
"MarshalLocalState", "WriteLocalStateBytes", "LocalStateFileName",
|
||||
"LocalStateFileMode", "DefaultLocalStateDir",
|
||||
)
|
||||
|
||||
// vmSymbols are the raw persisted record and its conversion.
|
||||
var vmSymbols = set("InstanceRecord", "InstanceSpec", "InstanceStatus", "VMFromRecord")
|
||||
|
||||
// jsmMethods are JetStreamManager's instance-state methods. Matched by name, so
|
||||
// a call through an interface that mirrors them counts as a consumer too.
|
||||
var jsmMethods = set(
|
||||
"WriteInstanceRecord", "LoadInstanceRecord", "UpdateInstanceRecord",
|
||||
"DeleteInstanceRecord", "ListInstanceRecords",
|
||||
"WriteTerminatedInstanceRecord", "LoadTerminatedInstanceRecord",
|
||||
"UpdateTerminatedInstanceRecord", "DeleteTerminatedInstanceRecord",
|
||||
"ListTerminatedInstanceRecords",
|
||||
"WriteNodeMarker", "WriteNodeMarkerBestEffort", "WriteRunningSet",
|
||||
"WriteStoppedInstance", "LoadStoppedInstance", "DeleteStoppedInstance",
|
||||
"ClaimStoppedInstance", "ClaimRecoverableInstance", "ReleaseRecoveredInstance",
|
||||
"AbandonRecovery", "UpdateStoppedInstance", "ListStoppedInstances",
|
||||
"WriteTerminatedInstance", "UpdateTerminatedInstance", "ListTerminatedInstances",
|
||||
"DeleteTerminatedInstance", "LoadTerminatedInstance",
|
||||
"InitKVBucket", "InitTerminatedInstanceBucket",
|
||||
)
|
||||
|
||||
// ambiguousJSMMethods share a name with unrelated methods, so they count only
|
||||
// when called on a field or variable named like a JetStreamManager handle.
|
||||
var ambiguousJSMMethods = set("LoadState", "DeleteState")
|
||||
|
||||
// literals duplicate a bucket or file name instead of naming the owner's symbol.
|
||||
var literals = set(`"spinifex-instance-state"`, `"spinifex-terminated-instances"`, `"instance-state.json"`)
|
||||
|
||||
// ownerFiles define the symbols above. They are the implementation being
|
||||
// migrated, not consumers of it, and are listed so a deleted one is noticed.
|
||||
var ownerFiles = []string{
|
||||
"spinifex/daemon/instance_membership.go",
|
||||
"spinifex/daemon/instance_records.go",
|
||||
"spinifex/daemon/instance_records_migrate.go",
|
||||
"spinifex/daemon/instance_running_set.go",
|
||||
"spinifex/daemon/jetstream.go",
|
||||
"spinifex/daemon/local_state.go",
|
||||
"spinifex/runtime/compute/vm/record.go",
|
||||
}
|
||||
|
||||
func set(names ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// use is one consumer of one symbol, keyed by file so a new file is a new use.
|
||||
type use struct {
|
||||
File, Symbol string
|
||||
}
|
||||
|
||||
func (u use) String() string { return u.File + " uses " + u.Symbol }
|
||||
|
||||
// scan maps each use to the first file:line it appears at.
|
||||
type scan map[use]string
|
||||
|
||||
func moduleRoot() (string, error) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", errors.New("cannot locate test source")
|
||||
}
|
||||
return filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
}
|
||||
|
||||
var excludedTopDirs = map[string]bool{"tests": true, "docs": true, "architecture": true}
|
||||
|
||||
// scanModule parses every non-test production Go file under root.
|
||||
func scanModule(root string) (scan, int, error) {
|
||||
out := scan{}
|
||||
files := 0
|
||||
fset := token.NewFileSet()
|
||||
owners := set(ownerFiles...)
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") ||
|
||||
name == "testdata" || name == "vendor" || name == "node_modules" || excludedTopDirs[rel] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := parser.ParseFile(fset, path, src, parser.ParseComments|parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ignoredByBuildTag(f.Comments, f.Package) {
|
||||
return nil
|
||||
}
|
||||
files++
|
||||
if owners[rel] {
|
||||
return nil
|
||||
}
|
||||
scanFile(fset, rel, f, out)
|
||||
return nil
|
||||
})
|
||||
return out, files, err
|
||||
}
|
||||
|
||||
// scanFile records every use in f, which lives at module-relative path rel.
|
||||
func scanFile(fset *token.FileSet, rel string, f *ast.File, out scan) {
|
||||
dir := filepath.ToSlash(filepath.Dir(rel))
|
||||
daemonName, vmName := importName(f, daemonPath, "daemon"), importName(f, vmPath, "vm")
|
||||
inDaemon, inVM := dir == daemonDir, dir == vmDir
|
||||
seesVM := inDaemon || inVM || daemonName != "" || vmName != ""
|
||||
|
||||
record := func(symbol string, pos token.Pos) {
|
||||
u := use{File: rel, Symbol: symbol}
|
||||
if _, ok := out[u]; !ok {
|
||||
out[u] = fmt.Sprintf("%s:%d", rel, fset.Position(pos).Line)
|
||||
}
|
||||
}
|
||||
|
||||
var visit func(n ast.Node) bool
|
||||
visit = func(n ast.Node) bool {
|
||||
switch n := n.(type) {
|
||||
case *ast.ImportSpec:
|
||||
return false
|
||||
case *ast.BasicLit:
|
||||
if n.Kind == token.STRING && literals[n.Value] {
|
||||
record("literal "+n.Value, n.Pos())
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
if sel, ok := n.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Record" && len(n.Args) == 0 && seesVM {
|
||||
record("VM.Record()", sel.Sel.Pos())
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
if x, ok := n.X.(*ast.Ident); ok {
|
||||
switch {
|
||||
case daemonName != "" && x.Name == daemonName && daemonSymbols[n.Sel.Name]:
|
||||
record("daemon."+n.Sel.Name, n.Sel.Pos())
|
||||
case vmName != "" && x.Name == vmName && vmSymbols[n.Sel.Name]:
|
||||
record("vm."+n.Sel.Name, n.Sel.Pos())
|
||||
}
|
||||
}
|
||||
if jsmMethods[n.Sel.Name] || (ambiguousJSMMethods[n.Sel.Name] && jsmReceivers[receiverName(n.X)]) {
|
||||
record("JetStreamManager."+n.Sel.Name, n.Sel.Pos())
|
||||
}
|
||||
// The selected name is a field or method, never a bare package symbol.
|
||||
ast.Inspect(n.X, visit)
|
||||
return false
|
||||
case *ast.FuncDecl:
|
||||
// A method declared with one of these names is an implementation,
|
||||
// not a use; its body is still scanned.
|
||||
if n.Recv != nil {
|
||||
ast.Inspect(n.Recv, visit)
|
||||
}
|
||||
if n.Body != nil {
|
||||
ast.Inspect(n.Body, visit)
|
||||
}
|
||||
if n.Type != nil {
|
||||
ast.Inspect(n.Type, visit)
|
||||
}
|
||||
return false
|
||||
case *ast.Field:
|
||||
// Interface method and struct field names are declarations.
|
||||
if n.Type != nil {
|
||||
ast.Inspect(n.Type, visit)
|
||||
}
|
||||
if n.Tag != nil {
|
||||
ast.Inspect(n.Tag, visit)
|
||||
}
|
||||
return false
|
||||
case *ast.Ident:
|
||||
if inDaemon && daemonSymbols[n.Name] {
|
||||
record("daemon."+n.Name, n.Pos())
|
||||
}
|
||||
if inVM && vmSymbols[n.Name] {
|
||||
record("vm."+n.Name, n.Pos())
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
ast.Inspect(f, visit)
|
||||
}
|
||||
|
||||
var jsmReceivers = set("jsManager", "jsm", "js")
|
||||
|
||||
// receiverName is the last name in x: "js" for both js and a.js.
|
||||
func receiverName(x ast.Expr) string {
|
||||
switch x := x.(type) {
|
||||
case *ast.Ident:
|
||||
return x.Name
|
||||
case *ast.SelectorExpr:
|
||||
return x.Sel.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// importName returns the name f refers to path by, or "" if f does not import it.
|
||||
func importName(f *ast.File, path, def string) string {
|
||||
for _, imp := range f.Imports {
|
||||
p, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil || p != path {
|
||||
continue
|
||||
}
|
||||
if imp.Name != nil {
|
||||
if imp.Name.Name == "_" || imp.Name.Name == "." {
|
||||
return ""
|
||||
}
|
||||
return imp.Name.Name
|
||||
}
|
||||
return def
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ignoredByBuildTag reports a `//go:build ignore` file, which is a standalone
|
||||
// program rather than part of its directory's package.
|
||||
func ignoredByBuildTag(groups []*ast.CommentGroup, pkgPos token.Pos) bool {
|
||||
for _, cg := range groups {
|
||||
if cg.Pos() >= pkgPos {
|
||||
break
|
||||
}
|
||||
for _, c := range cg.List {
|
||||
if !constraint.IsGoBuild(c.Text) {
|
||||
continue
|
||||
}
|
||||
expr, err := constraint.Parse(c.Text)
|
||||
if err == nil && expr.String() == "ignore" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s scan) sorted() []use {
|
||||
out := make([]use, 0, len(s))
|
||||
for u := range s {
|
||||
out = append(out, u)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
return out[i].Symbol < out[j].Symbol
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package layering
|
||||
|
||||
// allowance is one recorded forbidden import, allowed until its debt clears.
|
||||
type allowance struct {
|
||||
From, To string
|
||||
Debt string // subject of the migration record's debt entry or close-out row
|
||||
Reason string
|
||||
}
|
||||
|
||||
// allowlist mirrors the dependency-rule debt and close-out table in
|
||||
// docs/PACKAGE_BOUNDARY_MIGRATION.md. It only shrinks: add an edge only with
|
||||
// recorded debt, and delete an entry as soon as its import is gone.
|
||||
var allowlist = []allowance{
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/eip", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/igw", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/image", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/key", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/launchtemplate", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/placementgroup", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "accountteardown", 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: "handlers/ecs", Debt: "agents/ecs/agent", Reason: "assignment/state wire types from the ECS implementation; needs a guest/controller contract"},
|
||||
|
||||
{From: "agents/eks/tokenwebhook", To: "handlers/eks", Debt: "agents/eks/tokenwebhook", Reason: "WebhookTokenReviewResult from the EKS implementation; needs an EKS contract or projection"},
|
||||
|
||||
{From: "agents/rds/agent", To: "handlers/rds", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
|
||||
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
|
||||
{From: "daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "domains/acm", To: "handlers/iam", Debt: "ACM: the IAM AES-GCM helper import (ACM relocation residual)", Reason: "IAM AES-GCM helpers; needs a shared crypto boundary"},
|
||||
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/eip", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/instance", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "vcpu.go counts instances through the instance adapter; needs an EC2 reader capability"},
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/volume", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/vpc", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "domains/ec2/instancetypes", Debt: "domains/admission/quota → domains/ec2/instancetypes", Reason: "DefaultVCPUs sizing; needs a consumer-owned capability or EC2 projection"},
|
||||
{From: "domains/admission/quota", To: "gateway/elbv2", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "live ELBv2 counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "handlers/rds", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "live RDS counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "runtime/compute/vm", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "instance-record vCPU sweep; needs a reader capability"},
|
||||
|
||||
{From: "domains/dns", To: "domains/network/reconcile", Debt: "domains/dns", Reason: "takes the reconcile lock from network's implementation"},
|
||||
{From: "domains/dns", To: "runtime/compute/vm", Debt: "domains/dns", Reason: "domain-to-runtime import of VM state"},
|
||||
|
||||
{From: "domains/ec2/awsapi/instance", To: "handlers/iam", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "IAM edge needs an IAM capability"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "handlers/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
|
||||
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "handlers/iam", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "handlers/sts", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "runtime/compute/vm", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
|
||||
{From: "domains/ec2/instance", To: "domains/dns", Debt: "domains/ec2/instance", Reason: "cross-domain implementation import of DNS"},
|
||||
{From: "domains/ec2/instance", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
{From: "domains/ec2/instance", To: "runtime/compute/gpu", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
|
||||
{From: "domains/ec2/instance", To: "runtime/compute/vm", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
|
||||
{From: "domains/ec2/instance/state", To: "daemon", Debt: "domains/ec2/instance/state (dependency, IMDS readers)", Reason: "wraps the daemon's local-file reader and JetStreamManager bucket open unchanged for vpcd's IMDS lookup"},
|
||||
{From: "domains/ec2/instance/state", To: "runtime/compute/vm", Debt: "domains/ec2/instance/state (dependency)", Reason: "the vpcd IMDS local reader returns vm.VM"},
|
||||
|
||||
{From: "domains/ec2/volume", To: "runtime/compute/vm", Debt: "Domain → runtime", Reason: "VM runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
{From: "domains/ec2/vpc", To: "domains/network/external", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
{From: "domains/ec2/vpc", To: "domains/network/external/dhcp", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
{From: "domains/ec2/vpc", To: "domains/network/identifiers", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
{From: "domains/ec2/vpc", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ecr/auth", To: "handlers/iam", Debt: "Cross-domain", Reason: "IAM implementation import; needs an IAM capability"},
|
||||
|
||||
{From: "domains/network/host", To: "runtime/compute/vm", Debt: "domains/network", Reason: "domain-to-runtime import"},
|
||||
{From: "domains/network/host", To: "runtime/host/command", Debt: "domains/network", Reason: "domain-to-runtime import"},
|
||||
|
||||
{From: "domains/network/reconcile", To: "domains/ec2/eip", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
|
||||
{From: "domains/network/reconcile", To: "domains/ec2/igw", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
|
||||
{From: "domains/network/reconcile", To: "domains/ec2/natgw", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
|
||||
{From: "domains/network/reconcile", To: "domains/ec2/routetable", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
|
||||
{From: "domains/network/reconcile", To: "domains/ec2/vpc", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
|
||||
{From: "domains/network/reconcile", To: "runtime/compute/vm", Debt: "domains/network", Reason: "domain-to-runtime import"},
|
||||
|
||||
{From: "domains/ochre", To: "domains/ec2/systeminstance", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "domains/network/systemvpc", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "gateway/bedrock", Debt: "gateway/bedrock", Reason: "Ochre domain core still lives in gateway/bedrock; needs the recorded split"},
|
||||
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
{From: "domains/ochre/vector", To: "handlers/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
|
||||
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
|
||||
{From: "gateway/bedrock", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
|
||||
|
||||
{From: "gateway/rds", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "gateway/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
|
||||
|
||||
{From: "handlers/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "handlers/elbv2", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "handlers/iam", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
|
||||
{From: "handlers/eks", To: "domains/dns", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/placementgroup", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/network/systemvpc", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "handlers/iam", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "runtime/compute/vm", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "handlers/elbv2", To: "domains/acm", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/dns", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/ec2/systeminstance", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/ec2/vpc", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/network/topology", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "system-instance role ensurer; needs an IAM capability"},
|
||||
{From: "handlers/elbv2", To: "lbagent", Debt: "handlers/elbv2/{agent_types,haproxy,health_checker,nginx}.go import lbagent", Reason: "health-report and cert/PID-path vocabulary; needs a guest/controller contract"},
|
||||
|
||||
{From: "handlers/rds", To: "domains/dns", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/ec2/instance", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/network/systemvpc", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "handlers/iam", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "handlers/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
|
||||
{From: "operator/cli", To: "accountteardown", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "daemon", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "gateway/bedrock", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "handlers/eks", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "handlers/iam", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package layering enforces ADR-0001's package-boundary dependency rules over
|
||||
// the module's production import graph, ratcheted by a recorded-debt allowlist.
|
||||
package layering
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/build/constraint"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const modulePath = "github.com/mulgadc/spinifex"
|
||||
|
||||
// edge is one package-to-package import, both sides named as in the allowlist:
|
||||
// application packages relative to spinifex/, module-root ones (cmd/,
|
||||
// contracts/, internal/) relative to the module root.
|
||||
type edge struct {
|
||||
From, To string
|
||||
}
|
||||
|
||||
func (e edge) String() string { return e.From + " -> " + e.To }
|
||||
|
||||
// graph is the production import graph restricted to this module.
|
||||
type graph struct {
|
||||
// pkgs maps a package name to its module-relative directory.
|
||||
pkgs map[string]string
|
||||
// imports records the first file:line at which each edge appears.
|
||||
imports map[edge]string
|
||||
}
|
||||
|
||||
func newGraph() *graph {
|
||||
return &graph{pkgs: map[string]string{}, imports: map[edge]string{}}
|
||||
}
|
||||
|
||||
// shortName drops the application-root prefix so names read like the ADR and
|
||||
// the migration record; classify rejects app-root dirs that would collide.
|
||||
func shortName(full string) string {
|
||||
if rest, ok := strings.CutPrefix(full, "spinifex/"); ok {
|
||||
return rest
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
func moduleRoot() (string, error) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", errors.New("cannot locate test source")
|
||||
}
|
||||
// architecture/layering/graph_test.go -> module root.
|
||||
return filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
}
|
||||
|
||||
// excludedTopDirs hold no production code: tests/ is test-only, docs/ only
|
||||
// embeds documentation and architecture/ is repository governance tooling.
|
||||
var excludedTopDirs = map[string]bool{"tests": true, "docs": true, "architecture": true}
|
||||
|
||||
// loadGraph parses the imports of every non-test Go file under root, so it
|
||||
// sees every build-tag variant and needs no Go toolchain or workspace.
|
||||
func loadGraph(root string) (*graph, error) {
|
||||
g := newGraph()
|
||||
fset := token.NewFileSet()
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") ||
|
||||
name == "testdata" || name == "vendor" || name == "node_modules" || excludedTopDirs[rel] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.ParseComments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ignoredByBuildTag(f.Comments, f.Package) {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.ToSlash(filepath.Dir(rel))
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
from := shortName(dir)
|
||||
g.pkgs[from] = dir
|
||||
for _, imp := range f.Imports {
|
||||
p, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, ok := strings.CutPrefix(p, modulePath+"/")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e := edge{From: from, To: shortName(target)}
|
||||
if _, seen := g.imports[e]; !seen {
|
||||
pos := fset.Position(imp.Pos())
|
||||
g.imports[e] = fmt.Sprintf("%s:%d", rel, pos.Line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return g, err
|
||||
}
|
||||
|
||||
// ignoredByBuildTag reports a `//go:build ignore` file, which is a standalone
|
||||
// program (a generator) rather than part of its directory's package.
|
||||
func ignoredByBuildTag(groups []*ast.CommentGroup, pkgPos token.Pos) bool {
|
||||
for _, cg := range groups {
|
||||
if cg.Pos() >= pkgPos {
|
||||
break
|
||||
}
|
||||
for _, c := range cg.List {
|
||||
if !constraint.IsGoBuild(c.Text) {
|
||||
continue
|
||||
}
|
||||
expr, err := constraint.Parse(c.Text)
|
||||
if err == nil && expr.String() == "ignore" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sortedEdges returns the graph's edges in a stable order.
|
||||
func (g *graph) sortedEdges() []edge {
|
||||
out := make([]edge, 0, len(g.imports))
|
||||
for e := range g.imports {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].From != out[j].From {
|
||||
return out[i].From < out[j].From
|
||||
}
|
||||
return out[i].To < out[j].To
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package layering
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type violation struct {
|
||||
rule string
|
||||
edge edge
|
||||
pos string
|
||||
}
|
||||
|
||||
type report struct {
|
||||
unmapped []string
|
||||
violations []violation // every rule hit, allowlisted or not
|
||||
unrecorded []violation // hits whose edge has no allowlist entry
|
||||
stale []allowance // entries whose edge no rule rejects any more
|
||||
malformed []string
|
||||
}
|
||||
|
||||
// check classifies g, applies every rule and compares the result with allow.
|
||||
func check(g *graph, allow []allowance) report {
|
||||
var rep report
|
||||
classes := map[string]class{}
|
||||
for name, dir := range g.pkgs {
|
||||
c, ok := classify(dir)
|
||||
if !ok || c.layer == layerTests {
|
||||
rep.unmapped = append(rep.unmapped, fmt.Sprintf("%s (directory %s)", name, dir))
|
||||
continue
|
||||
}
|
||||
classes[name] = c
|
||||
}
|
||||
|
||||
allowed := map[edge]bool{}
|
||||
for _, a := range allow {
|
||||
e := edge{From: a.From, To: a.To}
|
||||
switch {
|
||||
case allowed[e]:
|
||||
rep.malformed = append(rep.malformed, "duplicate entry "+e.String())
|
||||
case a.Debt == "" || a.Reason == "":
|
||||
rep.malformed = append(rep.malformed, "entry "+e.String()+" needs both Debt and Reason")
|
||||
}
|
||||
allowed[e] = true
|
||||
}
|
||||
|
||||
hit := map[edge]bool{}
|
||||
for _, e := range g.sortedEdges() {
|
||||
from, ok := classes[e.From]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
to, ok := classify(fullPath(e.To))
|
||||
if !ok {
|
||||
rep.unmapped = append(rep.unmapped, fmt.Sprintf("%s (imported by %s at %s)", e.To, e.From, g.imports[e]))
|
||||
continue
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !r.subject(from, e.From) || !r.forbids(from, to, e.From) {
|
||||
continue
|
||||
}
|
||||
v := violation{rule: r.id, edge: e, pos: g.imports[e]}
|
||||
rep.violations = append(rep.violations, v)
|
||||
hit[e] = true
|
||||
if !allowed[e] {
|
||||
rep.unrecorded = append(rep.unrecorded, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range allow {
|
||||
if !hit[edge{From: a.From, To: a.To}] {
|
||||
rep.stale = append(rep.stale, a)
|
||||
}
|
||||
}
|
||||
sort.Strings(rep.unmapped)
|
||||
return rep
|
||||
}
|
||||
|
||||
func ruleByID(id string) rule {
|
||||
for _, r := range rules {
|
||||
if r.id == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
panic("unknown rule " + id)
|
||||
}
|
||||
|
||||
// TestADR0001_Layering is the blocking ratchet over the real import graph.
|
||||
func TestADR0001_Layering(t *testing.T) {
|
||||
root, err := moduleRoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := loadGraph(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Guards against a walk that silently finds nothing and passes vacuously.
|
||||
if len(g.pkgs) < 100 || len(g.imports) < 500 {
|
||||
t.Fatalf("scanned %d packages and %d module imports under %s; the walk has lost the tree and every rule would pass vacuously",
|
||||
len(g.pkgs), len(g.imports), root)
|
||||
}
|
||||
rep := check(g, allowlist)
|
||||
|
||||
t.Run("Classification_EveryPackageMapped", func(t *testing.T) {
|
||||
for _, u := range rep.unmapped {
|
||||
t.Errorf("ADR-0001 current-to-target map: package %s has no layer; place it under an ADR-0001 home or classify it deliberately in layers_test.go", u)
|
||||
}
|
||||
})
|
||||
|
||||
for _, r := range rules {
|
||||
t.Run(r.id, func(t *testing.T) {
|
||||
subjects := 0
|
||||
for name, dir := range g.pkgs {
|
||||
if c, ok := classify(dir); ok && r.subject(c, name) {
|
||||
subjects++
|
||||
}
|
||||
}
|
||||
if subjects == 0 {
|
||||
t.Fatalf("no scanned package is governed by %s; the rule would pass vacuously", r.id)
|
||||
}
|
||||
var snippets []string
|
||||
for _, v := range rep.unrecorded {
|
||||
if v.rule != r.id {
|
||||
continue
|
||||
}
|
||||
t.Errorf("ADR-0001 %s: %s imports %s (%s); %s",
|
||||
r.clause, v.edge.From, v.edge.To, v.pos, r.hint)
|
||||
snippets = append(snippets, fmt.Sprintf("{From: %q, To: %q, Debt: \"<migration-record debt subject>\", Reason: \"<why>\"},", v.edge.From, v.edge.To))
|
||||
}
|
||||
if len(snippets) > 0 {
|
||||
t.Logf("fix the import; only debt already recorded in docs/PACKAGE_BOUNDARY_MIGRATION.md may be allow-listed:\n%s",
|
||||
strings.Join(snippets, "\n"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Allowlist_NoStaleEntries", func(t *testing.T) {
|
||||
for _, a := range rep.stale {
|
||||
t.Errorf("allowlist entry %s -> %s (%s) no longer matches a forbidden import; the debt is paid, so delete the entry from allowlist_test.go",
|
||||
a.From, a.To, a.Debt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Allowlist_WellFormed", func(t *testing.T) {
|
||||
for _, m := range rep.malformed {
|
||||
t.Errorf("allowlist_test.go: %s", m)
|
||||
}
|
||||
t.Logf("%d allow-listed edges", len(allowlist))
|
||||
})
|
||||
}
|
||||
|
||||
// TestADR0001_LayeringCheckerDetectsFaults injects a forbidden edge, a stale
|
||||
// allowlist entry and an unmapped package into a synthetic graph.
|
||||
func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
|
||||
g := newGraph()
|
||||
for _, dir := range []string{
|
||||
"spinifex/foundation/widget", "spinifex/domains/alpha/thing",
|
||||
"spinifex/domains/beta/store", "spinifex/misc",
|
||||
} {
|
||||
g.pkgs[shortName(dir)] = dir
|
||||
}
|
||||
forbidden := edge{From: "foundation/widget", To: "domains/alpha/thing"}
|
||||
g.imports[forbidden] = "spinifex/foundation/widget/w.go:7"
|
||||
allowedEdge := edge{From: "domains/alpha/thing", To: "domains/beta/store"}
|
||||
g.imports[allowedEdge] = "spinifex/domains/alpha/thing/t.go:9"
|
||||
|
||||
staleEntry := allowance{From: "domains/beta/store", To: "domains/alpha/thing", Debt: "synthetic", Reason: "stale"}
|
||||
rep := check(g, []allowance{
|
||||
{From: allowedEdge.From, To: allowedEdge.To, Debt: "synthetic", Reason: "recorded"},
|
||||
staleEntry,
|
||||
})
|
||||
|
||||
if len(rep.unrecorded) != 1 || rep.unrecorded[0].edge != forbidden ||
|
||||
rep.unrecorded[0].rule != ruleByID("R1_FoundationImportsOnlyFoundation").id {
|
||||
t.Errorf("want only %s reported under R1, got %+v", forbidden, rep.unrecorded)
|
||||
}
|
||||
if len(rep.stale) != 1 || rep.stale[0] != staleEntry {
|
||||
t.Errorf("want stale entry %+v, got %+v", staleEntry, rep.stale)
|
||||
}
|
||||
if len(rep.unmapped) != 1 || !strings.HasPrefix(rep.unmapped[0], "misc ") {
|
||||
t.Errorf("want unmapped package misc, got %v", rep.unmapped)
|
||||
}
|
||||
var allowedHit bool
|
||||
for _, v := range rep.violations {
|
||||
allowedHit = allowedHit || v.edge == allowedEdge
|
||||
}
|
||||
if !allowedHit {
|
||||
t.Errorf("allow-listed cross-domain edge %s was not detected by R3, so the allowlist entry would read as stale", allowedEdge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package layering
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type layer string
|
||||
|
||||
const (
|
||||
layerFoundation layer = "foundation"
|
||||
layerContracts layer = "contracts"
|
||||
layerDomain layer = "domains"
|
||||
layerIngress layer = "ingress"
|
||||
layerRuntime layer = "runtime"
|
||||
layerOperator layer = "operator"
|
||||
layerAgents layer = "agents"
|
||||
layerProviders layer = "providers"
|
||||
layerBootstrap layer = "bootstrap"
|
||||
layerCmd layer = "cmd"
|
||||
layerInternal layer = "internal"
|
||||
layerTestkit layer = "testkit"
|
||||
layerLegacy layer = "legacy"
|
||||
// layerTests is valid only as an import target; tests/ is never scanned.
|
||||
layerTests layer = "tests"
|
||||
)
|
||||
|
||||
// domainKind distinguishes the parts of a domain the rules treat differently.
|
||||
type domainKind int
|
||||
|
||||
const (
|
||||
notDomain domainKind = iota
|
||||
domainImpl // domains/<d>/... resource implementation
|
||||
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
|
||||
domainProj // domains/<d>/projection/... authorised view
|
||||
legacyImpl // handlers/<d>/... legacy domain implementation
|
||||
legacyAdapter // gateway/<d>/... legacy AWS action adapters
|
||||
)
|
||||
|
||||
type class struct {
|
||||
layer layer
|
||||
legacy string // legacy root name when layer is layerLegacy
|
||||
domain string // owning domain for domain-shaped and agent packages
|
||||
kind domainKind
|
||||
}
|
||||
|
||||
func (c class) domainShaped() bool { return c.kind != notDomain }
|
||||
|
||||
// isLegacy reports whether c sits under one of the named legacy roots.
|
||||
func (c class) isLegacy(roots ...string) bool {
|
||||
if c.layer != layerLegacy {
|
||||
return false
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(roots, c.legacy)
|
||||
}
|
||||
|
||||
// legacyRoots are pre-ADR-0001 application roots still standing.
|
||||
var legacyRoots = map[string]bool{
|
||||
"handlers": true, "gateway": true, "daemon": true,
|
||||
"accountteardown": true, "lbagent": true,
|
||||
}
|
||||
|
||||
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
|
||||
// Bedrock is the AWS surface of the Ochre domain.
|
||||
var gatewayDomains = map[string]string{
|
||||
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
|
||||
"rds": "rds", "sts": "sts",
|
||||
}
|
||||
|
||||
// runtimeLegacy and operatorLegacy are the legacy roots whose ADR-0001 target
|
||||
// is runtime/ and operator/ respectively.
|
||||
var (
|
||||
runtimeLegacy = []string{"daemon"}
|
||||
operatorLegacy = []string{"accountteardown"}
|
||||
)
|
||||
|
||||
// classify maps a module-relative package directory to its layer. ok is false
|
||||
// for a package that has no deliberate classification.
|
||||
func classify(full string) (class, bool) {
|
||||
parts := strings.Split(full, "/")
|
||||
switch parts[0] {
|
||||
case "cmd":
|
||||
return class{layer: layerCmd}, len(parts) >= 2
|
||||
case "contracts":
|
||||
return class{layer: layerContracts}, len(parts) >= 2
|
||||
case "internal":
|
||||
if len(parts) < 2 {
|
||||
return class{}, false
|
||||
}
|
||||
if parts[1] == "testkit" || parts[1] == "tooling" {
|
||||
return class{layer: layerTestkit}, true
|
||||
}
|
||||
return class{layer: layerInternal}, true
|
||||
case "tests":
|
||||
return class{layer: layerTests}, true
|
||||
case "spinifex":
|
||||
return classifyApp(parts[1:])
|
||||
}
|
||||
return class{}, false
|
||||
}
|
||||
|
||||
func classifyApp(app []string) (class, bool) {
|
||||
if len(app) == 0 {
|
||||
return class{}, false
|
||||
}
|
||||
switch app[0] {
|
||||
case "foundation":
|
||||
return class{layer: layerFoundation}, true
|
||||
case "ingress":
|
||||
return class{layer: layerIngress}, true
|
||||
case "runtime":
|
||||
return class{layer: layerRuntime}, true
|
||||
case "operator":
|
||||
return class{layer: layerOperator}, true
|
||||
case "providers":
|
||||
return class{layer: layerProviders}, true
|
||||
case "bootstrap":
|
||||
return class{layer: layerBootstrap}, true
|
||||
case "agents":
|
||||
c := class{layer: layerAgents}
|
||||
if len(app) >= 2 {
|
||||
c.domain = app[1]
|
||||
}
|
||||
return c, true
|
||||
case "domains":
|
||||
if len(app) < 2 {
|
||||
return class{}, false
|
||||
}
|
||||
c := class{layer: layerDomain, domain: app[1], kind: domainImpl}
|
||||
if len(app) >= 3 {
|
||||
switch app[2] {
|
||||
case "awsapi":
|
||||
c.kind = domainAWSAPI
|
||||
case "projection":
|
||||
c.kind = domainProj
|
||||
}
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
if !legacyRoots[app[0]] {
|
||||
return class{}, false
|
||||
}
|
||||
c := class{layer: layerLegacy, legacy: app[0]}
|
||||
switch {
|
||||
case app[0] == "handlers" && len(app) >= 2:
|
||||
c.domain, c.kind = app[1], legacyImpl
|
||||
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
|
||||
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// fullPath reverses shortName for classification.
|
||||
func fullPath(name string) string {
|
||||
top, _, _ := strings.Cut(name, "/")
|
||||
switch top {
|
||||
case "cmd", "contracts", "internal", "tests", "docs":
|
||||
return name
|
||||
}
|
||||
return "spinifex/" + name
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package layering
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// frozenLegacyPackages is every production package still under a legacy root.
|
||||
// It only shrinks: a new entry is a new legacy package, a missing one is a
|
||||
// completed move whose line must be deleted.
|
||||
var frozenLegacyPackages = []string{
|
||||
"accountteardown",
|
||||
"daemon",
|
||||
"gateway",
|
||||
"gateway/bedrock",
|
||||
"gateway/ecs",
|
||||
"gateway/eks",
|
||||
"gateway/elbv2",
|
||||
"gateway/iam",
|
||||
"gateway/rds",
|
||||
"gateway/spx",
|
||||
"gateway/sts",
|
||||
"handlers/ecs",
|
||||
"handlers/eks",
|
||||
"handlers/elbv2",
|
||||
"handlers/iam",
|
||||
"handlers/iam/mock",
|
||||
"handlers/rds",
|
||||
"handlers/sts",
|
||||
"lbagent",
|
||||
}
|
||||
|
||||
// retiredLegacyRoots were legacy roots whose every package has moved to its
|
||||
// ADR-0001 target; recreating one would reopen a closed migration row.
|
||||
var retiredLegacyRoots = []string{"admin", "services", "utils", "vpcd"}
|
||||
|
||||
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
|
||||
// root a destination, so none may gain a package and no retired root may return.
|
||||
func TestADR0001_LegacyRootsFrozen(t *testing.T) {
|
||||
root, err := moduleRoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := loadGraph(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
present := map[string]bool{}
|
||||
for name, dir := range g.pkgs {
|
||||
if c, ok := classify(dir); ok && c.layer == layerLegacy {
|
||||
present[name] = true
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
t.Fatal("found no legacy packages; the freeze would pass vacuously")
|
||||
}
|
||||
frozen := map[string]bool{}
|
||||
for _, p := range frozenLegacyPackages {
|
||||
frozen[p] = true
|
||||
if !present[p] {
|
||||
t.Errorf("legacy package %s no longer exists; delete it from frozenLegacyPackages", p)
|
||||
}
|
||||
}
|
||||
for p := range present {
|
||||
if !frozen[p] {
|
||||
t.Errorf("ADR-0001 current-to-target map: %s is a new package under a legacy root; create it at its ADR-0001 target home instead", p)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range retiredLegacyRoots {
|
||||
entries, err := os.ReadDir(filepath.Join(root, "spinifex", r))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
t.Errorf("ADR-0001 current-to-target map: spinifex/%s is a retired legacy root; give the code its target home instead", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package layering
|
||||
|
||||
import "slices"
|
||||
|
||||
// rule is one ADR-0001 dependency rule over a classified import edge.
|
||||
type rule struct {
|
||||
id string // subtest name, led by the ADR-0001 clause it encodes
|
||||
clause string // the ADR text the rule enforces, quoted in failures
|
||||
hint string // one-line fix direction
|
||||
// subject reports whether an importer is governed by the rule; the
|
||||
// non-empty guard requires at least one governed package per rule.
|
||||
subject func(from class, fromName string) bool
|
||||
// forbids reports whether the rule rejects the edge.
|
||||
forbids func(from, to class, fromName string) bool
|
||||
}
|
||||
|
||||
// awsgwRole is the composition root ADR-0002 lets wire domain registrations.
|
||||
const awsgwRole = "runtime/roles/awsgw"
|
||||
|
||||
// qualificationBinaries are the developer/qualification mains ADR-0001 lets
|
||||
// keep reusable source in internal/testkit or internal/tooling.
|
||||
var qualificationBinaries = map[string]bool{
|
||||
"cmd/aws-model-coverage": true, "cmd/dhcptest": true, "cmd/spx-loadgen": true,
|
||||
}
|
||||
|
||||
func isLayer(c class, layers ...layer) bool {
|
||||
return slices.Contains(layers, c.layer)
|
||||
}
|
||||
|
||||
// importsDomainImplementation is any domain-owned package other than a named
|
||||
// projection, in its target or legacy home.
|
||||
func importsDomainImplementation(to class) bool {
|
||||
return to.domainShaped() && to.kind != domainProj
|
||||
}
|
||||
|
||||
var rules = []rule{
|
||||
{
|
||||
id: "R1_FoundationImportsOnlyFoundation",
|
||||
clause: "rule 1: foundation imports neither domains, ingress, runtime nor operator (enforced conservatively: foundation imports no module package above foundation)",
|
||||
hint: "move the dependency's needed primitive into foundation or invert it through a parameter",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerFoundation },
|
||||
forbids: func(_, t class, _ string) bool { return t.layer != layerFoundation },
|
||||
},
|
||||
{
|
||||
id: "R2_IngressImportsNoImplementation",
|
||||
clause: "rule 2: ingress/aws imports no domain implementation; domains supply their own action registration through the ingress registration contract",
|
||||
hint: "take the capability through the registration contract wired by runtime/roles/awsgw",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerIngress },
|
||||
forbids: func(_, t class, _ string) bool {
|
||||
return !isLayer(t, layerFoundation, layerContracts, layerIngress)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "R3_DomainImportsNoOtherDomainImplementation",
|
||||
clause: "rule 3: a domain does not read another domain's private records, migrations or repository; it uses a consumer-owned capability, a named projection, or a versioned contracts/<domain>/v1 contract",
|
||||
hint: "declare a narrow capability in the consumer, or import the owner's projection or contracts/<domain>/v1",
|
||||
subject: func(f class, _ string) bool { return f.domainShaped() },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
return f.domainShaped() && importsDomainImplementation(t) && t.domain != f.domain
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "R4_ProviderImportsNoDomainImplementation",
|
||||
clause: "rule 4: a provider adapter implements a provider contract and cannot acquire AWS-visible resource authority by reading a domain's private state",
|
||||
hint: "pass the needed values into the provider contract instead of importing the domain",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerProviders },
|
||||
forbids: func(_, t class, _ string) bool { return t.domainShaped() || t.isLegacy() },
|
||||
},
|
||||
{
|
||||
id: "R5_DomainImportsNoRuntimeOrOperator",
|
||||
clause: "rule 5: operator and runtime are composition consumers; they wire domain modules, so a domain does not import them",
|
||||
hint: "expose the runtime or operator state through a domain-owned projection or a capability wired at the composition root",
|
||||
subject: func(f class, _ string) bool { return f.domainShaped() },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
return f.domainShaped() && (isLayer(t, layerRuntime, layerOperator) ||
|
||||
t.isLegacy(runtimeLegacy...) || t.isLegacy(operatorLegacy...))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "R5_AWSAPIAdaptersImportedOnlyByOwnerOrComposition",
|
||||
clause: "rule 5: operator and runtime call public use cases; a domain's awsapi action adapters are owned by that domain and wired only by the composition root",
|
||||
hint: "call an owner-provided capability instead of the AWS action adapter",
|
||||
subject: func(_ class, _ string) bool { return true },
|
||||
forbids: func(f, t class, fromName string) bool {
|
||||
if t.kind != domainAWSAPI || fromName == awsgwRole {
|
||||
return false
|
||||
}
|
||||
return !f.domainShaped() || f.domain != t.domain
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "R5_OperatorImportsNoLegacyImplementation",
|
||||
clause: "rule 5 and the cmd/spinifex map row: operator use cases reach domains through public capabilities and do not read domain-private state",
|
||||
hint: "reach the domain through its public capability; do not import a legacy implementation root",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerOperator },
|
||||
forbids: func(f, t class, _ string) bool { return f.layer == layerOperator && t.isLegacy() },
|
||||
},
|
||||
{
|
||||
id: "R6_ProductionImportsNoTestkit",
|
||||
clause: "rule 6: test support is under internal/testkit; production packages do not import it",
|
||||
hint: "move the shared code to its production owner or keep the import in a _test.go file",
|
||||
subject: func(f class, n string) bool { return f.layer != layerTestkit && !qualificationBinaries[n] },
|
||||
forbids: func(f, t class, n string) bool {
|
||||
return f.layer != layerTestkit && !qualificationBinaries[n] && isLayer(t, layerTestkit, layerTests)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Contracts_ImportOnlyFoundationAndContracts",
|
||||
clause: "decision item 1 and the area table: contracts own cross-process vocabulary, not application interfaces, domain persistence or transport implementation",
|
||||
hint: "keep wire types self-contained; depend only on foundation or another contract",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerContracts },
|
||||
forbids: func(_, t class, _ string) bool { return !isLayer(t, layerFoundation, layerContracts) },
|
||||
},
|
||||
{
|
||||
id: "Domains_ImportNoAgentsIngressOrLegacy",
|
||||
clause: "area table and domain module shape: a domain owns no other domain's implementation; only its awsapi adapters may use the generic ingress/aws machinery",
|
||||
hint: "depend on a contract or capability; leave ingress use to the domain's awsapi package",
|
||||
subject: func(f class, _ string) bool { return f.domainShaped() },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
if !f.domainShaped() {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case t.layer == layerAgents || t.isLegacy("lbagent"):
|
||||
return true
|
||||
case t.layer == layerIngress:
|
||||
return f.kind != domainAWSAPI && f.kind != legacyAdapter
|
||||
case t.isLegacy("gateway") && !t.domainShaped():
|
||||
return true
|
||||
case t.isLegacy("handlers", "gateway"):
|
||||
return f.layer == layerDomain
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Agents_ImportNoDomainOrLegacyImplementation",
|
||||
clause: "area table: agents/<domain> own in-guest and service-role behaviour, not tenant API authority or another domain's private state",
|
||||
hint: "move the shared wire vocabulary behind a guest/controller contract under contracts/",
|
||||
subject: func(f class, _ string) bool { return f.layer == layerAgents || f.isLegacy("lbagent") },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
return (f.layer == layerAgents || f.isLegacy("lbagent")) && (t.domainShaped() || t.isLegacy())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Q22_STSImportsNoEKSImplementation",
|
||||
clause: "Q-22 prohibits the STS-to-EKS dependency in its present form; cut it through an identity-owned contract or an EKS projection",
|
||||
hint: "consume an EKS projection or an identity-owned contract instead of the EKS implementation",
|
||||
subject: func(f class, _ string) bool { return f.domainShaped() && f.domain == "sts" },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
return f.domainShaped() && f.domain == "sts" && t.domain == "eks" &&
|
||||
(importsDomainImplementation(t) || t.layer == layerAgents)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Interim_DaemonGatewayHandlersDirection",
|
||||
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
|
||||
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
|
||||
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
switch {
|
||||
case f.isLegacy("handlers"):
|
||||
return t.isLegacy("gateway", "daemon")
|
||||
case f.isLegacy("gateway"):
|
||||
return t.isLegacy("daemon")
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.27-bookworm AS builder
|
||||
FROM golang:1.27.3-bookworm@sha256:5c3e9b43cfd3bb95c7937462e29d87ada57b481a96fa23ad0eb61778d7a00110 AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
@@ -35,8 +35,8 @@ COPY bluebottle/ ./bluebottle/
|
||||
# Build spinifex binary (spx)
|
||||
RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
|
||||
cd spinifex && GOFIPS140=v1.0.0 go build -ldflags "-s -w \
|
||||
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=${VERSION} \
|
||||
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=${COMMIT}" \
|
||||
-X github.com/mulgadc/spinifex/spinifex/operator/cli.Version=${VERSION} \
|
||||
-X github.com/mulgadc/spinifex/spinifex/operator/cli.Commit=${COMMIT}" \
|
||||
-o /out/spx cmd/spinifex/main.go
|
||||
|
||||
# Build nbdkit viperblock plugin (.so)
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
@@ -43,10 +46,15 @@ func fail(err error) {
|
||||
}
|
||||
|
||||
func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
dispatch := gateway.AWSOperationInventory()
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
})
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := dispatch[string(service)]
|
||||
inventory, ok := inventories[string(service)]
|
||||
var modelInventory awsmodel.DispatchInventory
|
||||
switch {
|
||||
case ok:
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external/dhcp"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
@@ -1,175 +1,15 @@
|
||||
// ecr-credential-provider is a kubelet exec credential provider that lets a
|
||||
// Spinifex EKS worker node pull images from the internal Spinifex ECR registry.
|
||||
//
|
||||
// kubelet invokes it per-pull, feeding a CredentialProviderRequest on STDIN and
|
||||
// reading a CredentialProviderResponse on STDOUT. The provider fetches the
|
||||
// node IAM-role credentials from IMDS, SigV4-signs GetAuthorizationToken against
|
||||
// the AWS gateway's ECR endpoint, and returns the minted AWS:<jwt> as Basic auth
|
||||
// for the requested registry host.
|
||||
//
|
||||
// Static node config (gateway URL, CA, region) is read from the cloud-init env
|
||||
// file /etc/spinifex-eks/agent.env (KEY=value); real env vars override it.
|
||||
// ecr-credential-provider is the kubelet exec credential provider for Spinifex
|
||||
// EKS worker nodes; the implementation is in agents/eks/credentialprovider.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/ecrauth"
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/credentialprovider"
|
||||
)
|
||||
|
||||
const (
|
||||
credProviderAPIVersion = "credentialprovider.kubelet.k8s.io/v1"
|
||||
defaultCacheDuration = "10h"
|
||||
// expiryMargin trims the token's real TTL so kubelet re-invokes the provider
|
||||
// before the JWT actually expires mid-run.
|
||||
expiryMargin = 30 * time.Minute
|
||||
|
||||
defaultEnvFile = "/etc/spinifex-eks/agent.env"
|
||||
defaultGatewayCA = "/etc/spinifex-eks/gateway-ca.pem"
|
||||
defaultIMDSBase = "http://169.254.169.254/latest"
|
||||
)
|
||||
|
||||
// CredentialProviderRequest is the kubelet exec-provider request on STDIN.
|
||||
type CredentialProviderRequest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
// AuthConfig is a single registry credential entry.
|
||||
type AuthConfig struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CredentialProviderResponse is the kubelet exec-provider response on STDOUT.
|
||||
type CredentialProviderResponse struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
CacheKeyType string `json:"cacheKeyType"`
|
||||
CacheDuration string `json:"cacheDuration"`
|
||||
Auth map[string]AuthConfig `json:"auth"`
|
||||
}
|
||||
|
||||
// config holds the static node settings the provider needs.
|
||||
type config struct {
|
||||
GatewayURL string
|
||||
GatewayCA string
|
||||
Region string
|
||||
IMDSBase string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig(defaultEnvFile)
|
||||
|
||||
httpClient, err := ecrauth.GatewayHTTPClient(cfg.GatewayCA)
|
||||
if err != nil {
|
||||
slog.Error("ecr-credential-provider: build gateway client", "err", err)
|
||||
emitEmpty(os.Stdout)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(os.Stdin, os.Stdout, cfg, httpClient); err != nil {
|
||||
slog.Error("ecr-credential-provider: run failed", "err", err)
|
||||
// Still emit a valid empty-auth response so kubelet has a parseable reply.
|
||||
emitEmpty(os.Stdout)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the testable core: it reads a CredentialProviderRequest from in,
|
||||
// resolves credentials via IMDS + the gateway, and writes a
|
||||
// CredentialProviderResponse to out. The httpClient must trust the gateway CA.
|
||||
func run(in io.Reader, out io.Writer, cfg config, httpClient *http.Client) error {
|
||||
var req CredentialProviderRequest
|
||||
if err := json.NewDecoder(in).Decode(&req); err != nil {
|
||||
return fmt.Errorf("decode request: %w", err)
|
||||
}
|
||||
if req.Image == "" {
|
||||
return fmt.Errorf("request image is empty")
|
||||
}
|
||||
imageHost := ecrauth.HostFromImage(req.Image)
|
||||
|
||||
creds, err := imdscreds.Fetch(httpClient, cfg.IMDSBase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch IMDS credentials: %w", err)
|
||||
}
|
||||
|
||||
tok, err := ecrauth.GetAuthorizationToken(cfg.Region, cfg.GatewayURL, httpClient,
|
||||
creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get authorization token: %w", err)
|
||||
}
|
||||
|
||||
auth := AuthConfig{Username: tok.Username, Password: tok.Password}
|
||||
authMap := map[string]AuthConfig{imageHost: auth}
|
||||
if tok.ProxyHost != "" && tok.ProxyHost != imageHost {
|
||||
authMap[tok.ProxyHost] = auth
|
||||
}
|
||||
|
||||
resp := CredentialProviderResponse{
|
||||
APIVersion: credProviderAPIVersion,
|
||||
Kind: "CredentialProviderResponse",
|
||||
CacheKeyType: "Registry",
|
||||
CacheDuration: cacheDurationFrom(tok.ExpiresAt),
|
||||
Auth: authMap,
|
||||
}
|
||||
return json.NewEncoder(out).Encode(&resp)
|
||||
}
|
||||
|
||||
// cacheDurationFrom derives a Go-duration string from the token expiry minus a
|
||||
// safety margin, falling back to the default when expiry is unknown or too near.
|
||||
func cacheDurationFrom(expiresAt time.Time) string {
|
||||
if expiresAt.IsZero() {
|
||||
return defaultCacheDuration
|
||||
}
|
||||
d := time.Until(expiresAt) - expiryMargin
|
||||
if d <= 0 {
|
||||
return defaultCacheDuration
|
||||
}
|
||||
return d.Round(time.Second).String()
|
||||
}
|
||||
|
||||
// loadConfig reads the cloud-init env file then lets real env vars override. The
|
||||
// IMDS base is fixed (not in the env file) but overridable for tests.
|
||||
func loadConfig(envFile string) config {
|
||||
get := guestenv.Load(envFile).Get
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: get("EKS_GATEWAY_URL"),
|
||||
GatewayCA: get("EKS_GATEWAY_CA"),
|
||||
Region: get("EKS_REGION"),
|
||||
IMDSBase: get("EKS_IMDS_BASE"),
|
||||
}
|
||||
if cfg.GatewayCA == "" {
|
||||
cfg.GatewayCA = defaultGatewayCA
|
||||
}
|
||||
if cfg.IMDSBase == "" {
|
||||
cfg.IMDSBase = defaultIMDSBase
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// emitEmpty writes a valid CredentialProviderResponse with no auth entries.
|
||||
func emitEmpty(out io.Writer) {
|
||||
resp := CredentialProviderResponse{
|
||||
APIVersion: credProviderAPIVersion,
|
||||
Kind: "CredentialProviderResponse",
|
||||
CacheKeyType: "Registry",
|
||||
CacheDuration: defaultCacheDuration,
|
||||
Auth: map[string]AuthConfig{},
|
||||
}
|
||||
if err := json.NewEncoder(out).Encode(&resp); err != nil {
|
||||
slog.Error("ecr-credential-provider: encode empty response", "err", err)
|
||||
}
|
||||
os.Exit(credentialprovider.Main(os.Stdin, os.Stdout))
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBackend serves both the IMDS role/credentials endpoints and the gateway
|
||||
// ECR GetAuthorizationToken on one TLS server, routed by request path.
|
||||
func fakeBackend(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// IMDSv2 token. Real IMDS echoes the requested TTL back as a response
|
||||
// header; the SDK client parses it from there, not the body.
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("imds-v2-token"))
|
||||
})
|
||||
// IMDS role name.
|
||||
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
|
||||
if r.Header.Get("X-Aws-Ec2-Metadata-Token") != "imds-v2-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("node-role"))
|
||||
return
|
||||
}
|
||||
// Role credentials.
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"Code": "Success",
|
||||
"Type": "AWS-HMAC",
|
||||
"AccessKeyId": "AKIATEST",
|
||||
"SecretAccessKey": "secrettest",
|
||||
"Token": "sessiontoken",
|
||||
"AccountId": "123456789012",
|
||||
})
|
||||
})
|
||||
// Gateway ECR GetAuthorizationToken (X-Amz-Target dispatch).
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("X-Amz-Target"), "GetAuthorizationToken") {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authToken := base64.StdEncoding.EncodeToString([]byte("AWS:testjwt"))
|
||||
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"authorizationData": []map[string]any{{
|
||||
"authorizationToken": authToken,
|
||||
"proxyEndpoint": "https://123456789012.dkr.ecr.us-east-1.spinifex.internal",
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func clientTrusting(srv *httptest.Server) *http.Client {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(srv.Certificate())
|
||||
return &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
|
||||
}
|
||||
|
||||
func TestRun_ReturnsBasicAuth(t *testing.T) {
|
||||
srv := fakeBackend(t)
|
||||
client := clientTrusting(srv)
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: srv.URL,
|
||||
Region: "us-east-1",
|
||||
IMDSBase: srv.URL + "/latest",
|
||||
}
|
||||
|
||||
const image = "123456789012.dkr.ecr.us-east-1.spinifex.internal/spinifex-demo:latest"
|
||||
reqBody, err := json.Marshal(CredentialProviderRequest{
|
||||
APIVersion: credProviderAPIVersion,
|
||||
Kind: "CredentialProviderRequest",
|
||||
Image: image,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := run(bytes.NewReader(reqBody), &out, cfg, client); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
var resp CredentialProviderResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
if resp.Kind != "CredentialProviderResponse" {
|
||||
t.Errorf("Kind = %q, want CredentialProviderResponse", resp.Kind)
|
||||
}
|
||||
if resp.CacheKeyType != "Registry" {
|
||||
t.Errorf("CacheKeyType = %q, want Registry", resp.CacheKeyType)
|
||||
}
|
||||
|
||||
host := "123456789012.dkr.ecr.us-east-1.spinifex.internal"
|
||||
auth, ok := resp.Auth[host]
|
||||
if !ok {
|
||||
t.Fatalf("auth map missing host %q; got %v", host, resp.Auth)
|
||||
}
|
||||
if auth.Username != "AWS" {
|
||||
t.Errorf("username = %q, want AWS", auth.Username)
|
||||
}
|
||||
if auth.Password != "testjwt" {
|
||||
t.Errorf("password = %q, want testjwt", auth.Password)
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
goruntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/ecs-agent/credentials"
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// version is the agent build version, reported in the register message.
|
||||
// Overridable via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// Agent wires the ecs-agent's runtime seams: a gateway control-plane client, a
|
||||
// container runtime, and an ECR resolver. It registers the host as a container
|
||||
// instance at boot, heartbeats (re-registers) while alive, polls the gateway for
|
||||
// task assignments, and runs them through containerd, reporting state back over
|
||||
// the gateway. It never connects to NATS — the bus stays host-internal.
|
||||
type Agent struct {
|
||||
cfg config
|
||||
id identity
|
||||
cp controlPlane
|
||||
resolver ctrruntime.Resolver
|
||||
|
||||
// rt holds the container runtime, which is resolved by a loop rather than
|
||||
// once at boot. Empty until the containerd socket answers, and emptied again
|
||||
// if it stops answering.
|
||||
rt *runtimeGate
|
||||
|
||||
reg *registrar
|
||||
hb *heartbeater
|
||||
|
||||
// netns builds awsvpc task network namespaces (nil-safe; bridge/host skip it).
|
||||
netns *taskNetns
|
||||
|
||||
// cred serves task IAM role credentials at 169.254.170.2 (nil in unit tests).
|
||||
cred *credEndpoint
|
||||
|
||||
// gpu is the local pinning ledger over the host's discovered GPU UUIDs.
|
||||
// Always non-nil (newAgent seeds an empty one); New() reseeds it from
|
||||
// nvidia-smi discovery.
|
||||
gpu *gpuLedger
|
||||
|
||||
// dialRuntime connects to the container runtime. nil in unit tests, which
|
||||
// drive the gate directly.
|
||||
dialRuntime func() (ctrruntime.Runtime, error)
|
||||
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
// newAgent assembles an Agent from already-built seams. Tests use this directly
|
||||
// with fakes; New builds the production seams and delegates here. rt may be nil
|
||||
// when containerd is unavailable; the assign path then reports the task STOPPED
|
||||
// rather than crashing, and the watch loop keeps dialling for it.
|
||||
func newAgent(cfg config, id identity, cp controlPlane, rt ctrruntime.Runtime, resolver ctrruntime.Resolver) *Agent {
|
||||
gate := newRuntimeGate(rt)
|
||||
a := &Agent{
|
||||
cfg: cfg,
|
||||
id: id,
|
||||
cp: cp,
|
||||
resolver: resolver,
|
||||
rt: gate,
|
||||
netns: newTaskNetns(execNetRunner{}),
|
||||
gpu: newGPULedger(id.Capacity.GPUIDs),
|
||||
}
|
||||
// Registration reports the host's capacity only while a runtime is there to
|
||||
// use it, so an agent that cannot run a container advertises none and takes
|
||||
// no placement.
|
||||
a.reg = newRegistrar(cp, id, gate.available)
|
||||
a.hb = newHeartbeater(cp, id, cfg.Heartbeat, gate.available)
|
||||
return a
|
||||
}
|
||||
|
||||
// New builds an Agent from config: it resolves the host identity from IMDS,
|
||||
// builds the gateway control-plane client, the ECR resolver and (best-effort)
|
||||
// the containerd runtime. A containerd connect failure is logged, not fatal —
|
||||
// registration and heartbeat still run so the instance is visible while the
|
||||
// runtime recovers. The ECR gateway client is built lazily on first image pull
|
||||
// (not here), so a missing or malformed gateway CA does not stop registration.
|
||||
func New(cfg config) (*Agent, error) {
|
||||
imdsClient := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
meta, err := fetchInstanceMetadata(imdsClient, cfg.IMDSBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instance metadata: %w", err)
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
id := identity{
|
||||
AccountID: meta.AccountID,
|
||||
ClusterName: cfg.ClusterName,
|
||||
InstanceID: meta.InstanceID,
|
||||
AZ: meta.AZ,
|
||||
InstanceType: meta.InstanceType,
|
||||
Hostname: host,
|
||||
Capacity: detectCapacity(discoverNvidiaGPUs(execCommandRunner)),
|
||||
AgentVersion: version,
|
||||
}
|
||||
|
||||
creds := credentials.NewIMDSProvider(imdsClient, cfg.IMDSBase)
|
||||
resolver := newLazyECRResolver(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA)
|
||||
|
||||
// A dial that fails here is not fatal and not final: the node starts the
|
||||
// container daemon and the agent back to back, so the socket is often not
|
||||
// accepting yet. Run's watch loop keeps dialling.
|
||||
rt, perr := ctrruntime.New(cfg.ContainerdSocket)
|
||||
if perr != nil {
|
||||
slog.Warn("ecs-agent: containerd unavailable at boot, will keep retrying", "err", perr)
|
||||
}
|
||||
|
||||
cp, err := newGatewayControlPlane(cfg, creds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build gateway control-plane: %w", err)
|
||||
}
|
||||
|
||||
a := newAgent(cfg, id, cp, rt, resolver)
|
||||
a.cred = newCredEndpoint(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA,
|
||||
cfg.CredEndpointIP, cfg.CredEndpointPort, execNetRunner{})
|
||||
a.dialRuntime = func() (ctrruntime.Runtime, error) { return ctrruntime.New(cfg.ContainerdSocket) }
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Run registers the instance, starts the heartbeat and assignment-poll loops,
|
||||
// and blocks until ctx is cancelled, then tears down.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
if err := a.reg.Register(); err != nil {
|
||||
slog.Error("ecs-agent: initial registration failed", "err", err)
|
||||
} else {
|
||||
slog.Info("ecs-agent: registered", "cluster", a.id.ClusterName, "instance", a.id.InstanceID)
|
||||
}
|
||||
|
||||
if a.cred != nil {
|
||||
if err := a.cred.Start(); err != nil {
|
||||
slog.Error("ecs-agent: credential endpoint start failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
go a.hb.Run(ctx)
|
||||
|
||||
// Keeps the runtime current for the life of the process: dials while there
|
||||
// is none, and drops one that stops answering.
|
||||
if a.dialRuntime != nil {
|
||||
go a.rt.watch(ctx, a.dialRuntime, a.cfg.RuntimeRetry)
|
||||
}
|
||||
|
||||
// Re-adopt containers still running from before this restart, then poll with
|
||||
// their tasks pre-seeded so re-delivered assignments are acked, not re-run.
|
||||
go a.pollAssignments(ctx, a.reconcile(ctx))
|
||||
|
||||
<-ctx.Done()
|
||||
return a.Stop()
|
||||
}
|
||||
|
||||
// Stop closes the runtime. Safe to call once.
|
||||
func (a *Agent) Stop() error {
|
||||
var firstErr error
|
||||
if a.cred != nil {
|
||||
_ = a.cred.Stop()
|
||||
}
|
||||
for _, c := range a.closers {
|
||||
if err := c(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
a.rt.clear()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// detectCapacity reports the host's total CPU units (1 vCPU = 1024), memory,
|
||||
// and GPU count/UUIDs from the caller's nvidia-smi discovery (nil on a
|
||||
// non-GPU host).
|
||||
func detectCapacity(gpuUUIDs []string) bus.InstanceCapacity {
|
||||
return bus.InstanceCapacity{
|
||||
CPU: goruntime.NumCPU() * 1024,
|
||||
MemoryMiB: memTotalMiB(),
|
||||
GPU: len(gpuUUIDs),
|
||||
GPUIDs: gpuUUIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// memTotalMiB reads MemTotal from /proc/meminfo, returning 0 if unavailable.
|
||||
func memTotalMiB() int {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "MemTotal:") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0
|
||||
}
|
||||
kb, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return kb / 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
)
|
||||
|
||||
func TestAgent_RunRegistersThenStopsOnContext(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
cfg := config{Heartbeat: 5 * time.Millisecond, PollInterval: 5 * time.Millisecond}
|
||||
puller := &ctrruntime.FakePuller{}
|
||||
a := newAgent(cfg, testIdentity(), cp, puller, nil)
|
||||
a.closers = append(a.closers, puller.Close)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
// Give it time to register + re-register (heartbeat) a few times.
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
|
||||
// One boot register + at least one heartbeat re-register.
|
||||
if cp.registerCount() < 2 {
|
||||
t.Errorf("registers = %d, want >= 2 (boot + heartbeat)", cp.registerCount())
|
||||
}
|
||||
if !puller.Closed {
|
||||
t.Error("Stop did not close the puller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCapacity_Positive(t *testing.T) {
|
||||
c := detectCapacity(nil)
|
||||
if c.CPU <= 0 {
|
||||
t.Errorf("CPU = %d, want > 0", c.CPU)
|
||||
}
|
||||
// MemoryMiB may be 0 on platforms without /proc/meminfo; just assert non-negative.
|
||||
if c.MemoryMiB < 0 {
|
||||
t.Errorf("MemoryMiB = %d, want >= 0", c.MemoryMiB)
|
||||
}
|
||||
if c.GPU != 0 || c.GPUIDs != nil {
|
||||
t.Errorf("no GPU UUIDs given: want GPU=0/GPUIDs=nil, got %d/%v", c.GPU, c.GPUIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectCapacity_WithGPUUUIDs(t *testing.T) {
|
||||
c := detectCapacity([]string{"GPU-aaa", "GPU-bbb"})
|
||||
if c.GPU != 2 {
|
||||
t.Errorf("GPU = %d, want 2", c.GPU)
|
||||
}
|
||||
if len(c.GPUIDs) != 2 {
|
||||
t.Errorf("GPUIDs = %v, want 2 entries", c.GPUIDs)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEnvFile = "/etc/spinifex-ecs/agent.env"
|
||||
defaultGatewayCA = "/etc/spinifex-ecs/gateway-ca.pem"
|
||||
defaultIMDSBase = "http://169.254.169.254/latest"
|
||||
defaultContainerdSocket = "/run/containerd/containerd.sock"
|
||||
defaultHeartbeat = 30 * time.Second
|
||||
defaultPollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// config holds the static settings the agent reads at boot. Cluster identity
|
||||
// (account ID, instance ID) is discovered at runtime from IMDS, not configured;
|
||||
// only the cluster *name* the instance was launched into is static. AccessKey /
|
||||
// SecretKey are legacy seeded creds, optional now that the agent SigV4-signs
|
||||
// gateway calls with instance-role credentials from IMDS; the agent never holds
|
||||
// a NATS token.
|
||||
type config struct {
|
||||
GatewayURL string
|
||||
GatewayCA string
|
||||
Region string
|
||||
ClusterName string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
IMDSBase string
|
||||
ContainerdSocket string
|
||||
Heartbeat time.Duration
|
||||
PollInterval time.Duration
|
||||
RuntimeRetry time.Duration
|
||||
CredEndpointIP string
|
||||
CredEndpointPort int
|
||||
}
|
||||
|
||||
// loadConfig reads the cloud-init env file then lets real env vars override.
|
||||
func loadConfig(envFile string) config {
|
||||
get := guestenv.Load(envFile).Get
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: get("ECS_GATEWAY_URL"),
|
||||
GatewayCA: get("ECS_GATEWAY_CA"),
|
||||
Region: get("ECS_REGION"),
|
||||
ClusterName: get("ECS_CLUSTER"),
|
||||
AccessKey: get("ECS_ACCESS_KEY"),
|
||||
SecretKey: get("ECS_SECRET_KEY"),
|
||||
IMDSBase: get("ECS_IMDS_BASE"),
|
||||
ContainerdSocket: get("ECS_CONTAINERD_SOCKET"),
|
||||
}
|
||||
if cfg.GatewayCA == "" {
|
||||
cfg.GatewayCA = defaultGatewayCA
|
||||
}
|
||||
if cfg.IMDSBase == "" {
|
||||
cfg.IMDSBase = defaultIMDSBase
|
||||
}
|
||||
if cfg.ContainerdSocket == "" {
|
||||
cfg.ContainerdSocket = defaultContainerdSocket
|
||||
}
|
||||
if cfg.ClusterName == "" {
|
||||
cfg.ClusterName = "default"
|
||||
}
|
||||
cfg.Heartbeat = defaultHeartbeat
|
||||
if v := get("ECS_HEARTBEAT_INTERVAL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.Heartbeat = d
|
||||
}
|
||||
}
|
||||
cfg.PollInterval = defaultPollInterval
|
||||
if v := get("ECS_POLL_INTERVAL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.PollInterval = d
|
||||
}
|
||||
}
|
||||
cfg.RuntimeRetry = defaultRuntimeRetry
|
||||
if v := get("ECS_RUNTIME_RETRY"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.RuntimeRetry = d
|
||||
}
|
||||
}
|
||||
cfg.CredEndpointIP = get("ECS_CRED_ENDPOINT_IP")
|
||||
if cfg.CredEndpointIP == "" {
|
||||
cfg.CredEndpointIP = defaultCredEndpointIP
|
||||
}
|
||||
cfg.CredEndpointPort = defaultCredEndpointPort
|
||||
if v := get("ECS_CRED_ENDPOINT_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil && p > 0 {
|
||||
cfg.CredEndpointPort = p
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
// Ensure env vars don't leak in from the host.
|
||||
for _, k := range []string{"ECS_GATEWAY_URL", "ECS_GATEWAY_CA", "ECS_REGION",
|
||||
"ECS_CLUSTER", "ECS_ACCESS_KEY", "ECS_SECRET_KEY", "ECS_IMDS_BASE",
|
||||
"ECS_CONTAINERD_SOCKET", "ECS_HEARTBEAT_INTERVAL", "ECS_POLL_INTERVAL"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if cfg.GatewayCA != defaultGatewayCA {
|
||||
t.Errorf("GatewayCA = %q, want default", cfg.GatewayCA)
|
||||
}
|
||||
if cfg.IMDSBase != defaultIMDSBase {
|
||||
t.Errorf("IMDSBase = %q, want default", cfg.IMDSBase)
|
||||
}
|
||||
if cfg.ContainerdSocket != defaultContainerdSocket {
|
||||
t.Errorf("ContainerdSocket = %q, want default", cfg.ContainerdSocket)
|
||||
}
|
||||
if cfg.ClusterName != "default" {
|
||||
t.Errorf("ClusterName = %q, want default", cfg.ClusterName)
|
||||
}
|
||||
if cfg.Heartbeat != defaultHeartbeat {
|
||||
t.Errorf("Heartbeat = %v, want %v", cfg.Heartbeat, defaultHeartbeat)
|
||||
}
|
||||
if cfg.PollInterval != defaultPollInterval {
|
||||
t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, defaultPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_FileThenEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envFile := filepath.Join(dir, "agent.env")
|
||||
body := "# comment\nECS_GATEWAY_URL=https://gw.file\nECS_REGION=us-west-2\nECS_CLUSTER=prod\n"
|
||||
if err := os.WriteFile(envFile, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ECS_GATEWAY_URL", "https://gw.env")
|
||||
t.Setenv("ECS_ACCESS_KEY", "AKIAENV")
|
||||
|
||||
cfg := loadConfig(envFile)
|
||||
if cfg.GatewayURL != "https://gw.env" {
|
||||
t.Errorf("GatewayURL = %q, want env override", cfg.GatewayURL)
|
||||
}
|
||||
if cfg.Region != "us-west-2" {
|
||||
t.Errorf("Region = %q, want from file", cfg.Region)
|
||||
}
|
||||
if cfg.ClusterName != "prod" {
|
||||
t.Errorf("ClusterName = %q, want from file", cfg.ClusterName)
|
||||
}
|
||||
if cfg.AccessKey != "AKIAENV" {
|
||||
t.Errorf("AccessKey = %q, want env", cfg.AccessKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_HeartbeatOverride(t *testing.T) {
|
||||
t.Setenv("ECS_HEARTBEAT_INTERVAL", "5s")
|
||||
if cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env")); cfg.Heartbeat != 5*time.Second {
|
||||
t.Errorf("Heartbeat = %v, want 5s", cfg.Heartbeat)
|
||||
}
|
||||
// Garbage value falls back to the default, not zero.
|
||||
t.Setenv("ECS_HEARTBEAT_INTERVAL", "not-a-duration")
|
||||
if cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env")); cfg.Heartbeat != defaultHeartbeat {
|
||||
t.Errorf("Heartbeat = %v, want default on bad value", cfg.Heartbeat)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// heartbeater keeps the container instance alive by periodically re-registering
|
||||
// it through the gateway. Real ECS folds liveness into ACS; v1 folds it into an
|
||||
// idempotent RegisterContainerInstance, which refreshes the instance's LastSeen
|
||||
// so the scheduler can tell live instances from dead ones (no bus.Heartbeat).
|
||||
type heartbeater struct {
|
||||
cp controlPlane
|
||||
id identity
|
||||
interval time.Duration
|
||||
// runtimeReady reports whether a container runtime is resolved, so a beat
|
||||
// advertises capacity only while there is something to run it with. nil
|
||||
// means always ready.
|
||||
runtimeReady func() bool
|
||||
}
|
||||
|
||||
func newHeartbeater(cp controlPlane, id identity, interval time.Duration, runtimeReady func() bool) *heartbeater {
|
||||
if interval <= 0 {
|
||||
interval = defaultHeartbeat
|
||||
}
|
||||
return &heartbeater{cp: cp, id: id, interval: interval, runtimeReady: runtimeReady}
|
||||
}
|
||||
|
||||
// beat re-registers the instance once, refreshing its LastSeen.
|
||||
func (h *heartbeater) beat() error {
|
||||
ready := h.runtimeReady == nil || h.runtimeReady()
|
||||
return h.cp.Register(advertisedIdentity(h.id, ready))
|
||||
}
|
||||
|
||||
// Run beats every interval until ctx is cancelled. A failed beat is logged and
|
||||
// retried on the next tick rather than killing the loop.
|
||||
func (h *heartbeater) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(h.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := h.beat(); err != nil {
|
||||
slog.Warn("ecs-agent: heartbeat (re-register) failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -1,13 +1,6 @@
|
||||
// ecs-agent runs inside a Spinifex ECS container instance (a guest VM booted from
|
||||
// the ECS-AMI, with containerd baked in). It registers the host with the ECS
|
||||
// scheduler through the AWS gateway over TLS+SigV4 (never NATS), heartbeats by
|
||||
// re-registering, polls the gateway for task assignments, runs them through
|
||||
// containerd, and reports state back over the gateway.
|
||||
//
|
||||
// Static config (gateway URL, CA, region, cluster, seeded IAM creds, containerd
|
||||
// socket) is read from the cloud-init env file /etc/spinifex-ecs/agent.env
|
||||
// (KEY=value); real env vars override it. Host identity (account, instance, AZ)
|
||||
// comes from IMDS at boot.
|
||||
// Command ecs-agent runs inside a Spinifex ECS container instance; the
|
||||
// implementation is in agents/ecs/agent. This binary owns process lifecycle:
|
||||
// signal handling, startup and run failure logging, and the exit code.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -18,12 +11,19 @@ import (
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
ecsagent "github.com/mulgadc/spinifex/spinifex/agents/ecs/agent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig(defaultEnvFile)
|
||||
// version is the agent build version, reported in the register message.
|
||||
// Overridable via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
agent, err := New(cfg)
|
||||
func main() {
|
||||
cfg := ecsagent.LoadConfig(ecsagent.DefaultEnvFile)
|
||||
cfg.AgentVersion = version
|
||||
|
||||
agent, err := ecsagent.New(cfg)
|
||||
if err != nil {
|
||||
slog.Error("ecs-agent: startup failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
)
|
||||
|
||||
// instanceMetadata is the IMDS-derived identity of the host the agent runs on.
|
||||
type instanceMetadata struct {
|
||||
AccountID string
|
||||
InstanceID string
|
||||
AZ string
|
||||
InstanceType string
|
||||
}
|
||||
|
||||
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2
|
||||
// via the SDK IMDS client. base is the IMDS root, e.g.
|
||||
// "http://169.254.169.254/latest".
|
||||
func fetchInstanceMetadata(client *http.Client, base string) (instanceMetadata, error) {
|
||||
imdsClient := imdscreds.NewClient(client, base)
|
||||
ctx := context.Background()
|
||||
|
||||
instanceID, err := getMetadata(ctx, imdsClient, "instance-id")
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("instance-id: %w", err)
|
||||
}
|
||||
az, err := getMetadata(ctx, imdsClient, "placement/availability-zone")
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("availability-zone: %w", err)
|
||||
}
|
||||
|
||||
doc, err := imdsClient.GetInstanceIdentityDocument(ctx, &imds.GetInstanceIdentityDocumentInput{})
|
||||
if err != nil {
|
||||
return instanceMetadata{}, fmt.Errorf("identity document: %w", err)
|
||||
}
|
||||
|
||||
// The instance type comes off the identity document already in hand rather
|
||||
// than a further meta-data read; the scheduler sizes the host's awsvpc ENI
|
||||
// capacity from it.
|
||||
return instanceMetadata{
|
||||
AccountID: doc.AccountID,
|
||||
InstanceID: strings.TrimSpace(instanceID),
|
||||
AZ: strings.TrimSpace(az),
|
||||
InstanceType: strings.TrimSpace(doc.InstanceType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getMetadata reads a single meta-data path and returns its body as a string.
|
||||
func getMetadata(ctx context.Context, imdsClient *imds.Client, path string) (string, error) {
|
||||
out, err := imdsClient.GetMetadata(ctx, &imds.GetMetadataInput{Path: path})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Content.Close()
|
||||
body, err := io.ReadAll(out.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func metadataStub(t *testing.T, enforceV2 bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Real IMDS echoes the requested TTL back as a response header; the SDK
|
||||
// client parses it from there, not the body.
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("v2-token"))
|
||||
})
|
||||
guard := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
if enforceV2 && r.Header.Get("X-Aws-Ec2-Metadata-Token") != "v2-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte("i-0abc123"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte("us-east-1a"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) {
|
||||
if guard(w, r) {
|
||||
_, _ = w.Write([]byte(`{"accountId":"123456789012","region":"us-east-1"}`))
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestFetchInstanceMetadata_OK(t *testing.T) {
|
||||
srv := metadataStub(t, true)
|
||||
got, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if got.InstanceID != "i-0abc123" || got.AZ != "us-east-1a" || got.AccountID != "123456789012" {
|
||||
t.Errorf("metadata mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceMetadata_TokenRequired(t *testing.T) {
|
||||
// Server returns 405 for non-PUT token; a GET-only client would 401 downstream.
|
||||
srv := metadataStub(t, true)
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err != nil {
|
||||
t.Fatalf("v2 path should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceMetadata_BadDocument(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("t"))
|
||||
})
|
||||
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("i-1")) })
|
||||
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("z")) })
|
||||
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err == nil {
|
||||
t.Fatal("expected parse error on bad identity document")
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// reconcile re-syncs the gateway with what this instance is actually running
|
||||
// after an agent restart. It lists the runtime's containers and, for each task
|
||||
// in this cluster, either re-adopts it (still running: re-register IAM
|
||||
// credentials, re-attach the exit-wait, refresh RUNNING) or reports it STOPPED
|
||||
// and clears its leftovers. The returned set seeds pollAssignments so the
|
||||
// gateway's re-delivered assignments for these tasks are acked but not re-run.
|
||||
//
|
||||
// Reporting the dead ones is the load-bearing half. The exit-wait is cancelled
|
||||
// when the agent shuts down, so a container that dies while the agent is down
|
||||
// has nobody to report its exit; the gateway holds the task RUNNING forever,
|
||||
// its service never replaces it, and the target never recovers.
|
||||
//
|
||||
// It is best-effort: a nil runner or a List error returns an empty set and the
|
||||
// agent falls back to its pre-reconcile behaviour (poll + run).
|
||||
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
adopted := map[string]bool{}
|
||||
runner := a.rt.get()
|
||||
if runner == nil {
|
||||
return adopted
|
||||
}
|
||||
containers, err := runner.List(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ecs-agent: reconcile list failed", "err", err)
|
||||
return adopted
|
||||
}
|
||||
|
||||
// Group this cluster's containers back into their tasks, running or not. The
|
||||
// Assign is reconstructed from labels — enough to re-register creds and tear
|
||||
// down netns.
|
||||
type task struct {
|
||||
as *bus.Assign
|
||||
running []ctrruntime.Container
|
||||
dead []ctrruntime.Container
|
||||
}
|
||||
tasks := map[string]*task{}
|
||||
for _, c := range containers {
|
||||
if c.Labels[labelClusterName] != a.id.ClusterName {
|
||||
continue
|
||||
}
|
||||
taskID := c.Labels[labelTaskID]
|
||||
if taskID == "" {
|
||||
continue
|
||||
}
|
||||
t := tasks[taskID]
|
||||
if t == nil {
|
||||
t = &task{as: &bus.Assign{
|
||||
TaskID: taskID,
|
||||
ClusterName: a.id.ClusterName,
|
||||
CredID: c.Labels[labelCredID],
|
||||
TaskRoleARN: c.Labels[labelTaskRoleARN],
|
||||
ENIMacAddress: c.Labels[labelENIMac],
|
||||
}}
|
||||
tasks[taskID] = t
|
||||
}
|
||||
if c.Running {
|
||||
t.running = append(t.running, c)
|
||||
} else {
|
||||
t.dead = append(t.dead, c)
|
||||
}
|
||||
}
|
||||
|
||||
for taskID, t := range tasks {
|
||||
// A task with nothing running is over, whatever the gateway still believes.
|
||||
// Mixed running/dead is left to the running path: the exit-wait re-attached
|
||||
// below reports the individual container when it is reaped.
|
||||
if len(t.running) == 0 {
|
||||
a.reportStoppedTask(ctx, t.as, t.dead)
|
||||
adopted[taskID] = true
|
||||
continue
|
||||
}
|
||||
|
||||
a.registerTaskCreds(t.as)
|
||||
statuses := make([]bus.ContainerStatus, 0, len(t.running))
|
||||
for _, c := range t.running {
|
||||
name := c.Labels[labelContainerName]
|
||||
go a.waitContainer(ctx, t.as, name, c.ID)
|
||||
statuses = append(statuses, bus.ContainerStatus{Name: name, Status: bus.TaskStatusRunning, ContainerID: c.ID})
|
||||
}
|
||||
a.reportTaskState(t.as, bus.TaskStatusRunning, "", statuses)
|
||||
adopted[taskID] = true
|
||||
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.running))
|
||||
}
|
||||
return adopted
|
||||
}
|
||||
|
||||
// reportStoppedTask clears a task's leftover containers and tells the gateway it
|
||||
// is STOPPED, so the scheduler stops counting a task this instance is not
|
||||
// running and its service can place a replacement.
|
||||
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
|
||||
runner := a.rt.get()
|
||||
statuses := make([]bus.ContainerStatus, 0, len(dead))
|
||||
for _, c := range dead {
|
||||
if runner == nil {
|
||||
break
|
||||
}
|
||||
if err := runner.Remove(ctx, c.ID); err != nil {
|
||||
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
|
||||
}
|
||||
statuses = append(statuses, bus.ContainerStatus{
|
||||
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
|
||||
})
|
||||
}
|
||||
a.reportTaskState(as, bus.TaskStatusStopped, "Container exited while the agent was not running", statuses)
|
||||
slog.Info("ecs-agent: reported stopped task", "task", as.TaskID, "containers", len(dead))
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
const testTaskRole = "arn:aws:iam::111122223333:role/task"
|
||||
|
||||
// adoptedContainer builds a runtime.Container as the reconciler would discover it,
|
||||
// stamping only the mulga.ecs.* labels that are non-empty.
|
||||
func adoptedContainer(taskID, name, cluster, credID, role, mac string, running bool) ctrruntime.Container {
|
||||
labels := map[string]string{
|
||||
labelTaskID: taskID,
|
||||
labelContainerName: name,
|
||||
labelClusterName: cluster,
|
||||
}
|
||||
if credID != "" {
|
||||
labels[labelCredID] = credID
|
||||
}
|
||||
if role != "" {
|
||||
labels[labelTaskRoleARN] = role
|
||||
}
|
||||
if mac != "" {
|
||||
labels[labelENIMac] = mac
|
||||
}
|
||||
return ctrruntime.Container{ID: containerID(taskID, name), Labels: labels, Running: running}
|
||||
}
|
||||
|
||||
func waitedFor(t *testing.T, rt *ctrruntime.FakePuller, id string) {
|
||||
t.Helper()
|
||||
deadline := time.After(time.Second)
|
||||
for !slices.Contains(rt.Waits(), id) {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("exit-wait never re-attached for %s; waited=%v", id, rt.Waits())
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A running, correctly-labeled container is re-adopted: its credentials are
|
||||
// re-registered, its RUNNING state refreshed, and its exit-wait re-attached.
|
||||
func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
WaitErr: errors.New("blocked"),
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-001", "web", "default", "t-001", testTaskRole, "52:54:00:de:ad:01", true),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
if !adopted["t-001"] {
|
||||
t.Fatalf("t-001 not adopted; got %v", adopted)
|
||||
}
|
||||
if got := a.cred.roles["t-001"]; got != testTaskRole {
|
||||
t.Errorf("cred not re-registered: roles[t-001] = %q, want %q", got, testTaskRole)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-001", bus.TaskStatusRunning); n != 1 {
|
||||
t.Errorf("RUNNING reports for t-001 = %d, want 1", n)
|
||||
}
|
||||
waitedFor(t, rt, containerID("t-001", "web"))
|
||||
}
|
||||
|
||||
// A task whose container died while the agent was down must be reported STOPPED,
|
||||
// not silently skipped. The exit-wait is cancelled on agent shutdown, so nothing
|
||||
// else ever reports that exit: skipping it leaves the gateway holding the task
|
||||
// RUNNING forever, its service never places a replacement, and the load
|
||||
// balancer target stays unhealthy with no path back.
|
||||
func TestReconcile_ReportsTaskWhoseContainerDied(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-dead", "web", "default", "t-dead", testTaskRole, "52:54:00:de:ad:02", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusStopped); n != 1 {
|
||||
t.Errorf("STOPPED reports for t-dead = %d, want 1", n)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusRunning); n != 0 {
|
||||
t.Errorf("t-dead must not be reported RUNNING, got %d such reports", n)
|
||||
}
|
||||
// Seeded so a re-delivered assignment for the dead task is acked, not re-run.
|
||||
if !adopted["t-dead"] {
|
||||
t.Errorf("t-dead not seeded into the adopted set; got %v", adopted)
|
||||
}
|
||||
if want := containerID("t-dead", "web"); !slices.Contains(rt.Removed, want) {
|
||||
t.Errorf("leftover container %s not removed; removed=%v", want, rt.Removed)
|
||||
}
|
||||
}
|
||||
|
||||
// A task with one live and one dead container is still running: the live half is
|
||||
// re-adopted and the re-attached exit-wait reports the dead one when it is
|
||||
// reaped, so reconcile must not declare the whole task STOPPED.
|
||||
func TestReconcile_PartiallyDeadTaskStaysRunning(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
WaitErr: errors.New("blocked"),
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-mixed", "web", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", true),
|
||||
adoptedContainer("t-mixed", "sidecar", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
a.reconcile(context.Background())
|
||||
|
||||
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusRunning); n != 1 {
|
||||
t.Errorf("RUNNING reports for t-mixed = %d, want 1", n)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusStopped); n != 0 {
|
||||
t.Errorf("t-mixed must not be reported STOPPED while a container still runs, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// pollAssignments seeded with an adopted task acks its re-delivered assignment
|
||||
// but does not re-run it, while a genuinely new task still dispatches.
|
||||
func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
|
||||
mk := func(id string) bus.Assign {
|
||||
return bus.Assign{
|
||||
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123", TaskID: id,
|
||||
Containers: []bus.AssignContainer{{Name: "web", Image: "registry/web:1", Command: []string{"/bin/true"}}},
|
||||
}
|
||||
}
|
||||
adopted, fresh := mk("t-001"), mk("t-002")
|
||||
cp := &fakeCP{pollReplies: [][]bus.Assign{
|
||||
{adopted, fresh},
|
||||
{adopted, fresh},
|
||||
nil,
|
||||
}}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go a.pollAssignments(ctx, map[string]bool{"t-001": true})
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for countStatus(cp.taskStates(), "t-002", bus.TaskStatusRunning) < 1 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("fresh task t-002 was never dispatched")
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
if n := countStatus(cp.taskStates(), "t-001", bus.TaskStatusRunning); n != 0 {
|
||||
t.Errorf("adopted task t-001 was re-run %d times, want 0", n)
|
||||
}
|
||||
if n := countStatus(cp.taskStates(), "t-002", bus.TaskStatusRunning); n != 1 {
|
||||
t.Errorf("fresh task t-002 dispatched %d times, want 1", n)
|
||||
}
|
||||
var t1Acked bool
|
||||
for _, ack := range cp.acks() {
|
||||
for _, id := range ack {
|
||||
if id == "t-001" {
|
||||
t1Acked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !t1Acked {
|
||||
t.Errorf("adopted task t-001 was never acked; acks=%v", cp.acks())
|
||||
}
|
||||
}
|
||||
|
||||
// Containers belonging to another cluster are ignored entirely: this agent
|
||||
// speaks for its own cluster only, and must neither adopt nor report them.
|
||||
//
|
||||
// A stopped container of this cluster IS handled — see
|
||||
// TestReconcile_ReportsTaskWhoseContainerDied. It lands in the adopted set so
|
||||
// its re-delivered assignment is acked rather than re-run, after reconcile has
|
||||
// reported the task STOPPED.
|
||||
func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
WaitErr: errors.New("blocked"),
|
||||
Containers: []ctrruntime.Container{
|
||||
adoptedContainer("t-other", "web", "other", "", "", "", true),
|
||||
adoptedContainer("t-other-dead", "web", "other", "", "", "", false),
|
||||
adoptedContainer("t-ok", "web", "default", "", "", "", true),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
if len(adopted) != 1 || !adopted["t-ok"] {
|
||||
t.Fatalf("adopted = %v, want only t-ok", adopted)
|
||||
}
|
||||
for _, id := range []string{"t-other", "t-other-dead"} {
|
||||
if n := countStatus(cp.taskStates(), id, bus.TaskStatusStopped); n != 0 {
|
||||
t.Errorf("reported %d STOPPED states for another cluster's task %s, want 0", n, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil runner or a List error degrades to an empty set without panicking.
|
||||
func TestReconcile_DegradesGracefully(t *testing.T) {
|
||||
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil)
|
||||
if got := noRunner.reconcile(context.Background()); len(got) != 0 {
|
||||
t.Errorf("nil runner: adopted = %v, want empty", got)
|
||||
}
|
||||
|
||||
rt := &ctrruntime.FakePuller{ListErr: errors.New("boom")}
|
||||
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, nil)
|
||||
if got := listErr.reconcile(context.Background()); len(got) != 0 {
|
||||
t.Errorf("list error: adopted = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// taskLabels emits the optional cred/role/MAC labels only when set.
|
||||
func TestTaskLabels_OmitsEmptyOptionalLabels(t *testing.T) {
|
||||
full := taskLabels(&bus.Assign{
|
||||
TaskID: "t1", ClusterName: "c", CredID: "x", TaskRoleARN: testTaskRole, ENIMacAddress: "mac",
|
||||
}, "web")
|
||||
for k, want := range map[string]string{
|
||||
labelCredID: "x", labelTaskRoleARN: testTaskRole, labelENIMac: "mac",
|
||||
} {
|
||||
if full[k] != want {
|
||||
t.Errorf("full labels[%q] = %q, want %q", k, full[k], want)
|
||||
}
|
||||
}
|
||||
|
||||
bare := taskLabels(&bus.Assign{TaskID: "t1", ClusterName: "c"}, "web")
|
||||
if len(bare) != 3 {
|
||||
t.Errorf("bare labels = %v, want only taskID/containerName/clusterName", bare)
|
||||
}
|
||||
for _, k := range []string{labelCredID, labelTaskRoleARN, labelENIMac} {
|
||||
if _, ok := bare[k]; ok {
|
||||
t.Errorf("bare labels unexpectedly carry %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ecs"
|
||||
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// identity is the container instance's stable identity, assembled at boot from
|
||||
// IMDS (account, instance, AZ) and config (cluster). It is the join key the
|
||||
// scheduler uses to track this instance.
|
||||
type identity struct {
|
||||
AccountID string
|
||||
ClusterName string
|
||||
InstanceID string
|
||||
AZ string
|
||||
InstanceType string
|
||||
Hostname string
|
||||
Capacity bus.InstanceCapacity
|
||||
AgentVersion string
|
||||
}
|
||||
|
||||
// instanceAttributes builds the built-in container-instance attributes from the
|
||||
// host's identity. Only attributes whose value was actually discovered are
|
||||
// reported, so an empty one is absent rather than present and blank.
|
||||
func instanceAttributes(id identity) []*ecs.Attribute {
|
||||
var attrs []*ecs.Attribute
|
||||
add := func(name, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
attrs = append(attrs, &ecs.Attribute{Name: aws.String(name), Value: aws.String(value)})
|
||||
}
|
||||
add(handlers_ecs.AttrInstanceType, id.InstanceType)
|
||||
add(handlers_ecs.AttrAvailabilityZone, id.AZ)
|
||||
return attrs
|
||||
}
|
||||
|
||||
// advertisedIdentity is the identity to register with, given whether a
|
||||
// container runtime is available. A host with no runtime can run nothing, so it
|
||||
// advertises no capacity: the scheduler places nothing on it, and the control
|
||||
// plane reads the zero as an involuntary drain. The identity is otherwise
|
||||
// unchanged, so the instance stays visible rather than disappearing.
|
||||
func advertisedIdentity(id identity, runtimeReady bool) identity {
|
||||
if runtimeReady {
|
||||
return id
|
||||
}
|
||||
id.Capacity = bus.InstanceCapacity{}
|
||||
return id
|
||||
}
|
||||
|
||||
// registrar registers the host as a container instance through the gateway.
|
||||
type registrar struct {
|
||||
cp controlPlane
|
||||
id identity
|
||||
// runtimeReady reports whether a container runtime is resolved. nil means
|
||||
// always ready, which is what a test with no gate wants.
|
||||
runtimeReady func() bool
|
||||
}
|
||||
|
||||
func newRegistrar(cp controlPlane, id identity, runtimeReady func() bool) *registrar {
|
||||
return ®istrar{cp: cp, id: id, runtimeReady: runtimeReady}
|
||||
}
|
||||
|
||||
// ready reports the runtime state, defaulting to ready when no gate is wired.
|
||||
func (r *registrar) ready() bool { return r.runtimeReady == nil || r.runtimeReady() }
|
||||
|
||||
// Register calls the gateway's RegisterContainerInstance. The scheduler records
|
||||
// (or refreshes) the container instance on receipt.
|
||||
func (r *registrar) Register() error {
|
||||
if err := r.cp.Register(advertisedIdentity(r.id, r.ready())); err != nil {
|
||||
return fmt.Errorf("register instance %s: %w", r.id.InstanceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// fakeCP is a test double for the gateway control-plane. It records registers,
|
||||
// task-state reports and GPU reports, and replays a scripted queue of poll
|
||||
// responses.
|
||||
type fakeCP struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registers int
|
||||
lastRegistered identity
|
||||
states []bus.TaskState
|
||||
gpuReports []gpuReport
|
||||
pollAcks [][]string
|
||||
pollStopAcks [][]string
|
||||
pollReplies [][]bus.Assign
|
||||
stopReplies [][]bus.StopDirective
|
||||
stopPending []bus.StopDirective
|
||||
pollCalls int
|
||||
|
||||
registerErr bool
|
||||
submitErr bool
|
||||
}
|
||||
|
||||
// gpuReport is one recorded ReportTaskGPU call.
|
||||
type gpuReport struct {
|
||||
cluster string
|
||||
task string
|
||||
containers []handlers_ecs.ContainerGPUReport
|
||||
}
|
||||
|
||||
var _ controlPlane = (*fakeCP)(nil)
|
||||
|
||||
func (f *fakeCP) Register(id identity) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.registerErr {
|
||||
return errors.New("register boom")
|
||||
}
|
||||
f.registers++
|
||||
f.lastRegistered = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// registered returns the identity of the most recent Register call.
|
||||
func (f *fakeCP) registered() identity {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.lastRegistered
|
||||
}
|
||||
|
||||
func (f *fakeCP) SubmitTaskState(st bus.TaskState) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.submitErr {
|
||||
return errors.New("submit boom")
|
||||
}
|
||||
f.states = append(f.states, st)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCP) PollAssignments(_, _ string, ackAssigns, ackStops []string) ([]bus.Assign, []bus.StopDirective, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.pollAcks = append(f.pollAcks, append([]string(nil), ackAssigns...))
|
||||
f.pollStopAcks = append(f.pollStopAcks, append([]string(nil), ackStops...))
|
||||
var out []bus.Assign
|
||||
if f.pollCalls < len(f.pollReplies) {
|
||||
out = f.pollReplies[f.pollCalls]
|
||||
}
|
||||
var stops []bus.StopDirective
|
||||
if f.pollCalls < len(f.stopReplies) {
|
||||
stops = f.stopReplies[f.pollCalls]
|
||||
}
|
||||
// stopPending is the durable inbox the gateway keeps: an acked directive is
|
||||
// deleted, and everything still in it is redelivered on every poll.
|
||||
f.stopPending = slices.DeleteFunc(f.stopPending, func(sd bus.StopDirective) bool {
|
||||
return slices.Contains(ackStops, sd.TaskID)
|
||||
})
|
||||
stops = slices.Concat(stops, f.stopPending)
|
||||
f.pollCalls++
|
||||
return out, stops, nil
|
||||
}
|
||||
|
||||
func (f *fakeCP) ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.gpuReports = append(f.gpuReports, gpuReport{cluster: cluster, task: task, containers: containers})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeCP) gpuReportsFor(taskID string) []gpuReport {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []gpuReport
|
||||
for _, r := range f.gpuReports {
|
||||
if r.task == taskID {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeCP) registerCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.registers
|
||||
}
|
||||
|
||||
func (f *fakeCP) taskStates() []bus.TaskState {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]bus.TaskState(nil), f.states...)
|
||||
}
|
||||
|
||||
func (f *fakeCP) acks() [][]string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([][]string(nil), f.pollAcks...)
|
||||
}
|
||||
|
||||
func (f *fakeCP) stopAcks() [][]string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([][]string(nil), f.pollStopAcks...)
|
||||
}
|
||||
|
||||
func testIdentity() identity {
|
||||
return identity{
|
||||
AccountID: "123456789012",
|
||||
ClusterName: "default",
|
||||
InstanceID: "i-abc123",
|
||||
AZ: "us-east-1a",
|
||||
Hostname: "ecs-host",
|
||||
Capacity: bus.InstanceCapacity{CPU: 2048, MemoryMiB: 4096},
|
||||
AgentVersion: "test",
|
||||
}
|
||||
}
|
||||
|
||||
// testIdentityWithGPUs is testIdentity seeded with discovered GPU UUIDs, so
|
||||
// newAgent's ledger has real devices to pin from.
|
||||
func testIdentityWithGPUs(uuids ...string) identity {
|
||||
id := testIdentity()
|
||||
id.Capacity.GPU = len(uuids)
|
||||
id.Capacity.GPUIDs = uuids
|
||||
return id
|
||||
}
|
||||
|
||||
func TestRegistrar_Register(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
if err := newRegistrar(cp, testIdentity(), nil).Register(); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if cp.registerCount() != 1 {
|
||||
t.Errorf("registers = %d, want 1", cp.registerCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrar_RegisterError(t *testing.T) {
|
||||
cp := &fakeCP{registerErr: true}
|
||||
if err := newRegistrar(cp, testIdentity(), nil).Register(); err == nil {
|
||||
t.Fatal("expected register error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeater_Beat(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
if err := newHeartbeater(cp, testIdentity(), time.Second, nil).beat(); err != nil {
|
||||
t.Fatalf("beat: %v", err)
|
||||
}
|
||||
if cp.registerCount() != 1 {
|
||||
t.Errorf("beat should re-register once, got %d", cp.registerCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeater_DefaultInterval(t *testing.T) {
|
||||
h := newHeartbeater(&fakeCP{}, testIdentity(), 0, nil)
|
||||
if h.interval != defaultHeartbeat {
|
||||
t.Errorf("interval = %v, want default", h.interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeater_RunStopsOnContext(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { h.Run(ctx); close(done) }()
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run did not stop on context cancel")
|
||||
}
|
||||
if cp.registerCount() == 0 {
|
||||
t.Error("expected at least one re-register before cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrar_NoRuntimeAdvertisesNoCapacity is the placement half of the boot
|
||||
// bug: an agent with no container runtime kept registering its full CPU and
|
||||
// memory, so the scheduler placed tasks it could only fail to start. Capacity is
|
||||
// advertised only while a runtime is there to use it.
|
||||
func TestRegistrar_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
ready := false
|
||||
r := newRegistrar(cp, testIdentity(), func() bool { return ready })
|
||||
|
||||
if err := r.Register(); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
|
||||
t.Fatalf("advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
|
||||
ready = true
|
||||
if err := r.Register(); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
want := testIdentity().Capacity
|
||||
if got := cp.registered().Capacity; got.CPU != want.CPU || got.MemoryMiB != want.MemoryMiB {
|
||||
t.Fatalf("capacity not restored once the runtime resolved: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeater_NoRuntimeAdvertisesNoCapacity is the same rule on the beat,
|
||||
// which is how a runtime lost after boot reaches the control plane.
|
||||
func TestHeartbeater_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
h := newHeartbeater(cp, testIdentity(), time.Second, func() bool { return false })
|
||||
|
||||
if err := h.beat(); err != nil {
|
||||
t.Fatalf("beat: %v", err)
|
||||
}
|
||||
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
|
||||
t.Fatalf("beat advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
// The instance still identifies itself, so it stays visible as drained rather
|
||||
// than vanishing from the cluster.
|
||||
if cp.registered().InstanceID != testIdentity().InstanceID {
|
||||
t.Error("beat dropped the instance identity")
|
||||
}
|
||||
}
|
||||
+17
-170
@@ -1,196 +1,43 @@
|
||||
// eks-gateway-fetch runs inside the EKS K3s control-plane VM and fetches a
|
||||
// control-plane resource from the host through the AWS gateway, instead of
|
||||
// dialing core NATS directly. It is the read-side companion to
|
||||
// eks-gateway-publish.
|
||||
//
|
||||
// It SigV4-signs (service "eks") an HTTPS GET to the gateway, retrying with
|
||||
// backoff until the gateway returns 2xx or the attempt budget is exhausted (so
|
||||
// a degraded link surfaces as a non-zero exit), then emits the staged add-ons
|
||||
// as tab-separated lines the on-VM addon-sync shell agent consumes without a
|
||||
// JSON parser:
|
||||
//
|
||||
// <addonName>\t<addonVersion>\t<serviceAccountRoleArn>\t<base64(configurationValues)>
|
||||
//
|
||||
// The configuration values are base64-encoded so embedded tabs/newlines cannot
|
||||
// break the line framing.
|
||||
// control-plane resource from the host through the AWS gateway, writing it to
|
||||
// stdout as TSV; the implementation is in agents/eks/gatewayfetch.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
|
||||
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
|
||||
//
|
||||
// The recovery resource returns a single epoch\taction\tsnapshot line the on-VM
|
||||
// k3s-recovery agent applies before k3s starts (etcd cluster-reset / wipe-rejoin).
|
||||
//
|
||||
// Flags default to environment variables seeded by cloud-init:
|
||||
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
|
||||
// EKS_ACCOUNT_ID, EKS_CLUSTER_NAME, EKS_INSTANCE_ID.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
// stagedAddon mirrors the gateway's StagedAddonManifest wire shape; duplicated
|
||||
// here to keep this tiny VM binary free of the handler package's dependency
|
||||
// graph.
|
||||
type stagedAddon struct {
|
||||
AddonName string `json:"addonName"`
|
||||
AddonVersion string `json:"addonVersion"`
|
||||
ServiceAccountRoleArn string `json:"serviceAccountRoleArn"`
|
||||
ConfigurationValues string `json:"configurationValues"`
|
||||
}
|
||||
|
||||
type internalAddonsResponse struct {
|
||||
Addons []stagedAddon `json:"addons"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttempts = 30
|
||||
retryDelay = 5 * time.Second
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/gatewayfetch"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
gatewayURL string
|
||||
gatewayCA string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
accountID string
|
||||
clusterName string
|
||||
resource string
|
||||
instanceID string
|
||||
)
|
||||
|
||||
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
|
||||
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
|
||||
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(®ion, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
|
||||
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&instanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
|
||||
flag.StringVar(&resource, "resource", "addons", "Resource to fetch: addons | recovery")
|
||||
cfg := gatewayfetch.Config{Out: os.Stdout}
|
||||
flag.StringVar(&cfg.GatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
|
||||
flag.StringVar(&cfg.GatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
|
||||
flag.StringVar(&cfg.AccessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&cfg.SecretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(&cfg.Region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
|
||||
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
|
||||
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
|
||||
flag.Parse()
|
||||
|
||||
switch {
|
||||
case accountID == "":
|
||||
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
|
||||
case clusterName == "":
|
||||
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
|
||||
case resource != "addons" && resource != "recovery":
|
||||
fatal("--resource must be addons or recovery")
|
||||
case resource == "recovery" && instanceID == "":
|
||||
fatal("--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)")
|
||||
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
|
||||
slog.Error("eks-gateway-fetch: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("build gateway client: %v", err))
|
||||
}
|
||||
|
||||
var (
|
||||
path string
|
||||
emit func(io.Writer, []byte) error
|
||||
)
|
||||
switch resource {
|
||||
case "recovery":
|
||||
path = "/clusters/" + clusterName + "/internal-recovery/" + accountID + "/" + instanceID
|
||||
emit = emitRecoveryTSV
|
||||
default:
|
||||
path = "/clusters/" + clusterName + "/internal-addons/" + accountID
|
||||
emit = emitAddonsTSV
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
body, err := client.Get(path)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("eks-gateway-fetch: attempt failed", "resource", resource, "attempt", attempt, "err", err)
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if werr := emit(os.Stdout, body); werr != nil {
|
||||
fatal(fmt.Sprintf("render response: %v", werr))
|
||||
}
|
||||
return
|
||||
}
|
||||
fatal(fmt.Sprintf("fetch failed after %d attempts: %v", maxAttempts, lastErr))
|
||||
}
|
||||
|
||||
// emitAddonsTSV decodes the internal-addons response and writes one TSV line per
|
||||
// staged add-on to w. An empty add-on set writes nothing (the agent then GCs
|
||||
// every locally-rendered manifest), which is the correct steady state for a
|
||||
// cluster with no managed add-ons.
|
||||
func emitAddonsTSV(out io.Writer, body []byte) error {
|
||||
var resp internalAddonsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("unmarshal internal-addons response: %w", err)
|
||||
}
|
||||
w := bufio.NewWriter(out)
|
||||
for _, a := range resp.Addons {
|
||||
cfg := base64.StdEncoding.EncodeToString([]byte(a.ConfigurationValues))
|
||||
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", a.AddonName, a.AddonVersion, a.ServiceAccountRoleArn, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
// recoveryDirective mirrors the gateway's RecoveryDirective wire shape; duplicated
|
||||
// here to keep this tiny VM binary free of the handler package's dependency graph.
|
||||
type recoveryDirective struct {
|
||||
Epoch int64 `json:"epoch"`
|
||||
Action string `json:"action"`
|
||||
Snapshot string `json:"snapshot"`
|
||||
SnapshotRequired bool `json:"snapshotRequired"`
|
||||
}
|
||||
|
||||
type internalRecoveryResponse struct {
|
||||
Directive recoveryDirective `json:"directive"`
|
||||
}
|
||||
|
||||
// emitRecoveryTSV decodes the recovery directive and writes a single
|
||||
// epoch\taction\tsnapshot\tsnapshotRequired line the on-VM k3s-recovery agent
|
||||
// consumes without a JSON parser. An unset action defaults to "none" (steady
|
||||
// state); snapshotRequired is 1 when the snapshot MUST restore (fresh DR seed
|
||||
// aborts boot on fetch failure) else 0.
|
||||
func emitRecoveryTSV(out io.Writer, body []byte) error {
|
||||
var resp internalRecoveryResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("unmarshal internal-recovery response: %w", err)
|
||||
}
|
||||
d := resp.Directive
|
||||
if d.Action == "" {
|
||||
d.Action = "none"
|
||||
}
|
||||
required := "0"
|
||||
if d.SnapshotRequired {
|
||||
required = "1"
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot, required); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
slog.Error("eks-gateway-fetch: " + msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmitAddonsTSV(t *testing.T) {
|
||||
body := []byte(`{"addons":[
|
||||
{"addonName":"spinifex-noop","addonVersion":"0.1.0"},
|
||||
{"addonName":"aws-load-balancer-controller","addonVersion":"2.8.1",
|
||||
"serviceAccountRoleArn":"arn:aws:iam::111122223333:role/alb",
|
||||
"configurationValues":"{\"replicaCount\":2}"}
|
||||
]}`)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := emitAddonsTSV(&buf, body); err != nil {
|
||||
t.Fatalf("emitAddonsTSV: %v", err)
|
||||
}
|
||||
cfg := base64.StdEncoding.EncodeToString([]byte(`{"replicaCount":2}`))
|
||||
want := "spinifex-noop\t0.1.0\t\t\n" +
|
||||
"aws-load-balancer-controller\t2.8.1\tarn:aws:iam::111122223333:role/alb\t" + cfg + "\n"
|
||||
if buf.String() != want {
|
||||
t.Fatalf("TSV mismatch:\n got: %q\nwant: %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAddonsTSV_Empty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := emitAddonsTSV(&buf, []byte(`{"addons":[]}`)); err != nil {
|
||||
t.Fatalf("emitAddonsTSV: %v", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("empty addon set must emit nothing, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAddonsTSV_BadJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := emitAddonsTSV(&buf, []byte(`{not-json`)); err == nil {
|
||||
t.Fatal("expected error on malformed JSON")
|
||||
}
|
||||
}
|
||||
+17
-108
@@ -1,13 +1,6 @@
|
||||
// eks-gateway-publish runs inside the EKS K3s control-plane VM and relays a
|
||||
// single publication to the host through the AWS gateway, instead of dialing
|
||||
// core NATS directly.
|
||||
//
|
||||
// It reads a JSON payload on stdin, wraps it as
|
||||
// {accountId, channel, kind, payload}, SigV4-signs (service "eks") an HTTPS
|
||||
// POST to {gateway}/clusters/{cluster}/internal-publish, and retries with
|
||||
// backoff until the gateway returns 2xx or the attempt budget is exhausted —
|
||||
// so a degraded link surfaces as a non-zero exit rather than a silently
|
||||
// dropped message (the failure mode of fire-and-forget `nats pub`).
|
||||
// single JSON publication from stdin to the host through the AWS gateway; the
|
||||
// implementation is in agents/eks/gatewaypublish.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
@@ -20,115 +13,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
const (
|
||||
// Retry budget: the control plane reaches readiness before this runs, so a
|
||||
// failing POST means a degraded link, not a cold start. Bounded so a stuck
|
||||
// boot still terminates the OpenRC service.
|
||||
maxAttempts = 30
|
||||
retryDelay = 5 * time.Second
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/gatewaypublish"
|
||||
)
|
||||
|
||||
type publishBody struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Channel string `json:"channel"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
gatewayURL string
|
||||
gatewayCA string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
accountID string
|
||||
clusterName string
|
||||
channel string
|
||||
kind string
|
||||
)
|
||||
|
||||
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
|
||||
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
|
||||
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(®ion, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
|
||||
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&channel, "channel", "", "Publish channel: bootstrap|state|addon")
|
||||
flag.StringVar(&kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
|
||||
cfg := gatewaypublish.Config{Payload: os.Stdin}
|
||||
flag.StringVar(&cfg.GatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
|
||||
flag.StringVar(&cfg.GatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
|
||||
flag.StringVar(&cfg.AccessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&cfg.SecretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(&cfg.Region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
|
||||
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&cfg.Channel, "channel", "", "Publish channel: bootstrap|state|addon")
|
||||
flag.StringVar(&cfg.Kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
|
||||
flag.Parse()
|
||||
|
||||
switch {
|
||||
case accountID == "":
|
||||
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
|
||||
case clusterName == "":
|
||||
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
|
||||
case channel != "bootstrap" && channel != "state" && channel != "addon":
|
||||
fatal("--channel must be bootstrap, state or addon")
|
||||
case channel == "bootstrap" && kind == "":
|
||||
fatal("--kind is required for the bootstrap channel")
|
||||
if err := gatewaypublish.Run(context.Background(), cfg); err != nil {
|
||||
slog.Error("eks-gateway-publish: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("read stdin payload: %v", err))
|
||||
}
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
fatal("empty stdin payload")
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
fatal("stdin payload is not valid JSON")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publishBody{
|
||||
AccountID: accountID,
|
||||
Channel: channel,
|
||||
Kind: kind,
|
||||
Payload: json.RawMessage(payload),
|
||||
})
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("marshal request body: %v", err))
|
||||
}
|
||||
|
||||
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("build gateway client: %v", err))
|
||||
}
|
||||
path := "/clusters/" + clusterName + "/internal-publish"
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if _, err := client.Post(path, body); err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("eks-gateway-publish: attempt failed",
|
||||
"channel", channel, "kind", kind, "attempt", attempt, "err", err)
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
slog.Info("eks-gateway-publish: published", "channel", channel, "kind", kind)
|
||||
return
|
||||
}
|
||||
fatal(fmt.Sprintf("publish failed after %d attempts: %v", maxAttempts, lastErr))
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
slog.Error("eks-gateway-publish: " + msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
// Command eks-konnectivity-cert mints, then reuses, an ECDSA-P256 serving
|
||||
// certificate for the on-node konnectivity-server, signed by the K3s cluster CA.
|
||||
//
|
||||
// The konnectivity-agent (DaemonSet on workers) verifies the server it dials
|
||||
// using the in-pod ServiceAccount CA (/var/run/secrets/.../ca.crt), which in
|
||||
// K3s is the same server-ca that signs the apiserver. Signing the konnectivity
|
||||
// serving cert with that CA is therefore what lets the agent validate the
|
||||
// reverse-tunnel endpoint with no separate CA distribution.
|
||||
//
|
||||
// The eks-node AMI carries no openssl, so this is the Go equivalent: load the
|
||||
// K3s server CA cert+key, issue a leaf (ServerAuth) cert carrying the cluster's
|
||||
// konnectivity SANs (the NLB private-endpoint IP, public endpoint IP, NLB DNS),
|
||||
// and cache it under -dir so the OpenRC service is byte-stable across restarts.
|
||||
// Command eks-konnectivity-cert mints, then reuses, a K3s-CA-signed serving
|
||||
// certificate for the on-node konnectivity-server; the implementation is in
|
||||
// agents/eks/konnectivitycert.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
@@ -23,225 +13,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in")
|
||||
caCert := flag.String("ca-cert", "", "PEM path of the signing (K3s server) CA cert")
|
||||
caKey := flag.String("ca-key", "", "PEM path of the signing CA private key")
|
||||
cn := flag.String("cn", "konnectivity-server", "certificate common name")
|
||||
sansCSV := flag.String("sans", "", "comma-separated SANs (IP or DNS)")
|
||||
cfg := konnectivitycert.Config{Out: os.Stdout}
|
||||
flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in")
|
||||
flag.StringVar(&cfg.CACert, "ca-cert", "", "PEM path of the signing (K3s server) CA cert")
|
||||
flag.StringVar(&cfg.CAKey, "ca-key", "", "PEM path of the signing CA private key")
|
||||
flag.StringVar(&cfg.CN, "cn", "konnectivity-server", "certificate common name")
|
||||
flag.StringVar(&cfg.SANs, "sans", "", "comma-separated SANs (IP or DNS)")
|
||||
flag.Parse()
|
||||
|
||||
if *dir == "" || *caCert == "" || *caKey == "" || *sansCSV == "" {
|
||||
fatal("eks-konnectivity-cert: -dir, -ca-cert, -ca-key and -sans are required")
|
||||
if err := konnectivitycert.Run(context.Background(), cfg); err != nil {
|
||||
slog.Error("eks-konnectivity-cert: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
dns, ips := splitSANs(*sansCSV)
|
||||
if len(dns) == 0 && len(ips) == 0 {
|
||||
fatal("eks-konnectivity-cert: -sans lists no usable names")
|
||||
}
|
||||
|
||||
certPath, keyPath, err := ensureCert(*dir, *caCert, *caKey, *cn, dns, ips)
|
||||
if err != nil {
|
||||
fatal("eks-konnectivity-cert: " + err.Error())
|
||||
}
|
||||
fmt.Printf("%s\t%s\n", certPath, keyPath)
|
||||
}
|
||||
|
||||
// ensureCert returns the cached cert/key paths under dir, minting and persisting
|
||||
// a fresh CA-signed pair when none is present, readable, and still SAN-current.
|
||||
func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.IP) (string, string, error) {
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
|
||||
if certCoversSANs(certPath, keyPath, dns, ips) {
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPEM, keyPEM, err := issueLeaf(caCert, caKey, cn, dns, ips)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", "", fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return "", "", fmt.Errorf("write %s: %w", certPath, err)
|
||||
}
|
||||
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return "", "", fmt.Errorf("write %s: %w", keyPath, err)
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
// certCoversSANs reports whether the cached cert exists, parses, has a key, and
|
||||
// already carries every requested SAN — so a cert minted before the endpoint
|
||||
// set changed is re-issued rather than served stale.
|
||||
func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool {
|
||||
c, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if k, kerr := os.ReadFile(keyPath); kerr != nil || len(k) == 0 {
|
||||
return false
|
||||
}
|
||||
der := decodeFirst(c)
|
||||
if der == nil {
|
||||
return false
|
||||
}
|
||||
crt, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
have := make(map[string]struct{}, len(crt.DNSNames)+len(crt.IPAddresses))
|
||||
for _, n := range crt.DNSNames {
|
||||
have[n] = struct{}{}
|
||||
}
|
||||
for _, ip := range crt.IPAddresses {
|
||||
have[ip.String()] = struct{}{}
|
||||
}
|
||||
for _, n := range dns {
|
||||
if _, ok := have[n]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if _, ok := have[ip.String()]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
|
||||
func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err)
|
||||
}
|
||||
der := decodeFirst(certPEM)
|
||||
if der == nil {
|
||||
return nil, nil, fmt.Errorf("ca cert %s: no PEM block", certPath)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse ca cert: %w", err)
|
||||
}
|
||||
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read ca key %s: %w", keyPath, err)
|
||||
}
|
||||
keyDER := decodeFirst(keyPEM)
|
||||
if keyDER == nil {
|
||||
return nil, nil, fmt.Errorf("ca key %s: no PEM block", keyPath)
|
||||
}
|
||||
caKey, err := parseECKey(keyDER)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return caCert, caKey, nil
|
||||
}
|
||||
|
||||
// parseECKey accepts the EC SEC1 or PKCS8 encodings K3s may write for the CA key.
|
||||
func parseECKey(der []byte) (*ecdsa.PrivateKey, error) {
|
||||
if k, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ca key (sec1 and pkcs8 failed): %w", err)
|
||||
}
|
||||
ec, ok := k8.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ca key is %T, want ecdsa", k8)
|
||||
}
|
||||
return ec, nil
|
||||
}
|
||||
|
||||
// issueLeaf mints an ECDSA-P256 ServerAuth leaf for cn + SANs, signed by caCert/caKey.
|
||||
func issueLeaf(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, dns []string, ips []net.IP) (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: dns,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, caCert, &priv.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// splitSANs partitions the CSV into DNS names and IPs (an entry that parses as an
|
||||
// IP goes to IPAddresses, else DNSNames), matching how SAN matching is typed.
|
||||
func splitSANs(s string) (dns []string, ips []net.IP) {
|
||||
for p := range strings.SplitSeq(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(p); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
dns = append(dns, p)
|
||||
}
|
||||
return dns, ips
|
||||
}
|
||||
|
||||
// decodeFirst returns the DER bytes of the first PEM block, or nil.
|
||||
func decodeFirst(pemBytes []byte) []byte {
|
||||
blk, _ := pem.Decode(pemBytes)
|
||||
if blk == nil {
|
||||
return nil
|
||||
}
|
||||
return blk.Bytes
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
slog.Error(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeTestCA mints a self-signed EC CA (cert + SEC1 key PEM) into dir, returning
|
||||
// the two paths — a stand-in for K3s' /var/lib/rancher/k3s/server/tls/server-ca.*.
|
||||
func writeTestCA(t *testing.T, dir string) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen ca key: %v", err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "k3s-server-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create ca cert: %v", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ca key: %v", err)
|
||||
}
|
||||
certPath = filepath.Join(dir, "server-ca.crt")
|
||||
keyPath = filepath.Join(dir, "server-ca.key")
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
t.Fatalf("write ca cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
t.Fatalf("write ca key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
func leafFrom(t *testing.T, certPath string) *x509.Certificate {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read leaf: %v", err)
|
||||
}
|
||||
crt, err := x509.ParseCertificate(decodeFirst(b))
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf: %v", err)
|
||||
}
|
||||
return crt
|
||||
}
|
||||
|
||||
func TestEnsureCertSignsLeafWithSANsThatChainsToCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caCert, caKey := writeTestCA(t, dir)
|
||||
|
||||
dns, ips := splitSANs("10.32.100.4,203.0.113.9,eks-toc.example")
|
||||
certPath, keyPath, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureCert: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
t.Fatalf("key not written: %v", err)
|
||||
}
|
||||
|
||||
leaf := leafFrom(t, certPath)
|
||||
if leaf.IsCA {
|
||||
t.Error("konnectivity serving cert must be a leaf, not a CA")
|
||||
}
|
||||
// The agent dials the NLB private-endpoint IP and verifies via the in-pod CA,
|
||||
// so the IP must be a SAN or the tunnel TLS handshake fails.
|
||||
if err := leaf.VerifyHostname("10.32.100.4"); err != nil {
|
||||
t.Errorf("IP SAN 10.32.100.4 missing: %v", err)
|
||||
}
|
||||
|
||||
// The leaf must chain to the K3s server CA — that CA is the in-pod ca.crt the
|
||||
// agent trusts, so no separate CA distribution is needed.
|
||||
caPEM, _ := os.ReadFile(caCert)
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("append CA failed")
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
DNSName: "eks-toc.example",
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}); err != nil {
|
||||
t.Errorf("leaf does not chain to the K3s server CA: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCertReusesThenReissuesOnSANChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caCert, caKey := writeTestCA(t, dir)
|
||||
|
||||
dns, ips := splitSANs("10.32.100.4,eks-toc.example")
|
||||
c1, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips)
|
||||
if err != nil {
|
||||
t.Fatalf("first ensureCert: %v", err)
|
||||
}
|
||||
first, _ := os.ReadFile(c1)
|
||||
|
||||
// Same SANs → cached pair reused byte-for-byte (a churning cert would bounce
|
||||
// the konnectivity-server on every restart).
|
||||
if _, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips); err != nil {
|
||||
t.Fatalf("second ensureCert: %v", err)
|
||||
}
|
||||
if second, _ := os.ReadFile(c1); string(first) != string(second) {
|
||||
t.Error("ensureCert re-minted instead of reusing the SAN-current cached cert")
|
||||
}
|
||||
|
||||
// A new endpoint in the SAN set must force a re-issue.
|
||||
dns2, ips2 := splitSANs("10.32.100.4,eks-toc.example,10.32.100.99")
|
||||
if _, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns2, ips2); err != nil {
|
||||
t.Fatalf("third ensureCert: %v", err)
|
||||
}
|
||||
if third, _ := os.ReadFile(c1); string(first) == string(third) {
|
||||
t.Error("ensureCert served a stale cert that does not cover the new SAN")
|
||||
}
|
||||
if err := leafFrom(t, c1).VerifyHostname("10.32.100.99"); err != nil {
|
||||
t.Errorf("re-issued cert missing new SAN: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSANsPartitionsIPsAndDNS(t *testing.T) {
|
||||
dns, ips := splitSANs(" 10.0.0.1 , host.example , , 2001:db8::1 ")
|
||||
if len(dns) != 1 || dns[0] != "host.example" {
|
||||
t.Errorf("dns = %v, want [host.example]", dns)
|
||||
}
|
||||
wantIPs := []net.IP{net.ParseIP("10.0.0.1"), net.ParseIP("2001:db8::1")}
|
||||
if len(ips) != len(wantIPs) {
|
||||
t.Fatalf("ips = %v, want %v", ips, wantIPs)
|
||||
}
|
||||
for i, ip := range ips {
|
||||
if !ip.Equal(wantIPs[i]) {
|
||||
t.Errorf("ips[%d] = %v, want %v", i, ip, wantIPs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
|
||||
)
|
||||
|
||||
// serverTLSConfig is the webhook's loopback serving TLS config.
|
||||
func serverTLSConfig(cert tls.Certificate) *tls.Config {
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: tlsconfig.Curves,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureServingCert loads the persisted self-signed serving cert/key, or mints
|
||||
// a fresh ECDSA-P256 pair (SAN 127.0.0.1 / localhost) and persists it. Reusing
|
||||
// across restarts keeps the CA the apiserver loaded into its webhook kubeconfig
|
||||
// valid even if the webhook process is restarted. Returns the parsed cert and
|
||||
// the certificate PEM (for embedding as the kubeconfig CA bundle).
|
||||
func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error) {
|
||||
// Fall through to regenerate on a missing/corrupt/incompatible persisted
|
||||
// pair. The cert PEM is re-read because tls.Certificate keeps only DER.
|
||||
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
if certPEM, err := os.ReadFile(certPath); err == nil {
|
||||
return cert, certPEM, nil
|
||||
}
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err := generateSelfSigned()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, nil, err
|
||||
}
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return tls.Certificate{}, nil, fmt.Errorf("write cert %s: %w", certPath, err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return tls.Certificate{}, nil, fmt.Errorf("write key %s: %w", keyPath, err)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, nil, fmt.Errorf("parse minted keypair: %w", err)
|
||||
}
|
||||
return cert, certPEM, nil
|
||||
}
|
||||
|
||||
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "eks-token-webhook"},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// writeAPIServerKubeconfig writes the kubeconfig kube-apiserver loads via
|
||||
// --authentication-token-webhook-config-file. It points the apiserver at the
|
||||
// loopback webhook and trusts its self-signed serving cert as the CA bundle.
|
||||
func writeAPIServerKubeconfig(path, addr string, certPEM []byte) error {
|
||||
caB64 := base64.StdEncoding.EncodeToString(certPEM)
|
||||
kubeconfig := fmt.Sprintf(`apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: mulga-eks-token-webhook
|
||||
cluster:
|
||||
certificate-authority-data: %s
|
||||
server: https://%s/authenticate
|
||||
users:
|
||||
- name: kube-apiserver
|
||||
contexts:
|
||||
- name: webhook
|
||||
context:
|
||||
cluster: mulga-eks-token-webhook
|
||||
user: kube-apiserver
|
||||
current-context: webhook
|
||||
`, caB64, addr)
|
||||
if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+24
-179
@@ -1,76 +1,45 @@
|
||||
// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
|
||||
// the eks-server AMI. Per kubectl call the kube-apiserver POSTs the bearer
|
||||
// token (produced by `aws eks get-token`); the webhook relays it through the
|
||||
// AWS gateway broker (SigV4 HTTPS POST to /clusters/{name}/token-review), which
|
||||
// host-side runs the STS verify (cross-cluster x-k8s-aws-id pin) + AccessEntry
|
||||
// KV lookup and returns the resolved identity. The webhook never speaks core
|
||||
// NATS — same gateway-broker model as ELBv2's lb-agent and eks-gateway-publish.
|
||||
//
|
||||
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
|
||||
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
|
||||
// serving CA) so k3s can be pointed at it via
|
||||
// --authentication-token-webhook-config-file.
|
||||
//
|
||||
// SigV4 creds come from the AWS SDK chain (IMDS instance-role) when static
|
||||
// EKS_ACCESS_KEY/EKS_SECRET_KEY are absent, matching the sibling helpers.
|
||||
// Command eks-token-webhook is the K8s TokenReview webhook authenticator baked
|
||||
// into the eks-server AMI; the implementation is in agents/eks/tokenwebhook.
|
||||
// It reads its configuration from the first-boot env cloud-init seeds onto the
|
||||
// VM and binds loopback only.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/tokenwebhook"
|
||||
)
|
||||
|
||||
// config is the webhook's runtime configuration, sourced from the first-boot
|
||||
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
|
||||
type config struct {
|
||||
addr string
|
||||
gatewayURL string
|
||||
gatewayCA string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
accountID string
|
||||
clusterName string
|
||||
certPath string
|
||||
keyPath string
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
c := config{
|
||||
addr: flagAddr,
|
||||
gatewayURL: os.Getenv("EKS_GATEWAY_URL"),
|
||||
gatewayCA: os.Getenv("EKS_GATEWAY_CA"),
|
||||
accessKey: os.Getenv("EKS_ACCESS_KEY"),
|
||||
secretKey: os.Getenv("EKS_SECRET_KEY"),
|
||||
region: os.Getenv("EKS_REGION"),
|
||||
accountID: os.Getenv("EKS_ACCOUNT_ID"),
|
||||
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
|
||||
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
|
||||
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
|
||||
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
|
||||
func loadConfig() (tokenwebhook.Config, error) {
|
||||
c := tokenwebhook.Config{
|
||||
Addr: flagAddr,
|
||||
GatewayURL: os.Getenv("EKS_GATEWAY_URL"),
|
||||
GatewayCA: os.Getenv("EKS_GATEWAY_CA"),
|
||||
AccessKey: os.Getenv("EKS_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("EKS_SECRET_KEY"),
|
||||
Region: os.Getenv("EKS_REGION"),
|
||||
AccountID: os.Getenv("EKS_ACCOUNT_ID"),
|
||||
ClusterName: os.Getenv("EKS_CLUSTER_NAME"),
|
||||
CertPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
|
||||
KeyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
|
||||
Kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
|
||||
}
|
||||
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
|
||||
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
|
||||
// sibling helpers use. The CP VM launches on an instance profile, so
|
||||
// buildK3sUserData omits EKS_ACCESS_KEY/EKS_SECRET_KEY by design.
|
||||
switch {
|
||||
case c.gatewayURL == "":
|
||||
case c.GatewayURL == "":
|
||||
return c, errors.New("EKS_GATEWAY_URL not set")
|
||||
case c.accountID == "":
|
||||
case c.AccountID == "":
|
||||
return c, errors.New("EKS_ACCOUNT_ID not set")
|
||||
case c.clusterName == "":
|
||||
case c.ClusterName == "":
|
||||
return c, errors.New("EKS_CLUSTER_NAME not set")
|
||||
}
|
||||
return c, nil
|
||||
@@ -97,132 +66,8 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := run(cfg); err != nil {
|
||||
if err := tokenwebhook.Run(context.Background(), cfg); err != nil {
|
||||
slog.Error("eks-token-webhook fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfg config) error {
|
||||
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
|
||||
// so a webhook crash does not invalidate the CA the apiserver already loaded.
|
||||
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serving cert: %w", err)
|
||||
}
|
||||
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
|
||||
return fmt.Errorf("write apiserver kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
client, err := eksgw.New(cfg.gatewayURL, cfg.gatewayCA, cfg.accessKey, cfg.secretKey, cfg.region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway client: %w", err)
|
||||
}
|
||||
|
||||
authr := &authenticator{
|
||||
accountID: cfg.accountID,
|
||||
clusterName: cfg.clusterName,
|
||||
review: gatewayReviewer(client, cfg.clusterName, cfg.accountID),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/authenticate", authr.handle)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
TLSConfig: serverTLSConfig(tlsCert),
|
||||
}
|
||||
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
|
||||
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tokenReviewRequest is the body POSTed to /clusters/{name}/token-review. The
|
||||
// webhook holds system SigV4 creds (system account, not the cluster account),
|
||||
// so accountId names the cluster account explicitly — same as PublishInternal.
|
||||
type tokenReviewRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
|
||||
// the gateway and decode the resolved identity. A non-2xx / transport error is
|
||||
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
|
||||
// silent deny, distinguishing "webhook broker down" from "token rejected".
|
||||
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
path := "/clusters/" + clusterName + "/token-review"
|
||||
return func(token string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
body, err := json.Marshal(tokenReviewRequest{AccountID: accountID, Token: token})
|
||||
if err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
respBody, err := client.Post(path, body)
|
||||
if err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, err
|
||||
}
|
||||
var res handlers_eks.WebhookTokenReviewResult
|
||||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
// authenticator resolves a TokenReview by relaying to the gateway broker.
|
||||
type authenticator struct {
|
||||
accountID string
|
||||
clusterName string
|
||||
review func(token string) (handlers_eks.WebhookTokenReviewResult, error)
|
||||
}
|
||||
|
||||
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var review tokenReview
|
||||
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.review(review.Spec.Token)
|
||||
if err != nil {
|
||||
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
|
||||
// to retry rather than treating a transient outage as a hard deny.
|
||||
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
|
||||
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
status := tokenReviewStatus{Authenticated: res.Authenticated}
|
||||
if res.Authenticated {
|
||||
status.User = userInfo{
|
||||
Username: res.Username,
|
||||
UID: res.UID,
|
||||
Groups: res.Groups,
|
||||
}
|
||||
// Confirm the token for the audiences the apiserver asked about; an empty
|
||||
// status.audiences is rejected when --api-audiences is configured.
|
||||
status.Audiences = review.Spec.Audiences
|
||||
}
|
||||
slog.Info("TokenReview decision", "authenticated", status.Authenticated, "username", status.User.Username, "audiences", status.Audiences)
|
||||
|
||||
resp := tokenReview{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Status: status,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
slog.Error("encode TokenReview response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// postReview drives authenticator.handle with a TokenReview carrying token and
|
||||
// audiences, returning the decoded response status and HTTP code.
|
||||
func postReview(t *testing.T, a *authenticator, token string, audiences []string) (tokenReviewStatus, int) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(tokenReview{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Spec: tokenReviewSpec{Token: token, Audiences: audiences},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/authenticate", strings.NewReader(string(body)))
|
||||
w := httptest.NewRecorder()
|
||||
a.handle(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
return tokenReviewStatus{}, w.Code
|
||||
}
|
||||
var resp tokenReview
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
return resp.Status, w.Code
|
||||
}
|
||||
|
||||
func TestHandle_GrantsAndStampsAudiences(t *testing.T) {
|
||||
a := &authenticator{
|
||||
accountID: "111122223333",
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{
|
||||
Authenticated: true,
|
||||
Username: "arn:aws:iam::111122223333:role/admin",
|
||||
UID: "AROAEXAMPLE:session",
|
||||
Groups: []string{"system:masters"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.True(t, status.Authenticated)
|
||||
assert.Equal(t, "arn:aws:iam::111122223333:role/admin", status.User.Username)
|
||||
assert.Equal(t, "AROAEXAMPLE:session", status.User.UID)
|
||||
assert.Equal(t, []string{"system:masters"}, status.User.Groups)
|
||||
assert.Equal(t, []string{"https://kubernetes.default.svc"}, status.Audiences)
|
||||
}
|
||||
|
||||
func TestHandle_DeniesWithoutStampingAudiences(t *testing.T) {
|
||||
a := &authenticator{
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{Authenticated: false}, nil
|
||||
},
|
||||
}
|
||||
|
||||
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.False(t, status.Authenticated)
|
||||
assert.Empty(t, status.User.Username)
|
||||
assert.Empty(t, status.Audiences, "unauthenticated review must not stamp audiences")
|
||||
}
|
||||
|
||||
// A broker fault is a 5xx (retryable), not a silent deny — a transient gateway
|
||||
// outage must not look identical to a rejected token.
|
||||
func TestHandle_BrokerFaultReturns5xx(t *testing.T) {
|
||||
a := &authenticator{
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, errors.New("gateway returned 503")
|
||||
},
|
||||
}
|
||||
|
||||
_, code := postReview(t, a, "tok", nil)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, code)
|
||||
}
|
||||
|
||||
func TestEnsureServingCert_PersistsAndReuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "wh.crt")
|
||||
keyPath := filepath.Join(dir, "wh.key")
|
||||
|
||||
c1, pem1, err := ensureServingCert(certPath, keyPath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pem1)
|
||||
require.NotNil(t, serverTLSConfig(c1))
|
||||
|
||||
// Second call reuses the persisted pair (same cert bytes).
|
||||
_, pem2, err := ensureServingCert(certPath, keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pem1, pem2)
|
||||
}
|
||||
|
||||
func TestWriteAPIServerKubeconfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "wh.kubeconfig")
|
||||
certPEM := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
|
||||
|
||||
require.NoError(t, writeAPIServerKubeconfig(path, "127.0.0.1:8443", certPEM))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
assert.Contains(t, body, "server: https://127.0.0.1:8443/authenticate")
|
||||
assert.Contains(t, body, base64.StdEncoding.EncodeToString(certPEM))
|
||||
assert.Contains(t, body, "current-context: webhook")
|
||||
}
|
||||
|
||||
// The CP VM launches on an instance profile, so cloud-init omits the static
|
||||
// EKS_ACCESS_KEY/EKS_SECRET_KEY and eksgw.New signs with IMDS creds. loadConfig
|
||||
// must accept absent static keys; gating on them aborts every IMDS-mode boot,
|
||||
@@ -148,8 +33,8 @@ func TestLoadConfig_AcceptsAbsentStaticCreds(t *testing.T) {
|
||||
})
|
||||
cfg, err := loadConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cfg.accessKey)
|
||||
assert.Empty(t, cfg.secretKey)
|
||||
assert.Empty(t, cfg.AccessKey)
|
||||
assert.Empty(t, cfg.SecretKey)
|
||||
|
||||
// Genuinely-required vars are still enforced.
|
||||
setEnv(t, map[string]string{
|
||||
|
||||
+13
-127
@@ -1,12 +1,6 @@
|
||||
// Command eks-webhook-cert mints, then reuses, a self-signed ECDSA-P256 serving
|
||||
// certificate for an in-cluster admission webhook and prints its base64 PEM so
|
||||
// the addon renderer can embed it as the webhook Secret + each caBundle.
|
||||
//
|
||||
// The eks-node AMI carries no openssl, so this is the Go equivalent of the
|
||||
// upstream Helm chart's install-time genSelfSignedCert: one cert per cluster,
|
||||
// cached on disk under -dir, stable across addon-sync ticks. The single cert is
|
||||
// its own CA (IsCA + ServerAuth), so the same PEM serves as both the TLS leaf
|
||||
// and the caBundle the apiserver trusts.
|
||||
// Command eks-webhook-cert mints, then reuses, a self-signed serving certificate
|
||||
// for an in-cluster admission webhook and prints its base64 PEM; the
|
||||
// implementation is in agents/eks/webhookcert.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
@@ -17,133 +11,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/webhookcert"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in")
|
||||
cn := flag.String("cn", "", "certificate common name")
|
||||
dnsCSV := flag.String("dns", "", "comma-separated DNS SANs")
|
||||
cfg := webhookcert.Config{Out: os.Stdout}
|
||||
flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in")
|
||||
flag.StringVar(&cfg.CN, "cn", "", "certificate common name")
|
||||
flag.StringVar(&cfg.DNS, "dns", "", "comma-separated DNS SANs")
|
||||
flag.Parse()
|
||||
|
||||
if *dir == "" || *cn == "" || *dnsCSV == "" {
|
||||
fatal("eks-webhook-cert: -dir, -cn and -dns are required")
|
||||
if err := webhookcert.Run(context.Background(), cfg); err != nil {
|
||||
slog.Error("eks-webhook-cert: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
dnsNames := splitCSV(*dnsCSV)
|
||||
if len(dnsNames) == 0 {
|
||||
fatal("eks-webhook-cert: -dns lists no names")
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err := ensureCert(*dir, *cn, dnsNames)
|
||||
if err != nil {
|
||||
fatal("eks-webhook-cert: " + err.Error())
|
||||
}
|
||||
b := base64.StdEncoding.EncodeToString
|
||||
fmt.Printf("%s\t%s\t%s\n", b(certPEM), b(certPEM), b(keyPEM))
|
||||
}
|
||||
|
||||
// ensureCert returns the cached cert/key under dir, minting and persisting a
|
||||
// fresh self-signed pair (for cn + dnsNames) when none is present or readable.
|
||||
func ensureCert(dir, cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
|
||||
if c, cerr := os.ReadFile(certPath); cerr == nil {
|
||||
if k, kerr := os.ReadFile(keyPath); kerr == nil {
|
||||
if _, perr := x509.ParseCertificate(decodeFirst(c)); perr == nil && len(k) > 0 {
|
||||
return c, k, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err = generateSelfSigned(cn, dnsNames)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return nil, nil, fmt.Errorf("write %s: %w", certPath, err)
|
||||
}
|
||||
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return nil, nil, fmt.Errorf("write %s: %w", keyPath, err)
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// generateSelfSigned mints an ECDSA-P256 self-signed cert valid as both a CA and
|
||||
// a server cert, with the given common name and DNS SANs.
|
||||
func generateSelfSigned(cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
DNSNames: dnsNames,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// decodeFirst returns the DER bytes of the first PEM block, or nil.
|
||||
func decodeFirst(pemBytes []byte) []byte {
|
||||
blk, _ := pem.Decode(pemBytes)
|
||||
if blk == nil {
|
||||
return nil
|
||||
}
|
||||
return blk.Bytes
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for p := range strings.SplitSeq(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
slog.Error(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testCN = "aws-load-balancer-webhook-service.kube-system.svc"
|
||||
testDNS = "aws-load-balancer-webhook-service.kube-system.svc.cluster.local"
|
||||
)
|
||||
|
||||
func parseLeaf(t *testing.T, certPEM []byte) *x509.Certificate {
|
||||
t.Helper()
|
||||
leaf, err := x509.ParseCertificate(decodeFirst(certPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
func TestEnsureCertMintsServingCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, keyPEM, err := ensureCert(dir, testCN, []string{testCN, testDNS})
|
||||
if err != nil {
|
||||
t.Fatalf("ensureCert: %v", err)
|
||||
}
|
||||
if len(keyPEM) == 0 {
|
||||
t.Fatal("empty key PEM")
|
||||
}
|
||||
leaf := parseLeaf(t, certPEM)
|
||||
|
||||
// Both webhook service DNS names must be present, or the apiserver's TLS
|
||||
// dial to the webhook fails SAN verification.
|
||||
for _, want := range []string{testCN, testDNS} {
|
||||
if err := leaf.VerifyHostname(want); err != nil {
|
||||
t.Errorf("SAN %q missing: %v", want, err)
|
||||
}
|
||||
}
|
||||
if !leaf.IsCA {
|
||||
t.Error("cert must be a CA so the same PEM serves as the webhook caBundle")
|
||||
}
|
||||
|
||||
// The single self-signed cert must verify against itself as a root — this is
|
||||
// exactly what the apiserver does with caBundle == the serving cert.
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
DNSName: testCN,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}); err != nil {
|
||||
t.Errorf("self-signed cert does not verify as its own CA: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCertReusesCachedPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert1, key1, err := ensureCert(dir, testCN, []string{testCN})
|
||||
if err != nil {
|
||||
t.Fatalf("first ensureCert: %v", err)
|
||||
}
|
||||
cert2, key2, err := ensureCert(dir, testCN, []string{testCN})
|
||||
if err != nil {
|
||||
t.Fatalf("second ensureCert: %v", err)
|
||||
}
|
||||
if string(cert1) != string(cert2) || string(key1) != string(key2) {
|
||||
t.Error("ensureCert regenerated instead of reusing the cached pair; a churning cert re-applies the addon every tick")
|
||||
}
|
||||
|
||||
// The private key must not be world-readable on disk.
|
||||
info, err := os.Stat(filepath.Join(dir, "tls.key"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat key: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
t.Errorf("tls.key mode %o is group/other-accessible", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strings"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/ui"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/installer/install"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/installer/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// diskModel is a storage screen with n unselected disks and ext4 chosen, which
|
||||
// is where the wizard starts.
|
||||
func diskModel(n int) model {
|
||||
m := model{fsCursor: slices.Index(install.AllFSTypes, install.FSExt4)}
|
||||
for i := range n {
|
||||
m.disks = append(m.disks, install.Disk{
|
||||
Path: string(rune('a'+i)) + "-disk",
|
||||
Bytes: 200 << 30,
|
||||
LogicalBlockSize: 512,
|
||||
PhysicalBlockSize: 512,
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func rolePaths(cfg install.DiskConfig) map[install.DiskRole]string {
|
||||
out := map[install.DiskRole]string{}
|
||||
for _, rm := range cfg.Roles {
|
||||
out[rm.Role] = rm.Disk.Path
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A single-disk machine should need no storage input at all, so the disk that
|
||||
// newModel preselects has to arrive already holding the os role.
|
||||
func TestPreselectedDiskIsAValidConfiguration(t *testing.T) {
|
||||
m := newModel([]install.Disk{{Path: "/dev/sda", Bytes: 200 << 30, LogicalBlockSize: 512}}, nil)
|
||||
cfg := m.storage()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("the preselected disk must be installable as-is: %v", err)
|
||||
}
|
||||
if cfg.Primary().Path != "/dev/sda" {
|
||||
t.Errorf("Primary() = %q, want the preselected disk", cfg.Primary().Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Picking disks in order gives the layout an operator almost always wants, so
|
||||
// the role key is only needed for the exceptions.
|
||||
func TestSelectingDisksAssignsRolesInOrder(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
got := rolePaths(m.storage())
|
||||
for role, want := range map[install.DiskRole]string{
|
||||
install.RoleOS: "a-disk", install.RoleSpinifex: "b-disk", install.RolePredastore: "c-disk",
|
||||
} {
|
||||
if got[role] != want {
|
||||
t.Errorf("%s = %q, want %q", role, got[role], want)
|
||||
}
|
||||
}
|
||||
if err := m.storage().Validate(); err != nil {
|
||||
t.Errorf("the default assignment must be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deselecting has to drop the role with the disk, or every later role shifts
|
||||
// onto the wrong drive.
|
||||
func TestDeselectingADiskReleasesItsRole(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
m = m.toggleDisk(1) // drop the spinifex drive
|
||||
|
||||
got := rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "c-disk" {
|
||||
t.Fatalf("remaining roles moved: %v", got)
|
||||
}
|
||||
if _, ok := got[install.RoleSpinifex]; ok {
|
||||
t.Errorf("the spinifex role should be free again: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The two-drive predastore layout is only reachable by reassigning, so cycling
|
||||
// swaps rather than refusing a role another disk already holds.
|
||||
func TestCycleRoleSwapsWithTheDiskThatHoldsIt(t *testing.T) {
|
||||
m := diskModel(2)
|
||||
m = m.toggleDisk(0).toggleDisk(1)
|
||||
// b-disk: spinifex → predastore, which no disk holds, so no swap.
|
||||
m = m.cycleRole(1)
|
||||
|
||||
got := rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "b-disk" {
|
||||
t.Fatalf("roles = %v, want os on a-disk and predastore on b-disk", got)
|
||||
}
|
||||
|
||||
// Cycling past the end wraps onto os, which a-disk holds, so they trade.
|
||||
m = m.cycleRole(1)
|
||||
got = rolePaths(m.storage())
|
||||
if got[install.RoleOS] != "b-disk" || got[install.RolePredastore] != "a-disk" {
|
||||
t.Fatalf("roles = %v, want the two disks to have swapped", got)
|
||||
}
|
||||
if err := m.storage().Validate(); err != nil {
|
||||
t.Errorf("a swap must always leave a valid assignment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ZFS pairs mirrors by selection order and has no roles to assign.
|
||||
func TestStorageLeavesRolesUnsetOnZFS(t *testing.T) {
|
||||
m := diskModel(2)
|
||||
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
|
||||
m = m.toggleDisk(0).toggleDisk(1)
|
||||
|
||||
cfg := m.storage()
|
||||
if len(cfg.Roles) != 0 {
|
||||
t.Errorf("Roles = %v, want none on a pool", cfg.Roles)
|
||||
}
|
||||
if !slices.Equal(cfg.Paths(), []string{"a-disk", "b-disk"}) {
|
||||
t.Errorf("Paths() = %v, want the selection order preserved", cfg.Paths())
|
||||
}
|
||||
}
|
||||
|
||||
// A three-drive selection looks like an array and is not one, so the mapping
|
||||
// and the absence of redundancy both have to be on screen before the erase.
|
||||
func TestConfirmScreenStatesTheLayoutAndTheRisk(t *testing.T) {
|
||||
m := diskModel(3)
|
||||
m.height = 40
|
||||
for i := range 3 {
|
||||
m = m.toggleDisk(i)
|
||||
}
|
||||
got := m.viewDiskConfirm(100)
|
||||
|
||||
for _, want := range []string{"/var/lib/spinifex/predastore", "c-disk", "losing one loses"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("confirm screen is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/rdsgw"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/mulgadc/spinifex/spinifex/otelsetup"
|
||||
)
|
||||
|
||||
// Overridable via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// The gateway resolves the authoritative DB instance from the request's
|
||||
// credentials, so DBInstanceIdentifier is an assertion to be checked, not
|
||||
// trusted.
|
||||
type identity struct {
|
||||
DBInstanceIdentifier string
|
||||
AgentVersion string
|
||||
EngineVersion string
|
||||
}
|
||||
|
||||
// Every method is a SigV4-signed Query request, so the NATS bus stays
|
||||
// host-internal.
|
||||
type controlPlane interface {
|
||||
Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error)
|
||||
SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error)
|
||||
// Returns Mode=initialize with the master password for as long as a payload
|
||||
// staged for this VM generation is unacknowledged, and Mode=attach after.
|
||||
GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error)
|
||||
// Reports that PostgreSQL durably applied the staged password, which is what
|
||||
// lets the control plane destroy it.
|
||||
AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error
|
||||
PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error)
|
||||
}
|
||||
|
||||
// Bounds a register, heartbeat or bootstrap request. The long poll sets its own
|
||||
// deadline from the wait it asked the gateway to hold.
|
||||
const callTimeout = 15 * time.Second
|
||||
|
||||
// Keeps the poll's deadline past the requested wait, so a gateway answering at
|
||||
// the end of its window is not cut off by the client.
|
||||
const pollSlack = 10 * time.Second
|
||||
|
||||
type gatewayControlPlane struct {
|
||||
client *rdsgw.Client
|
||||
}
|
||||
|
||||
var _ controlPlane = (*gatewayControlPlane)(nil)
|
||||
|
||||
// Signs with the instance-role credentials the SDK chain resolves from IMDS,
|
||||
// against the pinned gateway CA.
|
||||
func newGatewayControlPlane(cfg config) (*gatewayControlPlane, error) {
|
||||
signer, err := gwsign.NewIMDS(context.Background(), cfg.Region)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IMDS signer: %w", err)
|
||||
}
|
||||
// The client timeout is the ceiling for the longest call, the long poll;
|
||||
// shorter calls carry their own deadline.
|
||||
client, err := rdsgw.New(cfg.GatewayURL, cfg.GatewayCA, signer, cfg.Region, cfg.PollWait+pollSlack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gatewayControlPlane{client: client}, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
|
||||
params := url.Values{}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "AgentVersion", id.AgentVersion)
|
||||
setIfPresent(params, "EngineVersion", id.EngineVersion)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.RegisterDBInstanceOutput
|
||||
if err := g.client.Call(ctx, "RegisterDBInstance", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
|
||||
params := url.Values{"EngineHealth": {string(health)}}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "EngineVersion", id.EngineVersion)
|
||||
setIfPresent(params, "Message", message)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.SubmitDBStateChangeOutput
|
||||
if err := g.client.Call(ctx, "SubmitDBStateChange", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
|
||||
params := url.Values{}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.GetDBBootstrapConfigOutput
|
||||
if err := g.client.Call(ctx, "GetDBBootstrapConfig", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error {
|
||||
params := url.Values{
|
||||
"PayloadId": {pending.payloadID},
|
||||
"VMGeneration": {strconv.FormatInt(pending.vmGeneration, 10)},
|
||||
}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
setIfPresent(params, "DataVolumeId", pending.dataVolumeID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out handlers_rds.AcknowledgeDBBootstrapOutput
|
||||
if err := g.client.Call(ctx, "AcknowledgeDBBootstrap", params, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !out.Acknowledged {
|
||||
return fmt.Errorf("the control plane did not acknowledge bootstrap payload %s", pending.payloadID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Declared here so the in-guest binary does not link the server side of the API.
|
||||
type pollDBCommandsOutput struct {
|
||||
Commands []handlers_rds.Command `xml:"Commands>member"`
|
||||
}
|
||||
|
||||
func (g *gatewayControlPlane) PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error) {
|
||||
params := url.Values{"WaitTimeSeconds": {strconv.FormatInt(int64(wait.Seconds()), 10)}}
|
||||
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
|
||||
// Replies ride the poll rather than a separate action, so replying and
|
||||
// asking for the next command are one round trip.
|
||||
for i, reply := range replies {
|
||||
member := fmt.Sprintf("Replies.member.%d.", i+1)
|
||||
params.Set(member+"CommandId", reply.CommandID)
|
||||
params.Set(member+"Status", reply.Status)
|
||||
setIfPresent(params, member+"Message", reply.Message)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, wait+pollSlack)
|
||||
defer cancel()
|
||||
|
||||
var out pollDBCommandsOutput
|
||||
if err := g.client.Call(ctx, "PollDBCommands", params, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Commands, nil
|
||||
}
|
||||
|
||||
// Only sets a parameter that has a value, so the gateway can tell "no
|
||||
// assertion" from "asserted as blank".
|
||||
func setIfPresent(params url.Values, key, value string) {
|
||||
if value != "" {
|
||||
params.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
cfg config
|
||||
id identity
|
||||
cp controlPlane
|
||||
probe *engineProbe
|
||||
engine engine
|
||||
handoffWriter func(string, *handlers_rds.GetDBBootstrapConfigOutput) error
|
||||
dataMountWaiter func(context.Context, string, string) error
|
||||
|
||||
// Set by bootstrap when the fetch replayed a staged payload; nil leaves the
|
||||
// acknowledgement step a no-op, which is every attach boot.
|
||||
pending *pendingBootstrap
|
||||
|
||||
hb *heartbeater
|
||||
cmd *commander
|
||||
guard *paramGuard
|
||||
}
|
||||
|
||||
// Assembles from already-built parts, so tests can pass fakes; New builds the
|
||||
// production control plane and delegates here.
|
||||
func newAgent(cfg config, cp controlPlane, probe *engineProbe) (*Agent, error) {
|
||||
if cfg.MountsFile == "" {
|
||||
cfg.MountsFile = defaultMountsFile
|
||||
}
|
||||
id := identity{
|
||||
DBInstanceIdentifier: cfg.DBInstanceIdentifier,
|
||||
AgentVersion: version,
|
||||
EngineVersion: cfg.EngineVersion,
|
||||
}
|
||||
a := &Agent{
|
||||
cfg: cfg, id: id, cp: cp, probe: probe,
|
||||
handoffWriter: writeHandoff, dataMountWaiter: waitForDataMount,
|
||||
}
|
||||
eng, err := newEngine(cfg, execCommandRunner, execSessionRunner, probe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.engine = eng
|
||||
a.hb = newHeartbeater(cp, probe, a.engine, handlers_rds.HeartbeatInterval)
|
||||
a.cmd = newCommander(cp, newCommandRegistry(a.engine, newGuestStorage(cfg, execCommandRunner)), cfg.PollWait)
|
||||
a.guard = newParamGuard(a.engine, probe, cp)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// New builds the agent from cfg, failing if no gateway URL is set. It does not
|
||||
// wait for IMDS: the register loop rides out a datapath still coming up.
|
||||
func New(cfg config) (*Agent, error) {
|
||||
if cfg.GatewayURL == "" {
|
||||
return nil, fmt.Errorf("no gateway URL configured (RDS_GATEWAY_URL)")
|
||||
}
|
||||
cp, err := newGatewayControlPlane(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probe, err := newProbe(cfg, execProbeRunner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newAgent(cfg, cp, probe)
|
||||
}
|
||||
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
// Named before the first dial: when register cannot reach the gateway, the
|
||||
// address it is dialing is what separates a mgmt NIC that came up without one
|
||||
// from a control plane that is genuinely down.
|
||||
slog.Info("rds-agent: starting",
|
||||
"gatewayURL", a.cfg.GatewayURL, "dbInstanceIdentifier", a.id.DBInstanceIdentifier,
|
||||
"agentVersion", a.id.AgentVersion)
|
||||
|
||||
// Registration comes first: its response is the agent's identity.
|
||||
if err := a.register(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.id, a.cmd.id, a.guard.id = a.id, a.id, a.id
|
||||
|
||||
// Ahead of the handoff, so rds-init never unblocks and no datadir is touched.
|
||||
// An agent running on the wrong image would initialise one engine over
|
||||
// another's data, coming up healthy and empty beside data nothing references.
|
||||
if err := a.refuseEngineMismatch(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Beating before the bootstrap keeps a stuck boot visible as a live VM with
|
||||
// a down engine rather than as silence.
|
||||
go a.hb.Run(ctx)
|
||||
|
||||
if err := a.bootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The handoff must land before this wait: rds-datadir needs it to identify
|
||||
// and authorize the volume. Commands and the parameter guard wait because
|
||||
// their durable state belongs on that mount, never on the disposable boot disk.
|
||||
if err := a.dataMountWaiter(ctx, a.cfg.MountsFile, a.cfg.DataMount); err != nil {
|
||||
// A shutdown cancels this wait; main treats the wrapped context.Canceled
|
||||
// as a clean stop, exactly as it does for register and bootstrap above.
|
||||
return fmt.Errorf("wait for data mount %s: %w", a.cfg.DataMount, err)
|
||||
}
|
||||
|
||||
// The receipt it waits on lives on that mount, so this cannot run any earlier.
|
||||
// A cancelled wait is a clean stop here too.
|
||||
if err := a.acknowledgeBootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Directives are only meaningful against a bootstrapped, mounted engine.
|
||||
go a.cmd.Run(ctx)
|
||||
|
||||
// Started after the handoff, so the window it measures covers the engine's
|
||||
// own start rather than the time rds-init spent waiting for this agent.
|
||||
go a.guard.Run(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
// The engine cloud-init says this VM was launched as, against the engine the
|
||||
// image bakes. An empty expectation is no assertion rather than a disagreement:
|
||||
// a VM launched before the control plane emitted RDS_ENGINE carries none.
|
||||
func (a *Agent) refuseEngineMismatch(ctx context.Context) error {
|
||||
expected := strings.ToLower(strings.TrimSpace(a.cfg.Engine))
|
||||
if expected == "" || expected == a.cfg.BakedEngine {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("this VM was launched as engine %q but its image bakes %q; refusing to bootstrap",
|
||||
expected, a.cfg.BakedEngine)
|
||||
slog.ErrorContext(ctx, "rds-agent: "+message)
|
||||
// Reported before returning, so the control plane learns why the instance
|
||||
// never came up rather than only that it never did.
|
||||
if _, err := a.cp.SubmitState(ctx, a.id, handlers_rds.EngineHealthUnhealthy, message); err != nil {
|
||||
slog.ErrorContext(ctx, "rds-agent: reporting the engine mismatch failed", "err", err)
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
const dataMountPollInterval = time.Second
|
||||
|
||||
// waitForDataMount blocks the stateful agent loops until the kernel reports the
|
||||
// configured data mount. Registration, heartbeat, and bootstrap run before it.
|
||||
func waitForDataMount(ctx context.Context, mountsFile, target string) error {
|
||||
ticker := time.NewTicker(dataMountPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
mounted, err := mountTableContains(mountsFile, target)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "rds-agent: could not read mount table while waiting for data volume",
|
||||
"mountsFile", mountsFile, "dataMount", target, "err", err)
|
||||
} else if mounted {
|
||||
slog.InfoContext(ctx, "rds-agent: data volume mount is ready", "dataMount", target)
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mountTableContains(path, target string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) >= 2 && unescapeMountField(fields[1]) == target {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Linux mount tables encode whitespace and backslashes as octal escapes.
|
||||
func unescapeMountField(field string) string {
|
||||
replacer := strings.NewReplacer(`\134`, `\`, `\040`, " ", `\011`, "\t", `\012`, "\n")
|
||||
return replacer.Replace(field)
|
||||
}
|
||||
|
||||
// Boot-retry bounds. Tight to start, since the usual cause is a control plane
|
||||
// still creating this instance's record, and capped so an outage does not turn
|
||||
// a booting fleet into a retry storm. Vars so tests can shorten the wait.
|
||||
var (
|
||||
retryMin = 1 * time.Second
|
||||
retryMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// With the production backoff, attempt five is about 15 seconds into the
|
||||
// failure and still precedes the first 30-second heartbeat.
|
||||
const retryErrorAttempt = 5
|
||||
|
||||
// Wrapping an error in this stops retryObserved. For the failures the control
|
||||
// plane decides from its own record, no retry can change the answer, and looping
|
||||
// on one only keeps the caller blocked.
|
||||
var errRetryTerminal = errors.New("terminal failure")
|
||||
|
||||
// Never gives up on its own: an agent that stopped trying would leave the
|
||||
// control plane with no signal at all.
|
||||
func retry(ctx context.Context, what string, fn func(context.Context) error) error {
|
||||
return retryObserved(ctx, what, fn, nil)
|
||||
}
|
||||
|
||||
// onFailure lets boot-critical callers publish the latest error while the retry
|
||||
// remains blocked. It must not block because it runs on the retry path itself.
|
||||
func retryObserved(ctx context.Context, what string, fn func(context.Context) error, onFailure func(error)) error {
|
||||
delay := retryMin
|
||||
for attempt := 1; ; attempt++ {
|
||||
err := fn(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s: %w", what, ctx.Err())
|
||||
}
|
||||
if onFailure != nil {
|
||||
onFailure(err)
|
||||
}
|
||||
if errors.Is(err, errRetryTerminal) {
|
||||
slog.ErrorContext(ctx, "rds-agent: "+what+" failed terminally, not retrying", "err", err)
|
||||
return fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
if attempt >= retryErrorAttempt {
|
||||
slog.ErrorContext(ctx, "rds-agent: "+what+" still failing, retrying",
|
||||
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
|
||||
} else {
|
||||
slog.WarnContext(ctx, "rds-agent: "+what+" failed, retrying",
|
||||
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("%s: %w", what, ctx.Err())
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay = min(delay*2, retryMax)
|
||||
}
|
||||
}
|
||||
@@ -1,873 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/rdsgw"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeControlPlane is a scriptable controlPlane. Every call records what the
|
||||
// agent sent, so a test asserts on the request as well as the reaction.
|
||||
type fakeControlPlane struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registerOut *handlers_rds.RegisterDBInstanceOutput
|
||||
registerErrs []error
|
||||
registerReqs []identity
|
||||
|
||||
bootstrapOut *handlers_rds.GetDBBootstrapConfigOutput
|
||||
bootstrapErrs []error
|
||||
bootstrapReqs []identity
|
||||
// Scripted responses ahead of bootstrapOut, so a test can answer the first
|
||||
// fetch differently from the ones the agent retries with.
|
||||
bootstrapQueue []*handlers_rds.GetDBBootstrapConfigOutput
|
||||
|
||||
ackErrs []error
|
||||
ackReqs []pendingBootstrap
|
||||
|
||||
states []submittedState
|
||||
|
||||
pollReplies [][]handlers_rds.CommandReply
|
||||
pollQueue [][]handlers_rds.Command
|
||||
pollErr error
|
||||
polled chan struct{}
|
||||
}
|
||||
|
||||
type submittedState struct {
|
||||
id identity
|
||||
health handlers_rds.EngineHealth
|
||||
message string
|
||||
}
|
||||
|
||||
var _ controlPlane = (*fakeControlPlane)(nil)
|
||||
|
||||
func newFakeControlPlane() *fakeControlPlane {
|
||||
return &fakeControlPlane{
|
||||
registerOut: &handlers_rds.RegisterDBInstanceOutput{
|
||||
DBInstanceIdentifier: "db-resolved", HeartbeatIntervalSeconds: 30,
|
||||
},
|
||||
polled: make(chan struct{}, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// nextErr pops the next scripted error, so a test can fail a call n times and
|
||||
// then let it succeed.
|
||||
func nextErr(errs *[]error) error {
|
||||
if len(*errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := (*errs)[0]
|
||||
*errs = (*errs)[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) Register(_ context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.registerReqs = append(f.registerReqs, id)
|
||||
if err := nextErr(&f.registerErrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.registerOut, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) SubmitState(_ context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.states = append(f.states, submittedState{id: id, health: health, message: message})
|
||||
// Zero means "keep the cadence you have", so a test's fast interval is not
|
||||
// reset by every beat.
|
||||
return &handlers_rds.SubmitDBStateChangeOutput{Acknowledged: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) GetBootstrapConfig(_ context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.bootstrapReqs = append(f.bootstrapReqs, id)
|
||||
if err := nextErr(&f.bootstrapErrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(f.bootstrapQueue) > 0 {
|
||||
out := f.bootstrapQueue[0]
|
||||
f.bootstrapQueue = f.bootstrapQueue[1:]
|
||||
return out, nil
|
||||
}
|
||||
return f.bootstrapOut, nil
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) AcknowledgeBootstrap(_ context.Context, _ identity, pending *pendingBootstrap) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ackReqs = append(f.ackReqs, *pending)
|
||||
return nextErr(&f.ackErrs)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) PollCommands(ctx context.Context, _ identity, replies []handlers_rds.CommandReply, _ time.Duration) ([]handlers_rds.Command, error) {
|
||||
f.mu.Lock()
|
||||
f.pollReplies = append(f.pollReplies, slices.Clone(replies))
|
||||
err := f.pollErr
|
||||
var out []handlers_rds.Command
|
||||
if len(f.pollQueue) > 0 {
|
||||
out, f.pollQueue = f.pollQueue[0], f.pollQueue[1:]
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
select {
|
||||
case f.polled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
// An idle window: block until the agent hangs up, as the gateway's long poll
|
||||
// does, rather than spinning the caller's loop.
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) ackRequests() []pendingBootstrap {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return slices.Clone(f.ackReqs)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) snapshotStates() []submittedState {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]submittedState(nil), f.states...)
|
||||
}
|
||||
|
||||
func (f *fakeControlPlane) snapshotReplies() [][]handlers_rds.CommandReply {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([][]handlers_rds.CommandReply, len(f.pollReplies))
|
||||
for i := range f.pollReplies {
|
||||
out[i] = slices.Clone(f.pollReplies[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// testConfig stamps the engine a real rds-postgres image bakes, so the agent
|
||||
// resolves the same guest layout it would in a guest, and points its file output
|
||||
// at a temp dir.
|
||||
func testConfig(t *testing.T) config {
|
||||
t.Helper()
|
||||
cfg := testLoadConfig(t, enginePostgres)
|
||||
cfg.GatewayURL = "https://gw.test"
|
||||
cfg.HandoffDir = filepath.Join(t.TempDir(), "spinifex-rds")
|
||||
cfg.PollWait = time.Second
|
||||
return cfg
|
||||
}
|
||||
|
||||
// newTestAgent builds the agent over a probe the test drives directly.
|
||||
func newTestAgent(t *testing.T, cfg config, cp controlPlane, run probeRunner) *Agent {
|
||||
t.Helper()
|
||||
a, err := newAgent(cfg, cp, newPostgresProbe(cfg, run))
|
||||
if err != nil {
|
||||
t.Fatalf("newAgent: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// staticProbe is a probeRunner that always reports the same exit code.
|
||||
func staticProbe(code int) probeRunner {
|
||||
return func(context.Context, string, ...string) (int, string, error) { return code, "", nil }
|
||||
}
|
||||
|
||||
func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput {
|
||||
mode := handlers_rds.BootstrapModeAttach
|
||||
if password != nil {
|
||||
mode = handlers_rds.BootstrapModeInitialize
|
||||
}
|
||||
return &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: mode,
|
||||
DBInstanceIdentifier: "db-resolved",
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
MasterUserPassword: password,
|
||||
Port: 5432,
|
||||
ListenAddress: testListenAddress,
|
||||
ClientCIDR: testClientCIDR,
|
||||
DataVolumeID: "vol-data-01",
|
||||
DataVolumeSerial: "voldata01",
|
||||
VMGeneration: 1,
|
||||
PayloadID: testPayloadID,
|
||||
BootstrapPending: password != nil,
|
||||
}
|
||||
}
|
||||
|
||||
const testPayloadID = "bp-0123456789abcdef"
|
||||
|
||||
// The customer ENI address the engine binds, and the VPC range its client
|
||||
// authentication admits. writeHandoff refuses a config without either.
|
||||
const (
|
||||
testListenAddress = "10.0.1.20"
|
||||
testClientCIDR = "10.0.0.0/16"
|
||||
)
|
||||
|
||||
// Writes the completion receipt rds-init leaves behind, so the acknowledgement
|
||||
// step finds what a finished initialization would have produced.
|
||||
func writeReceipt(t *testing.T, a *Agent, payloadID, dbInstanceIdentifier string) {
|
||||
t.Helper()
|
||||
path := a.receiptPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir receipt dir: %v", err)
|
||||
}
|
||||
body := fmt.Sprintf("%s=%s\n%s=%s\n", receiptPayload, payloadID, receiptDBID, dbInstanceIdentifier)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runAgent runs the agent until its bootstrap has landed, then cancels. It
|
||||
// returns once Run has exited so no goroutine outlives the test.
|
||||
func runAgent(t *testing.T, a *Agent) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
_, err := os.Stat(filepath.Join(a.cfg.HandoffDir, handoffEnvFile))
|
||||
return err == nil
|
||||
}, "handoff file to be written")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
// The cancellation above is this helper's own, so Run reporting it is the
|
||||
// expected clean stop rather than a failure, exactly as main treats it.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run did not exit after cancellation")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls cond until it holds or the test times out.
|
||||
func waitFor(t *testing.T, cond func() bool, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
func TestRun_RegistersThenDeliversBootstrap(t *testing.T) {
|
||||
password := "s3cret"
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
if err := runAgent(t, a); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(cp.registerReqs) != 1 {
|
||||
t.Fatalf("registered %d times, want 1", len(cp.registerReqs))
|
||||
}
|
||||
// The agent had no configured identifier, so the gateway's resolution is
|
||||
// what every later call must carry.
|
||||
if got := cp.bootstrapReqs[0].DBInstanceIdentifier; got != "db-resolved" {
|
||||
t.Errorf("bootstrap sent identifier %q, want the registered db-resolved", got)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
|
||||
if !strings.Contains(env, "RDS_MODE='initialize'") || !strings.Contains(env, "RDS_MASTER_PASSWORD='s3cret'") {
|
||||
t.Errorf("handoff env = %q, want initialize mode carrying the password", env)
|
||||
}
|
||||
// The probe follows the port the control plane assigned, not the default.
|
||||
if got := a.probe.port.Load(); got != 5432 {
|
||||
t.Errorf("probe port = %d, want 5432", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A reboot re-fetches in attach mode. The agent must write that payload as it
|
||||
// stands rather than refusing it or inventing a password.
|
||||
func TestRun_AttachModeNeedsNoPassword(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
|
||||
if !strings.Contains(env, "RDS_MODE='attach'") {
|
||||
t.Errorf("handoff env = %q, want attach mode", env)
|
||||
}
|
||||
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
|
||||
t.Errorf("handoff env = %q, want no password key at all in attach mode", env)
|
||||
}
|
||||
}
|
||||
|
||||
// The record may not exist yet when a freshly launched VM first calls, so both
|
||||
// boot-critical calls have to ride out failures instead of exiting.
|
||||
func TestRun_RetriesRegisterAndBootstrap(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerErrs = []error{errors.New("DBInstanceNotFound")}
|
||||
cp.bootstrapErrs = []error{errors.New("ServerInternal")}
|
||||
|
||||
cfg := testConfig(t)
|
||||
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(cp.registerReqs) != 2 {
|
||||
t.Errorf("register attempts = %d, want 2 (one failure, one success)", len(cp.registerReqs))
|
||||
}
|
||||
if len(cp.bootstrapReqs) != 2 {
|
||||
t.Errorf("bootstrap attempts = %d, want 2 (one failure, one success)", len(cp.bootstrapReqs))
|
||||
}
|
||||
}
|
||||
|
||||
// The fetch mutates nothing, so a handoff that cannot be written is retried
|
||||
// against the payload already in hand rather than costing a second fetch.
|
||||
func TestBootstrap_RetriesHandoffWithoutRefetching(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
password := "staged-secret"
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
|
||||
writes := 0
|
||||
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
writes++
|
||||
if got.MasterUserPassword == nil || *got.MasterUserPassword != password {
|
||||
t.Fatalf("handoff attempt %d lost the initialize password", writes)
|
||||
}
|
||||
if writes == 1 {
|
||||
return errors.New("temporary tmpfs write error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.bootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if len(cp.bootstrapReqs) != 1 {
|
||||
t.Errorf("bootstrap fetches = %d, want 1", len(cp.bootstrapReqs))
|
||||
}
|
||||
if writes != 2 {
|
||||
t.Errorf("handoff writes = %d, want 2", writes)
|
||||
}
|
||||
if a.pending == nil || a.pending.payloadID != testPayloadID {
|
||||
t.Errorf("pending bootstrap = %+v, want the staged payload %s", a.pending, testPayloadID)
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon that predates the encrypted payload answers initialize with nothing
|
||||
// to initialise with. Writing that handoff would let rds-init run initdb with an
|
||||
// empty master password, so it is retried until an upgraded node answers.
|
||||
func TestBootstrap_RetriesInitializeWithNoPassword(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
empty := ""
|
||||
password := "staged-secret"
|
||||
cp.bootstrapQueue = []*handlers_rds.GetDBBootstrapConfigOutput{{
|
||||
Mode: handlers_rds.BootstrapModeInitialize, DBInstanceIdentifier: "db-resolved",
|
||||
MasterUsername: "master", MasterUserPassword: &empty,
|
||||
}}
|
||||
cp.bootstrapOut = bootstrapOutput(&password)
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
|
||||
var handed []*handlers_rds.GetDBBootstrapConfigOutput
|
||||
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
handed = append(handed, got)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := a.bootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if len(handed) != 1 {
|
||||
t.Fatalf("handoff writes = %d, want 1 once a usable payload arrived", len(handed))
|
||||
}
|
||||
if handed[0].MasterUserPassword == nil || *handed[0].MasterUserPassword != password {
|
||||
t.Error("the agent wrote a handoff for a fetch that carried no master password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeBootstrap_WaitsForTheReceiptThenRetiresIt(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1, dataVolumeID: "vol-data-01"}
|
||||
|
||||
// rds-init runs after the agent registers, so the receipt is absent when the
|
||||
// acknowledgement step first looks and appears some way into the boot.
|
||||
if err := a.checkBootstrapReceipt(a.pending); err == nil {
|
||||
t.Fatal("a missing receipt must not read as a completed bootstrap")
|
||||
}
|
||||
writeReceipt(t, a, testPayloadID, "db-resolved")
|
||||
|
||||
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap: %v", err)
|
||||
}
|
||||
if len(cp.ackRequests()) != 1 {
|
||||
t.Fatalf("acknowledgements = %d, want 1", len(cp.ackRequests()))
|
||||
}
|
||||
if got := cp.ackRequests()[0]; got != *a.pending {
|
||||
t.Errorf("acknowledged %+v, want %+v", got, *a.pending)
|
||||
}
|
||||
if _, err := os.Stat(a.receiptPath()); !os.IsNotExist(err) {
|
||||
t.Error("the receipt must be removed once the record holds the audit trail")
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt rides along in every snapshot of the data volume, so a restored
|
||||
// instance's volume carries the source instance's receipt. Acknowledging on one
|
||||
// would destroy the ciphertext this instance still needs.
|
||||
func TestAcknowledgeBootstrap_IgnoresAForeignReceipt(t *testing.T) {
|
||||
tests := map[string]struct{ payloadID, dbID string }{
|
||||
"another payload": {payloadID: "bp-somebody-else", dbID: "db-resolved"},
|
||||
"another instance": {payloadID: testPayloadID, dbID: "db-the-snapshot-source"},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
writeReceipt(t, a, tc.payloadID, tc.dbID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err == nil {
|
||||
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting on a real receipt")
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 0 {
|
||||
t.Errorf("acknowledgements = %d, want 0", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An attach boot has nothing staged, so it must not go looking for a receipt the
|
||||
// datadir will never grow.
|
||||
func TestAcknowledgeBootstrap_IsANoOpOnAttach(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
|
||||
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap: %v", err)
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 0 {
|
||||
t.Errorf("acknowledgements = %d, want 0 when nothing was staged", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The image is the authority on which engine the agent runs. A VM launched as
|
||||
// another engine has to stop ahead of the handoff: rds-init would otherwise
|
||||
// initialise one engine over the other's datadir and come up healthy and empty
|
||||
// beside data nothing references.
|
||||
func TestRun_RefusesAnEngineTheImageDoesNotBake(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
cfg.Engine = "mariadb"
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
|
||||
err := a.Run(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("Run returned nil for a VM launched as an engine this image does not bake")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mariadb") {
|
||||
t.Errorf("error = %v, want it to name the engine the VM was launched as", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(cfg.HandoffDir, handoffEnvFile)); !os.IsNotExist(statErr) {
|
||||
t.Error("the agent wrote a bootstrap handoff for an engine it refused to run")
|
||||
}
|
||||
if n := len(cp.bootstrapReqs); n != 0 {
|
||||
t.Errorf("bootstrap fetches = %d, want none before the refusal", n)
|
||||
}
|
||||
// Reported rather than only logged, so the control plane learns why the
|
||||
// instance never came up rather than only that it never did.
|
||||
states := cp.snapshotStates()
|
||||
if len(states) != 1 || states[0].health != handlers_rds.EngineHealthUnhealthy {
|
||||
t.Fatalf("submitted states = %+v, want a single unhealthy report", states)
|
||||
}
|
||||
}
|
||||
|
||||
// The same assertion when it agrees is not a gate: the boot goes on exactly as
|
||||
// it does for a VM launched before the control plane sent one at all.
|
||||
func TestRun_ProceedsWhenTheDeliveredEngineMatchesTheImage(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
cfg.Engine = enginePostgres
|
||||
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A configured identifier is asserted on the wire so a mis-provisioned VM is
|
||||
// rejected by the gateway instead of adopting whatever it resolves to.
|
||||
func TestRun_SendsConfiguredIdentifier(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
|
||||
cfg := testConfig(t)
|
||||
cfg.DBInstanceIdentifier = "db-configured"
|
||||
cfg.EngineVersion = "18.1"
|
||||
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
sent := cp.registerReqs[0]
|
||||
if sent.DBInstanceIdentifier != "db-configured" || sent.EngineVersion != "18.1" {
|
||||
t.Errorf("register sent %+v, want the configured identifier and engine version", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_WaitsForDataMountBeforePollingCommands(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(2))
|
||||
|
||||
waiting := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
a.dataMountWaiter = func(ctx context.Context, mountsFile, target string) error {
|
||||
if mountsFile != defaultMountsFile || target != cfg.DataMount {
|
||||
t.Errorf("mount waiter got %q/%q, want configured defaults", mountsFile, target)
|
||||
}
|
||||
close(waiting)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
select {
|
||||
case <-waiting:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("agent did not reach the data-mount wait after writing the handoff")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.HandoffDir, handoffEnvFile)); err != nil {
|
||||
t.Fatalf("handoff was not present before mount wait: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cp.polled:
|
||||
t.Fatal("command poll started before the data volume was mounted")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
select {
|
||||
case <-cp.polled:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("command poll did not start after the data mount became ready")
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTableContains(t *testing.T) {
|
||||
mounts := filepath.Join(t.TempDir(), "mounts")
|
||||
require.NoError(t, os.WriteFile(mounts, []byte(
|
||||
"/dev/vda1 / ext4 rw 0 0\n/dev/vdb /var/lib/postgresql ext4 rw 0 0\n"+
|
||||
"/dev/vdc /path\\040with\\040spaces xfs rw 0 0\nmalformed\n"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{target: "/var/lib/postgresql", want: true},
|
||||
{target: "/path with spaces", want: true},
|
||||
{target: "/missing", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.target, func(t *testing.T) {
|
||||
got, err := mountTableContains(mounts, tc.target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_HeartbeatIncludesBootstrapFailure(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapErrs = []error{errors.New("403 AccessDenied: instance profile is not authorised")}
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(2))
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
for _, state := range cp.snapshotStates() {
|
||||
if strings.Contains(state.message, "bootstrap fetch failed") &&
|
||||
strings.Contains(state.message, "403 AccessDenied") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, "the bootstrap failure to reach a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRun_HeartbeatsEngineHealth(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
// Declining to set a cadence leaves the agent's own, which the test shortens
|
||||
// rather than waiting out the 30s production interval.
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
beat := cp.snapshotStates()[0]
|
||||
if beat.health != handlers_rds.EngineHealthHealthy {
|
||||
t.Errorf("heartbeat health = %q, want healthy", beat.health)
|
||||
}
|
||||
if beat.id.DBInstanceIdentifier != "db-resolved" {
|
||||
t.Errorf("heartbeat identifier = %q, want db-resolved", beat.id.DBInstanceIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// The cadence is the control plane's to set: it feeds the staleness window the
|
||||
// recovery reconciler judges an instance against, so the guest adopts it rather
|
||||
// than keeping its own.
|
||||
func TestRegister_AdoptsControlPlaneCadence(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 45
|
||||
|
||||
cfg := testConfig(t)
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
if err := runAgent(t, a); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if a.hb.interval != 45*time.Second {
|
||||
t.Errorf("heartbeat interval = %v, want the 45s the control plane returned", a.hb.interval)
|
||||
}
|
||||
}
|
||||
|
||||
// The agent being up says nothing about the engine. A down engine has to reach
|
||||
// the control plane as such, or auto-recovery has nothing to act on.
|
||||
func TestRun_HeartbeatReportsDownEngine(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cp.registerOut.HeartbeatIntervalSeconds = 0
|
||||
|
||||
cfg := testConfig(t)
|
||||
probe := newPostgresProbe(cfg, staticProbe(2))
|
||||
// The engine has served before, so a silent one now is a failure rather
|
||||
// than a boot still in progress.
|
||||
probe.seenHealthy = true
|
||||
|
||||
a, err := newAgent(cfg, cp, probe)
|
||||
if err != nil {
|
||||
t.Fatalf("newAgent: %v", err)
|
||||
}
|
||||
a.hb.interval = 10 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.Run(ctx) }()
|
||||
|
||||
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
beat := cp.snapshotStates()[0]
|
||||
if beat.health != handlers_rds.EngineHealthUnhealthy {
|
||||
t.Errorf("heartbeat health = %q, want unhealthy while the agent is up", beat.health)
|
||||
}
|
||||
if beat.message == "" {
|
||||
t.Error("unhealthy heartbeat carried no message explaining the probe result")
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror the agent decodes poll responses into has to stay aligned with the
|
||||
// gateway type that produces them; nothing else in the build ties the two.
|
||||
func TestPollDBCommandsOutput_MatchesGatewayShape(t *testing.T) {
|
||||
issued := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
|
||||
// Rendered exactly as the gateway's typed adapter renders it, so a change to
|
||||
// either side's tags fails here.
|
||||
payload := utils.GenerateIAMXMLPayload("PollDBCommands", &gateway_rds.PollDBCommandsOutput{
|
||||
Commands: []handlers_rds.Command{{
|
||||
CommandID: "cmd-1",
|
||||
Type: "reload-parameters",
|
||||
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
|
||||
IssuedAt: &issued,
|
||||
}},
|
||||
})
|
||||
body, err := utils.MarshalToXML(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal poll result: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := rdsgw.New(srv.URL, "", gwsign.NewStatic("AKIATEST", "secret"), "us-east-1", time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("rdsgw.New: %v", err)
|
||||
}
|
||||
|
||||
var got pollDBCommandsOutput
|
||||
if err := client.Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(got.Commands) != 1 {
|
||||
t.Fatalf("decoded %d commands, want 1", len(got.Commands))
|
||||
}
|
||||
cmd := got.Commands[0]
|
||||
if cmd.CommandID != "cmd-1" || cmd.Type != "reload-parameters" {
|
||||
t.Errorf("command = %+v, want cmd-1/reload-parameters", cmd)
|
||||
}
|
||||
if len(cmd.Parameters) != 1 || cmd.Parameters[0].Value != "8MB" {
|
||||
t.Errorf("Parameters = %+v, want the seeded member", cmd.Parameters)
|
||||
}
|
||||
if cmd.IssuedAt == nil || !cmd.IssuedAt.Equal(issued) {
|
||||
t.Errorf("IssuedAt = %v, want %v", cmd.IssuedAt, issued)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// AccessDenied is decided by the control plane's own record, so no retry can
|
||||
// change it. Looping on one would leave the command poller and the parameter
|
||||
// guard unstarted for the life of a VM whose engine is serving normally.
|
||||
func TestAcknowledgeBootstrap_StopsOnATerminalDenial(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.ackErrs = []error{&rdsgw.APIError{
|
||||
Action: "AcknowledgeDBBootstrap", StatusCode: 403,
|
||||
Code: awserrors.ErrorAccessDenied,
|
||||
Message: "bootstrap payload bp-x at generation 1 does not match the current state",
|
||||
}}
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
writeReceipt(t, a, testPayloadID, "db-resolved")
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err != nil {
|
||||
t.Fatalf("acknowledgeBootstrap = %v, want nil so the stateful loops still start", err)
|
||||
}
|
||||
if n := len(cp.ackRequests()); n != 1 {
|
||||
t.Errorf("acknowledgements = %d, want 1 attempt and no retry", n)
|
||||
}
|
||||
if a.hb.bootstrapFailure.Load() == nil {
|
||||
t.Error("a terminal denial must still reach the operator through the heartbeat")
|
||||
}
|
||||
if _, err := os.Stat(a.receiptPath()); err != nil {
|
||||
t.Error("a denied acknowledgement must leave the receipt for a later attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// rds-init runs after this agent, so the receipt is absent for the whole initdb
|
||||
// window. Reporting that would put a failure on every healthy create's beat, and
|
||||
// then into the reason a create that timed out for some other cause records.
|
||||
func TestAcknowledgeBootstrap_DoesNotReportAnAbsentReceiptAsAFailure(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
a := newTestAgent(t, cfg, cp, staticProbe(0))
|
||||
a.id = identity{DBInstanceIdentifier: "db-resolved"}
|
||||
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := a.acknowledgeBootstrap(ctx); err == nil {
|
||||
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting for rds-init")
|
||||
}
|
||||
if got := a.hb.bootstrapFailure.Load(); got != nil {
|
||||
t.Errorf("bootstrap failure = %q, want none while the receipt has simply not been written", *got)
|
||||
}
|
||||
|
||||
// A receipt that exists but names another instance is a real mismatch and is
|
||||
// reported, which is what separates the two cases.
|
||||
writeReceipt(t, a, testPayloadID, "db-the-snapshot-source")
|
||||
mismatch, cancelMismatch := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancelMismatch()
|
||||
if err := a.acknowledgeBootstrap(mismatch); err == nil {
|
||||
t.Fatal("a foreign receipt must not be acknowledged")
|
||||
}
|
||||
if a.hb.bootstrapFailure.Load() == nil {
|
||||
t.Error("a receipt naming another instance must be reported")
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The staged payload this boot has to acknowledge once PostgreSQL has applied
|
||||
// it. Nil on an attach boot, which makes the acknowledgement step a no-op.
|
||||
type pendingBootstrap struct {
|
||||
payloadID string
|
||||
vmGeneration int64
|
||||
dataVolumeID string
|
||||
}
|
||||
|
||||
// Fetches the boot material and writes the handoff rds-init is blocked waiting
|
||||
// for, then points the health probe at the assigned port.
|
||||
func (a *Agent) bootstrap(ctx context.Context) error {
|
||||
var cfg *handlers_rds.GetDBBootstrapConfigOutput
|
||||
if err := retryObserved(ctx, "bootstrap fetch", func(ctx context.Context) error {
|
||||
fetched, err := a.cp.GetBootstrapConfig(ctx, a.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fetched == nil {
|
||||
return fmt.Errorf("bootstrap fetch returned no config")
|
||||
}
|
||||
// A control plane predating the encrypted payload answers initialize with
|
||||
// nothing to initialise with. Retrying re-dials the queue group until an
|
||||
// upgraded node answers, so a mixed-version fleet heals within one boot.
|
||||
if fetched.Mode == handlers_rds.BootstrapModeInitialize &&
|
||||
(fetched.MasterUserPassword == nil || *fetched.MasterUserPassword == "") {
|
||||
return fmt.Errorf("bootstrap fetch returned mode=%s with no master password", fetched.Mode)
|
||||
}
|
||||
cfg = fetched
|
||||
return nil
|
||||
}, func(err error) {
|
||||
a.hb.setBootstrapFailure("bootstrap fetch", err)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
|
||||
// The fetch mutates nothing, so a handoff that cannot be written is retried
|
||||
// against the same staged payload rather than against an attach response.
|
||||
if err := retryObserved(ctx, "bootstrap handoff", func(context.Context) error {
|
||||
return a.handoffWriter(a.cfg.HandoffDir, cfg)
|
||||
}, func(err error) {
|
||||
a.hb.setBootstrapFailure("bootstrap handoff", err)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.hb.clearBootstrapFailure()
|
||||
if cfg.Port > 0 {
|
||||
a.probe.setPort(int(cfg.Port))
|
||||
}
|
||||
if cfg.BootstrapPending && cfg.PayloadID != "" {
|
||||
a.pending = &pendingBootstrap{
|
||||
payloadID: cfg.PayloadID,
|
||||
vmGeneration: cfg.VMGeneration,
|
||||
dataVolumeID: cfg.DataVolumeID,
|
||||
}
|
||||
}
|
||||
// Mode is logged but never branched on: rds-init decides whether to initdb
|
||||
// from the state of the datadir.
|
||||
slog.Info("rds-agent: bootstrap config delivered",
|
||||
"mode", cfg.Mode, "port", cfg.Port, "parameters", len(cfg.Parameters),
|
||||
"bootstrapPending", cfg.BootstrapPending)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The handoff files rds-init reads. They live on tmpfs, so nothing survives a
|
||||
// reboot and the next boot re-fetches rather than reusing a stale password or a
|
||||
// re-minted cert.
|
||||
const (
|
||||
handoffEnvFile = "bootstrap.env"
|
||||
handoffParamsFile = "parameters.conf"
|
||||
handoffCertFile = "server.crt"
|
||||
handoffKeyFile = "server.key"
|
||||
|
||||
// Root-only: only rds-init reads these, and one holds the master password.
|
||||
handoffMode = 0o600
|
||||
handoffDirMode = 0o700
|
||||
)
|
||||
|
||||
// bootstrap.env is written last and renamed into place, so its appearance —
|
||||
// which rds-init waits on — means the whole handoff is complete.
|
||||
func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) error {
|
||||
if cfg.MasterUsername == "" {
|
||||
return fmt.Errorf("bootstrap config carries no master username")
|
||||
}
|
||||
// Half a handoff would leave rds-init deciding what to bind and who to admit,
|
||||
// and the only answers it could reach on its own are the wildcards this
|
||||
// contract exists to remove.
|
||||
if cfg.ListenAddress == "" {
|
||||
return fmt.Errorf("bootstrap config carries no listen address for the engine to bind")
|
||||
}
|
||||
if cfg.ClientCIDR == "" {
|
||||
return fmt.Errorf("bootstrap config carries no client CIDR to scope client authentication to")
|
||||
}
|
||||
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
|
||||
return fmt.Errorf("create handoff dir %s: %w", dir, err)
|
||||
}
|
||||
// MkdirAll leaves an existing directory's mode alone, and this one may have
|
||||
// been created more permissively earlier in the boot.
|
||||
if err := os.Chmod(dir, handoffDirMode); err != nil {
|
||||
return fmt.Errorf("secure handoff dir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
params, err := renderParameters(cfg.Engine, cfg.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHandoffFile(dir, handoffParamsFile, params); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cert and key are written as a pair; half of one would start the engine
|
||||
// with TLS configured against a key it does not have.
|
||||
if cfg.ServingCertificate != "" && cfg.ServingPrivateKey != "" {
|
||||
if err := writeHandoffFile(dir, handoffCertFile, cfg.ServingCertificate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHandoffFile(dir, handoffKeyFile, cfg.ServingPrivateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writeHandoffFile(dir, handoffEnvFile, renderBootstrapEnv(cfg))
|
||||
}
|
||||
|
||||
// Every value is single-quoted because rds-init sources this with `.`: an
|
||||
// unquoted password holding a space, `$` or `;` would be word-split or executed.
|
||||
func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Written by rds-agent. Regenerated on every boot; edits are lost.\n")
|
||||
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
|
||||
writeEnvLine(&b, "RDS_DB_INSTANCE_IDENTIFIER", cfg.DBInstanceIdentifier)
|
||||
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
|
||||
writeEnvLine(&b, "RDS_LISTEN_ADDRESS", cfg.ListenAddress)
|
||||
writeEnvLine(&b, "RDS_CLIENT_CIDR", cfg.ClientCIDR)
|
||||
writeEnvLine(&b, "RDS_DATA_VOLUME_ID", cfg.DataVolumeID)
|
||||
writeEnvLine(&b, "RDS_DATA_VOLUME_SERIAL", cfg.DataVolumeSerial)
|
||||
writeEnvLine(&b, "RDS_VM_GENERATION", strconv.FormatInt(cfg.VMGeneration, 10))
|
||||
writeEnvLine(&b, "RDS_FORMAT_AUTHORIZED", strconv.FormatBool(cfg.FormatAuthorized))
|
||||
// Neither needs volume access, which is why the assertion is made here: the
|
||||
// agent runs before rds-datadir mounts the volume, so rds-init is the first
|
||||
// component that can compare it against the completion receipt on disk.
|
||||
writeEnvLine(&b, "RDS_BOOTSTRAP_PENDING", boolFlag(cfg.BootstrapPending))
|
||||
writeEnvLine(&b, "RDS_PAYLOAD_ID", cfg.PayloadID)
|
||||
// Present only in initialize mode: an attach fetch has no password to write
|
||||
// and rds-init must not find a stale one.
|
||||
if cfg.MasterUserPassword != nil {
|
||||
writeEnvLine(&b, "RDS_MASTER_PASSWORD", *cfg.MasterUserPassword)
|
||||
}
|
||||
if cfg.DBName != "" {
|
||||
writeEnvLine(&b, "RDS_DB_NAME", cfg.DBName)
|
||||
}
|
||||
if cfg.Port > 0 {
|
||||
writeEnvLine(&b, "RDS_PORT", strconv.FormatInt(cfg.Port, 10))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rds-init compares against 1 rather than sourcing a Go boolean, so the two
|
||||
// halves of the guard cannot drift on spelling.
|
||||
func boolFlag(v bool) string {
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func writeEnvLine(b *strings.Builder, key, value string) {
|
||||
b.WriteString(key)
|
||||
b.WriteString("=")
|
||||
b.WriteString(shellQuote(value))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Single quotes suppress every shell expansion; an embedded quote is closed,
|
||||
// escaped and reopened.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// postgresql.conf syntax, which MariaDB's option files also parse. Values are
|
||||
// quoted so a space or unit suffix survives. Names are the engine's startup
|
||||
// spellings, not the customer's: the wrong one leaves the engine unable to boot.
|
||||
func renderParameters(engineName string, params []handlers_rds.Parameter) (string, error) {
|
||||
engine, err := handlers_rds.LookupEngine(engineName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render the resolved parameters: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Resolved parameter group, written by rds-agent.\n")
|
||||
for _, p := range params {
|
||||
if p.Name == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s = '%s'\n", engine.OptionFileName(p.Name), strings.ReplaceAll(p.Value, "'", "''"))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// Atomic: a temp file in the same directory, renamed over the target. The mode
|
||||
// is set at creation, so content is never briefly readable at the process umask.
|
||||
func writeHandoffFile(dir, name, content string) error {
|
||||
path := filepath.Join(dir, name)
|
||||
tmp := path + ".new"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, handoffMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", tmp, err)
|
||||
}
|
||||
// A failed write must not leave the temp file behind holding a password.
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("close %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("install %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
func TestWriteHandoff_WritesEveryFileRootOnly(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
password := "s3cret"
|
||||
|
||||
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeInitialize,
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
ListenAddress: "10.0.1.20",
|
||||
ClientCIDR: "10.0.0.0/16",
|
||||
MasterUserPassword: &password,
|
||||
DBName: "appdb",
|
||||
Port: 5433,
|
||||
DataVolumeID: "vol-data-01",
|
||||
DataVolumeSerial: "voldata01",
|
||||
VMGeneration: 4,
|
||||
FormatAuthorized: true,
|
||||
Parameters: []handlers_rds.Parameter{
|
||||
{Name: "max_connections", Value: "200"},
|
||||
{Name: "shared_buffers", Value: "256MB"},
|
||||
},
|
||||
ServingCertificate: "CERT",
|
||||
ServingPrivateKey: "KEY",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeHandoff: %v", err)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(dir, handoffEnvFile))
|
||||
for _, want := range []string{
|
||||
"RDS_MODE='initialize'",
|
||||
"RDS_MASTER_USERNAME='master'",
|
||||
"RDS_MASTER_PASSWORD='s3cret'",
|
||||
"RDS_DB_NAME='appdb'",
|
||||
"RDS_PORT='5433'",
|
||||
"RDS_DATA_VOLUME_ID='vol-data-01'",
|
||||
"RDS_DATA_VOLUME_SERIAL='voldata01'",
|
||||
"RDS_VM_GENERATION='4'",
|
||||
"RDS_FORMAT_AUTHORIZED='true'",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("handoff env missing %s:\n%s", want, env)
|
||||
}
|
||||
}
|
||||
|
||||
params := readFile(t, filepath.Join(dir, handoffParamsFile))
|
||||
if !strings.Contains(params, "max_connections = '200'") || !strings.Contains(params, "shared_buffers = '256MB'") {
|
||||
t.Errorf("parameters.conf = %q, want both resolved settings", params)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, handoffCertFile)); got != "CERT" {
|
||||
t.Errorf("server.crt = %q, want CERT", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, handoffKeyFile)); got != "KEY" {
|
||||
t.Errorf("server.key = %q, want KEY", got)
|
||||
}
|
||||
|
||||
// The handoff holds a master password and a private key; anything readable
|
||||
// beyond root is a disclosure to every process in the guest.
|
||||
if mode := statMode(t, dir); mode != handoffDirMode {
|
||||
t.Errorf("handoff dir mode = %#o, want %#o", mode, handoffDirMode)
|
||||
}
|
||||
for _, name := range []string{handoffEnvFile, handoffParamsFile, handoffCertFile, handoffKeyFile} {
|
||||
if mode := statMode(t, filepath.Join(dir, name)); mode != handoffMode {
|
||||
t.Errorf("%s mode = %#o, want %#o", name, mode, handoffMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach mode carries no password, and the key must be absent rather than
|
||||
// present and empty — rds-init reads an empty value as "no password" only by
|
||||
// accident, and a later boot would inherit it.
|
||||
func TestWriteHandoff_AttachOmitsPassword(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeAttach,
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
ListenAddress: "10.0.1.20",
|
||||
ClientCIDR: "10.0.0.0/16",
|
||||
Port: 5432,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeHandoff: %v", err)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(dir, handoffEnvFile))
|
||||
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
|
||||
t.Errorf("handoff env = %q, want no RDS_MASTER_PASSWORD key", env)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"RDS_DATA_VOLUME_ID=''",
|
||||
"RDS_DATA_VOLUME_SERIAL=''",
|
||||
"RDS_VM_GENERATION='0'",
|
||||
"RDS_FORMAT_AUTHORIZED='false'",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("handoff env missing fail-closed field %s: %q", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The env file is sourced by a shell, so a password holding shell metacharacters
|
||||
// must survive as data rather than being split, expanded or executed.
|
||||
func TestWriteHandoff_QuotesShellMetacharacters(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
password := `a b$(id);'"\` + "`whoami`"
|
||||
|
||||
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeInitialize,
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
ListenAddress: "10.0.1.20",
|
||||
ClientCIDR: "10.0.0.0/16",
|
||||
MasterUserPassword: &password,
|
||||
Port: 5432,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeHandoff: %v", err)
|
||||
}
|
||||
|
||||
env := readFile(t, filepath.Join(dir, handoffEnvFile))
|
||||
line := findLine(t, env, "RDS_MASTER_PASSWORD=")
|
||||
if got := shellSource(t, line, "RDS_MASTER_PASSWORD"); got != password {
|
||||
t.Errorf("sourced password = %q, want %q (line was %s)", got, password, line)
|
||||
}
|
||||
}
|
||||
|
||||
// A serving cert with no key would have rds-init configure TLS against a key
|
||||
// that is not there; the pair is delivered together or not at all.
|
||||
func TestWriteHandoff_SkipsHalfATLSPair(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeAttach,
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
ListenAddress: "10.0.1.20",
|
||||
ClientCIDR: "10.0.0.0/16",
|
||||
Port: 5432,
|
||||
ServingCertificate: "CERT",
|
||||
}); err != nil {
|
||||
t.Fatalf("writeHandoff: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, handoffCertFile)); !os.IsNotExist(err) {
|
||||
t.Errorf("server.crt exists without a key; stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A config with no master username cannot bootstrap anything, and writing it
|
||||
// would only move the failure into rds-init after the boot has committed to it.
|
||||
func TestWriteHandoff_RejectsConfigWithNoMasterUser(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeAttach,
|
||||
Engine: "postgres",
|
||||
Port: 5432,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("writeHandoff accepted a config with no master username, want an error")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, handoffEnvFile)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("handoff env was written despite the rejection; stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// A directory left over from an earlier boot at a looser mode must be tightened,
|
||||
// not accepted as it is found.
|
||||
func TestWriteHandoff_TightensExistingDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "handoff")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("seed dir: %v", err)
|
||||
}
|
||||
|
||||
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
|
||||
Mode: handlers_rds.BootstrapModeAttach,
|
||||
Engine: "postgres",
|
||||
MasterUsername: "master",
|
||||
ListenAddress: "10.0.1.20",
|
||||
ClientCIDR: "10.0.0.0/16",
|
||||
Port: 5432,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeHandoff: %v", err)
|
||||
}
|
||||
if mode := statMode(t, dir); mode != handoffDirMode {
|
||||
t.Errorf("handoff dir mode = %#o, want it tightened to %#o", mode, handoffDirMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote_EscapesEmbeddedQuotes(t *testing.T) {
|
||||
for _, in := range []string{"", "plain", "it's", `''`, "new\nline", `$HOME`} {
|
||||
if got := shellSource(t, "V="+shellQuote(in), "V"); got != in {
|
||||
t.Errorf("shellQuote(%q) sourced back as %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellSource evaluates one KEY=value assignment in a real shell and returns
|
||||
// what the variable ends up holding. The quoting is only correct if a shell
|
||||
// agrees, so the test asks one instead of re-implementing its rules.
|
||||
func shellSource(t *testing.T, line, name string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("/bin/sh", "-c", line+"\nprintf %s \"$"+name+"\"\n").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("sourcing %q: %v", line, err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func statMode(t *testing.T, path string) os.FileMode {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
return info.Mode().Perm()
|
||||
}
|
||||
|
||||
func findLine(t *testing.T, body, prefix string) string {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return line
|
||||
}
|
||||
}
|
||||
t.Fatalf("no line starting %q in:\n%s", prefix, body)
|
||||
return ""
|
||||
}
|
||||
|
||||
// The include rds-init installs is what the engine starts on, so the spelling
|
||||
// written here is the one that has to boot.
|
||||
func TestRenderParameters_UsesTheEngineStartupSpelling(t *testing.T) {
|
||||
params := []handlers_rds.Parameter{
|
||||
{Name: "time_zone", Value: "SYSTEM"},
|
||||
{Name: "max_connections", Value: "85"},
|
||||
}
|
||||
|
||||
rendered, err := renderParameters("mariadb", params)
|
||||
if err != nil {
|
||||
t.Fatalf("renderParameters: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "default_time_zone = 'SYSTEM'\n") {
|
||||
t.Errorf("time_zone was not written under its startup spelling:\n%s", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, "\ntime_zone = ") {
|
||||
t.Errorf("time_zone reached the option file, which mariadbd refuses:\n%s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, "max_connections = '85'\n") {
|
||||
t.Errorf("an unmapped parameter was not written unchanged:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderParameters_PostgresKeepsEveryName(t *testing.T) {
|
||||
rendered, err := renderParameters("postgres", []handlers_rds.Parameter{
|
||||
{Name: "shared_buffers", Value: "262144"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderParameters: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "shared_buffers = '262144'\n") {
|
||||
t.Errorf("postgres name was rewritten:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
// Failing closed beats writing a file the engine may refuse: the bad include
|
||||
// would already be on the data volume by the time the server rejected it.
|
||||
func TestRenderParameters_RefusesAnUnknownEngine(t *testing.T) {
|
||||
if _, err := renderParameters("", nil); err == nil {
|
||||
t.Fatal("renderParameters accepted a config naming no engine, want an error")
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// Executes one control-plane directive inside the guest and returns the message
|
||||
// reported back to the issuer.
|
||||
type commandHandler func(ctx context.Context, cmd handlers_rds.Command) (string, error)
|
||||
|
||||
// A registry rather than a dispatch switch, so guest operations — password
|
||||
// apply, parameter reload, storage grow, snapshot quiesce — add an entry each.
|
||||
type commandRegistry map[string]commandHandler
|
||||
|
||||
// An unregistered type is replied to as unsupported, so a control plane ahead
|
||||
// of the guest gets a clear answer rather than a timeout.
|
||||
func newCommandRegistry(engine engineOps, storage storageOps) commandRegistry {
|
||||
return commandRegistry{
|
||||
handlers_rds.CommandSetPassword: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
params := commandParams(cmd)
|
||||
return "", engine.SetPassword(ctx,
|
||||
params[handlers_rds.CommandParamMasterUsername],
|
||||
params[handlers_rds.CommandParamMasterUserPassword])
|
||||
},
|
||||
handlers_rds.CommandApplyParams: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
pending, err := engine.ApplyParameters(ctx, cmd.Parameters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The reply carries no structured payload, so the settings still
|
||||
// awaiting a restart come back as the message itself.
|
||||
return strings.Join(pending, ","), nil
|
||||
},
|
||||
handlers_rds.CommandStopEngine: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
return "", engine.Stop(ctx)
|
||||
},
|
||||
handlers_rds.CommandGrowFilesystem: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
return storage.GrowFilesystem(ctx)
|
||||
},
|
||||
handlers_rds.CommandQuiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
params := commandParams(cmd)
|
||||
hold, err := quiesceHoldFrom(params[handlers_rds.CommandParamQuiesceDeadlineSeconds])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// After the hold, so what is flushed is the engine's checkpointed
|
||||
// state. Its own files are not the risk — those it repairs from the
|
||||
// WAL — but the parameter file beside them it never opens.
|
||||
return "", storage.SyncDataMount(ctx)
|
||||
},
|
||||
handlers_rds.CommandUnquiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
return "", engine.Unquiesce(ctx)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The control plane's hold deadline. A missing or unreadable one is refused
|
||||
// rather than defaulted: a hold the guest chose the length of would not be the
|
||||
// bound the control plane is relying on.
|
||||
func quiesceHoldFrom(value string) (time.Duration, error) {
|
||||
seconds, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || seconds <= 0 {
|
||||
return 0, fmt.Errorf("%s must be a positive number of seconds, got %q",
|
||||
handlers_rds.CommandParamQuiesceDeadlineSeconds, value)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
|
||||
// A later value wins, so a malformed command carrying a parameter twice cannot
|
||||
// leave the handler acting on the first of two conflicting values.
|
||||
func commandParams(cmd handlers_rds.Command) map[string]string {
|
||||
params := make(map[string]string, len(cmd.Parameters))
|
||||
for _, p := range cmd.Parameters {
|
||||
params[p.Name] = p.Value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// Spaces retries after a failed poll, so a broken channel is not re-polled at
|
||||
// line rate for as long as it stays broken.
|
||||
const pollErrorBackoff = 5 * time.Second
|
||||
|
||||
// The agent's command channel: a long poll that carries back the replies for
|
||||
// whatever the previous poll delivered.
|
||||
type commander struct {
|
||||
cp controlPlane
|
||||
id identity
|
||||
registry commandRegistry
|
||||
wait time.Duration
|
||||
// Cleared only once a poll has carried them, so a failed poll re-delivers
|
||||
// rather than dropping the result of work the guest actually did.
|
||||
pending []handlers_rds.CommandReply
|
||||
}
|
||||
|
||||
func newCommander(cp controlPlane, registry commandRegistry, wait time.Duration) *commander {
|
||||
if wait <= 0 {
|
||||
wait = defaultPollWait
|
||||
}
|
||||
return &commander{cp: cp, registry: registry, wait: wait}
|
||||
}
|
||||
|
||||
func (c *commander) Run(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
commands, err := c.cp.PollCommands(ctx, c.id, c.pending, c.wait)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("rds-agent: command poll failed", "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(pollErrorBackoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.pending = nil
|
||||
|
||||
for _, cmd := range commands {
|
||||
c.pending = append(c.pending, c.execute(ctx, cmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every command gets a reply: the issuer is blocked on this command ID, so a
|
||||
// silent drop costs it a full timeout.
|
||||
func (c *commander) execute(ctx context.Context, cmd handlers_rds.Command) handlers_rds.CommandReply {
|
||||
handler, ok := c.registry[cmd.Type]
|
||||
if !ok {
|
||||
slog.Warn("rds-agent: unsupported command", "commandID", cmd.CommandID, "type", cmd.Type)
|
||||
return failedReply(cmd, fmt.Sprintf("unsupported command type %q", cmd.Type))
|
||||
}
|
||||
|
||||
slog.Info("rds-agent: executing command", "commandID", cmd.CommandID, "type", cmd.Type)
|
||||
message, err := handler(ctx, cmd)
|
||||
if err != nil {
|
||||
slog.Error("rds-agent: command failed", "commandID", cmd.CommandID, "type", cmd.Type, "err", err)
|
||||
return failedReply(cmd, err.Error())
|
||||
}
|
||||
return handlers_rds.CommandReply{
|
||||
CommandID: cmd.CommandID,
|
||||
Status: handlers_rds.CommandStatusSucceeded,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func failedReply(cmd handlers_rds.Command, message string) handlers_rds.CommandReply {
|
||||
return handlers_rds.CommandReply{
|
||||
CommandID: cmd.CommandID,
|
||||
Status: handlers_rds.CommandStatusFailed,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommander_DispatchesByNameAndReplies(t *testing.T) {
|
||||
var ran handlers_rds.Command
|
||||
registry := commandRegistry{
|
||||
"reload-parameters": func(_ context.Context, cmd handlers_rds.Command) (string, error) {
|
||||
ran = cmd
|
||||
return "reloaded", nil
|
||||
},
|
||||
}
|
||||
|
||||
reply := newCommander(nil, registry, time.Second).execute(context.Background(), handlers_rds.Command{
|
||||
CommandID: "cmd-1",
|
||||
Type: "reload-parameters",
|
||||
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
|
||||
})
|
||||
|
||||
if ran.CommandID != "cmd-1" || len(ran.Parameters) != 1 {
|
||||
t.Errorf("handler received %+v, want the command with its parameters", ran)
|
||||
}
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded || reply.Message != "reloaded" {
|
||||
t.Errorf("reply = %+v, want succeeded/reloaded", reply)
|
||||
}
|
||||
if reply.CommandID != "cmd-1" {
|
||||
t.Errorf("reply command ID = %q, want cmd-1", reply.CommandID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommander_FailedHandlerRepliesWithItsError(t *testing.T) {
|
||||
registry := commandRegistry{
|
||||
"grow-storage": func(context.Context, handlers_rds.Command) (string, error) {
|
||||
return "", errors.New("no space left on device")
|
||||
},
|
||||
}
|
||||
|
||||
reply := newCommander(nil, registry, time.Second).execute(context.Background(),
|
||||
handlers_rds.Command{CommandID: "cmd-2", Type: "grow-storage"})
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusFailed {
|
||||
t.Errorf("reply status = %q, want failed", reply.Status)
|
||||
}
|
||||
if reply.Message != "no space left on device" {
|
||||
t.Errorf("reply message = %q, want the handler's error", reply.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// A control plane ahead of the guest issues types this build has no handler
|
||||
// for. The issuer is blocked on the command ID, so it gets an answer rather
|
||||
// than a timeout.
|
||||
func TestCommander_UnknownTypeRepliesFailedNotDropped(t *testing.T) {
|
||||
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, &fakeStorage{}), time.Second).execute(context.Background(),
|
||||
handlers_rds.Command{CommandID: "cmd-3", Type: "quiesce"})
|
||||
|
||||
if reply.CommandID != "cmd-3" || reply.Status != handlers_rds.CommandStatusFailed {
|
||||
t.Errorf("reply = %+v, want a failed reply for cmd-3", reply)
|
||||
}
|
||||
if reply.Message == "" {
|
||||
t.Error("unsupported-command reply carried no message")
|
||||
}
|
||||
}
|
||||
|
||||
// The reply for work the guest actually did rides the next poll. A poll that
|
||||
// fails must not consume it, or the issuer never learns the outcome.
|
||||
func TestCommander_RunCarriesRepliesOnTheNextPoll(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollQueue = [][]handlers_rds.Command{
|
||||
{{CommandID: "cmd-1", Type: "reload-parameters"}},
|
||||
}
|
||||
registry := commandRegistry{
|
||||
"reload-parameters": func(context.Context, handlers_rds.Command) (string, error) { return "done", nil },
|
||||
}
|
||||
|
||||
c := newCommander(cp, registry, 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
replies := cp.snapshotReplies()
|
||||
return len(replies) >= 2 && len(replies[1]) == 1
|
||||
}, "the reply to ride the second poll")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
replies := cp.snapshotReplies()
|
||||
if len(replies[0]) != 0 {
|
||||
t.Errorf("first poll carried replies %+v, want none", replies[0])
|
||||
}
|
||||
if got := replies[1][0]; got.CommandID != "cmd-1" || got.Status != handlers_rds.CommandStatusSucceeded {
|
||||
t.Errorf("second poll carried %+v, want the succeeded reply for cmd-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommander_RetainsAndRetriesTheCompletePendingBatch(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
pending := []handlers_rds.CommandReply{
|
||||
{CommandID: "cmd-1", Status: handlers_rds.CommandStatusSucceeded},
|
||||
{CommandID: "cmd-2", Status: handlers_rds.CommandStatusFailed, Message: "disk full"},
|
||||
}
|
||||
c := newCommander(cp, commandRegistry{
|
||||
"reload-parameters": func(context.Context, handlers_rds.Command) (string, error) {
|
||||
return "reloaded", nil
|
||||
},
|
||||
}, 10*time.Millisecond)
|
||||
c.pending = append([]handlers_rds.CommandReply(nil), pending...)
|
||||
cp.pollErr = errors.New("reply publication failed")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
<-cp.polled
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
assert.Equal(t, pending, c.pending)
|
||||
replies := cp.snapshotReplies()
|
||||
require.Len(t, replies, 1)
|
||||
assert.Equal(t, pending, replies[0])
|
||||
|
||||
cp.mu.Lock()
|
||||
cp.pollErr = nil
|
||||
cp.pollQueue = [][]handlers_rds.Command{{{
|
||||
CommandID: "cmd-3",
|
||||
Type: "reload-parameters",
|
||||
}}}
|
||||
cp.mu.Unlock()
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
done = make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
waitFor(t, func() bool {
|
||||
return len(cp.snapshotReplies()) >= 3
|
||||
}, "the retained batch to succeed and the new reply to reach the following poll")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
replies = cp.snapshotReplies()
|
||||
assert.Equal(t, pending, replies[1], "the complete failed batch must be retried in order")
|
||||
require.Len(t, replies[2], 1)
|
||||
assert.Equal(t, "cmd-3", replies[2][0].CommandID)
|
||||
assert.Equal(t, handlers_rds.CommandStatusSucceeded, replies[2][0].Status)
|
||||
}
|
||||
|
||||
// A poll error must not spin the loop: without a backoff a gateway that is down
|
||||
// would be re-polled at line rate for as long as it stayed down.
|
||||
func TestCommander_BacksOffAfterAFailedPoll(t *testing.T) {
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollErr = errors.New("gateway unreachable")
|
||||
|
||||
c := newCommander(cp, newCommandRegistry(&fakeEngine{}, &fakeStorage{}), 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
<-done
|
||||
|
||||
// pollErrorBackoff is 5s, so the window admits the first poll and no more.
|
||||
if polls := len(cp.snapshotReplies()); polls > 1 {
|
||||
t.Errorf("polled %d times in 200ms against a broken channel, want 1", polls)
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/guestenv"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEnvFile = "/etc/spinifex-rds/agent.env"
|
||||
defaultGatewayCA = "/etc/spinifex-rds/gateway-ca.pem"
|
||||
defaultHandoffDir = "/run/spinifex-rds"
|
||||
// Where setup.sh stamps the engine the image bakes. The agent builds its
|
||||
// engine implementation from this file rather than from anything delivered.
|
||||
defaultEngineFile = "/etc/spinifex-rds/engine"
|
||||
defaultRCService = "rc-service"
|
||||
defaultEngineProbeTimeout = 5 * time.Second
|
||||
// The long-poll window the agent asks the gateway to hold a request open
|
||||
// for. The gateway caps it at 20s.
|
||||
defaultPollWait = 20 * time.Second
|
||||
)
|
||||
|
||||
// Static settings delivered per-instance by cloud-init. It carries no secrets:
|
||||
// IMDS is readable by anything in the guest, so the master password only
|
||||
// arrives via GetDBBootstrapConfig.
|
||||
type config struct {
|
||||
GatewayURL string
|
||||
GatewayCA string
|
||||
Region string
|
||||
// Optional — the gateway resolves the instance from the caller's
|
||||
// credentials. When set it is sent, so a mis-provisioned VM is rejected.
|
||||
DBInstanceIdentifier string
|
||||
// What this image actually ships. Empty leaves the control plane's recorded
|
||||
// version alone rather than clearing it.
|
||||
EngineVersion string
|
||||
HandoffDir string
|
||||
EnginePort int
|
||||
PollWait time.Duration
|
||||
|
||||
// The engine the control plane launched this VM as, delivered by cloud-init,
|
||||
// and the engine the image itself bakes. Two independent statements of the
|
||||
// same fact: the agent refuses to bootstrap when they disagree.
|
||||
Engine string
|
||||
EngineFile string
|
||||
BakedEngine string
|
||||
|
||||
// Where the command handlers reach the engine: the client binaries, the
|
||||
// datadir the parameter file is installed into, the socket they connect
|
||||
// over, the OS user they drop to, and the service the stop goes through.
|
||||
EngineBinDir string
|
||||
EngineDataDir string
|
||||
SocketDir string
|
||||
EngineUser string
|
||||
RCService string
|
||||
EngineService string
|
||||
// Where the engine records the pid its probe checks for liveness, for an
|
||||
// engine whose own client cannot tell a server still recovering from one that
|
||||
// is not running at all.
|
||||
EnginePidFile string
|
||||
EngineProbeTimeout time.Duration
|
||||
// Where the engine records why it would not start, which the probe quotes
|
||||
// when the server is up but not serving. Overridable so a test can point it
|
||||
// at a fixture.
|
||||
EngineErrorLog string
|
||||
|
||||
// Where the data volume is mounted, and the two kernel surfaces a storage
|
||||
// grow resolves its device from. Overridable so a test can point them at
|
||||
// fixtures rather than at the running host's.
|
||||
DataMount string
|
||||
MountsFile string
|
||||
SysBlock string
|
||||
}
|
||||
|
||||
// Reads the cloud-init env file, then lets real env vars override.
|
||||
func loadConfig(envFile string) config {
|
||||
get := guestenv.Load(envFile).Get
|
||||
|
||||
cfg := config{
|
||||
GatewayURL: get("RDS_GATEWAY_URL"),
|
||||
GatewayCA: get("RDS_GATEWAY_CA"),
|
||||
Region: get("RDS_REGION"),
|
||||
DBInstanceIdentifier: get("RDS_DB_INSTANCE_IDENTIFIER"),
|
||||
Engine: get("RDS_ENGINE"),
|
||||
EngineFile: get("RDS_ENGINE_FILE"),
|
||||
EngineVersion: get("RDS_ENGINE_VERSION"),
|
||||
HandoffDir: get("RDS_HANDOFF_DIR"),
|
||||
EngineBinDir: get("RDS_ENGINE_BIN"),
|
||||
EngineDataDir: get("RDS_ENGINE_DATA"),
|
||||
SocketDir: get("RDS_SOCKET_DIR"),
|
||||
EngineUser: get("RDS_ENGINE_USER"),
|
||||
RCService: get("RDS_RC_SERVICE"),
|
||||
EngineService: get("RDS_ENGINE_SERVICE"),
|
||||
EnginePidFile: get("RDS_ENGINE_PIDFILE"),
|
||||
EngineErrorLog: get("RDS_ENGINE_LOG"),
|
||||
DataMount: get("RDS_DATA_MOUNT"),
|
||||
PollWait: defaultPollWait,
|
||||
EngineProbeTimeout: defaultEngineProbeTimeout,
|
||||
}
|
||||
if cfg.GatewayCA == "" {
|
||||
cfg.GatewayCA = defaultGatewayCA
|
||||
}
|
||||
if cfg.HandoffDir == "" {
|
||||
cfg.HandoffDir = defaultHandoffDir
|
||||
}
|
||||
if cfg.EngineFile == "" {
|
||||
cfg.EngineFile = defaultEngineFile
|
||||
}
|
||||
if cfg.RCService == "" {
|
||||
cfg.RCService = defaultRCService
|
||||
}
|
||||
// The image's own stamp rather than the delivered engine: an agent that took
|
||||
// its layout from the payload could run one engine's implementation against
|
||||
// another engine's datadir, which is the failure the stamp exists to prevent.
|
||||
cfg.BakedEngine = readBakedEngine(cfg.EngineFile)
|
||||
cfg.applyLayout(engineLayouts[cfg.BakedEngine])
|
||||
|
||||
// rds-datadir reads the same RDS_DATA_MOUNT, so the two agree on where the
|
||||
// volume landed without either asserting it to the other.
|
||||
cfg.MountsFile = defaultMountsFile
|
||||
cfg.SysBlock = defaultSysBlock
|
||||
// The authoritative port comes from the bootstrap config; this is only what
|
||||
// the probe uses until that first fetch lands.
|
||||
if v := get("RDS_ENGINE_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil && p > 0 {
|
||||
cfg.EnginePort = p
|
||||
}
|
||||
}
|
||||
if v := get("RDS_POLL_WAIT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.PollWait = d
|
||||
}
|
||||
}
|
||||
if v := get("RDS_ENGINE_PROBE_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.EngineProbeTimeout = d
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Fills in whatever the delivered configuration left unset. An override always
|
||||
// wins, so a test can point any of these at a fixture. An unrecognised engine
|
||||
// leaves the layout empty, and New refuses rather than guessing at one.
|
||||
func (c *config) applyLayout(layout engineLayout) {
|
||||
if c.EngineBinDir == "" {
|
||||
c.EngineBinDir = layout.binDir
|
||||
}
|
||||
if c.EngineDataDir == "" {
|
||||
c.EngineDataDir = layout.dataDir
|
||||
}
|
||||
if c.SocketDir == "" {
|
||||
c.SocketDir = layout.socketDir
|
||||
}
|
||||
if c.EngineUser == "" {
|
||||
c.EngineUser = layout.osUser
|
||||
}
|
||||
if c.EngineService == "" {
|
||||
c.EngineService = layout.service
|
||||
}
|
||||
if c.EnginePidFile == "" {
|
||||
c.EnginePidFile = layout.pidFile
|
||||
}
|
||||
if c.EngineErrorLog == "" {
|
||||
c.EngineErrorLog = layout.errorLog
|
||||
}
|
||||
if c.DataMount == "" {
|
||||
c.DataMount = layout.dataMount
|
||||
}
|
||||
if c.EnginePort == 0 {
|
||||
c.EnginePort = layout.port
|
||||
}
|
||||
}
|
||||
|
||||
// The engine the image bakes. An absent or unreadable stamp reads as empty,
|
||||
// which New refuses: an agent that guessed would defeat the stamp.
|
||||
func readBakedEngine(path string) string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(string(raw)))
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stamps the engine a real image bakes and loads the configuration against it,
|
||||
// which is what selects the guest layout the agent's defaults come from.
|
||||
func testLoadConfig(t *testing.T, engine string) config {
|
||||
t.Helper()
|
||||
stamp := filepath.Join(t.TempDir(), "engine")
|
||||
if err := os.WriteFile(stamp, []byte(engine+"\n"), 0o444); err != nil {
|
||||
t.Fatalf("write the engine stamp: %v", err)
|
||||
}
|
||||
t.Setenv("RDS_ENGINE_FILE", stamp)
|
||||
return loadConfig(filepath.Join(t.TempDir(), "absent.env"))
|
||||
}
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
cfg := testLoadConfig(t, enginePostgres)
|
||||
|
||||
if cfg.GatewayCA != defaultGatewayCA || cfg.HandoffDir != defaultHandoffDir {
|
||||
t.Errorf("CA/handoff = %q/%q, want the built-in defaults", cfg.GatewayCA, cfg.HandoffDir)
|
||||
}
|
||||
if cfg.PollWait != defaultPollWait {
|
||||
t.Errorf("pollWait = %v, want the built-in default", cfg.PollWait)
|
||||
}
|
||||
if cfg.EngineProbeTimeout != defaultEngineProbeTimeout {
|
||||
t.Errorf("engine probe timeout = %v, want %v", cfg.EngineProbeTimeout, defaultEngineProbeTimeout)
|
||||
}
|
||||
// Identity is optional: the gateway resolves it from the caller's
|
||||
// credentials, so an agent with no configured identifier is normal.
|
||||
if cfg.DBInstanceIdentifier != "" {
|
||||
t.Errorf("DBInstanceIdentifier = %q, want empty when unconfigured", cfg.DBInstanceIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
// The guest layout is the image's to state, not the control plane's: everything
|
||||
// the agent reaches the engine through comes from the stamp it was built with.
|
||||
func TestLoadConfig_TakesTheLayoutFromTheBakedEngine(t *testing.T) {
|
||||
cfg := testLoadConfig(t, enginePostgres)
|
||||
|
||||
if cfg.BakedEngine != enginePostgres {
|
||||
t.Fatalf("baked engine = %q, want %q", cfg.BakedEngine, enginePostgres)
|
||||
}
|
||||
layout := engineLayouts[enginePostgres]
|
||||
if cfg.EngineBinDir != layout.binDir || cfg.EngineDataDir != layout.dataDir {
|
||||
t.Errorf("bin/data = %q/%q, want the PostgreSQL layout", cfg.EngineBinDir, cfg.EngineDataDir)
|
||||
}
|
||||
if cfg.EngineUser != layout.osUser || cfg.EngineService != layout.service {
|
||||
t.Errorf("user/service = %q/%q, want the PostgreSQL layout", cfg.EngineUser, cfg.EngineService)
|
||||
}
|
||||
if cfg.SocketDir != layout.socketDir || cfg.DataMount != layout.dataMount {
|
||||
t.Errorf("socket/mount = %q/%q, want the PostgreSQL layout", cfg.SocketDir, cfg.DataMount)
|
||||
}
|
||||
if cfg.EnginePort != layout.port {
|
||||
t.Errorf("port = %d, want the PostgreSQL default %d", cfg.EnginePort, layout.port)
|
||||
}
|
||||
}
|
||||
|
||||
// An image with no stamp cannot say which engine it is, and an agent that
|
||||
// guessed would be the failure the stamp exists to prevent.
|
||||
func TestNewEngine_RefusesAnImageWithNoEngineStamp(t *testing.T) {
|
||||
t.Setenv("RDS_ENGINE_FILE", filepath.Join(t.TempDir(), "absent-engine"))
|
||||
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
|
||||
|
||||
if _, err := newProbe(cfg, staticProbe(0)); err == nil {
|
||||
t.Error("newProbe built a probe for an image carrying no engine stamp")
|
||||
}
|
||||
if _, err := newEngine(cfg, nil, nil, nil); err == nil {
|
||||
t.Error("newEngine built an engine for an image carrying no engine stamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEngine_RefusesAnEngineItDoesNotImplement(t *testing.T) {
|
||||
cfg := testLoadConfig(t, "oracle")
|
||||
|
||||
_, err := newEngine(cfg, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("newEngine built an engine this agent does not implement")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oracle") {
|
||||
t.Errorf("error = %v, want it to name the engine the image bakes", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The implementation is the agent's, but the definition it validates names and
|
||||
// classifies parameters against is the control plane's, which now offers MariaDB.
|
||||
func TestNewEngine_BuildsMariaDBForAMariaDBImage(t *testing.T) {
|
||||
cfg := testLoadConfig(t, engineMariaDB)
|
||||
probe, err := newProbe(cfg, staticProbe(0))
|
||||
if err != nil {
|
||||
t.Fatalf("newProbe: %v", err)
|
||||
}
|
||||
|
||||
built, err := newEngine(cfg, nil, nil, probe)
|
||||
if err != nil {
|
||||
t.Fatalf("newEngine: %v", err)
|
||||
}
|
||||
if _, ok := built.(*mariadbEngine); !ok {
|
||||
t.Errorf("newEngine built %T for a MariaDB image", built)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe reads the pidfile Alpine's packaged service starts mariadbd with,
|
||||
// because nothing in the generated configuration can move it: the service passes
|
||||
// --pid-file on the command line, which beats an option file.
|
||||
func TestLoadConfig_TakesTheMariaDBPidfileFromTheLayout(t *testing.T) {
|
||||
cfg := testLoadConfig(t, engineMariaDB)
|
||||
|
||||
if cfg.EnginePidFile != engineLayouts[engineMariaDB].pidFile {
|
||||
t.Errorf("pidfile = %q, want the MariaDB layout's", cfg.EnginePidFile)
|
||||
}
|
||||
if cfg.EnginePidFile == "" {
|
||||
t.Error("an engine whose probe needs a pidfile was given none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ReadsEnvFile(t *testing.T) {
|
||||
t.Setenv("RDS_ENGINE_FILE", filepath.Join(t.TempDir(), "absent-engine"))
|
||||
path := filepath.Join(t.TempDir(), "agent.env")
|
||||
body := "RDS_GATEWAY_URL=https://gw.internal:9999\n" +
|
||||
"RDS_REGION=ap-southeast-2\n" +
|
||||
"RDS_DB_INSTANCE_IDENTIFIER=db-1\n" +
|
||||
"RDS_ENGINE=postgres\n" +
|
||||
"RDS_ENGINE_VERSION=18.1\n" +
|
||||
"RDS_ENGINE_PORT=6543\n" +
|
||||
"RDS_POLL_WAIT=5s\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write env file: %v", err)
|
||||
}
|
||||
|
||||
cfg := loadConfig(path)
|
||||
if cfg.GatewayURL != "https://gw.internal:9999" || cfg.Region != "ap-southeast-2" {
|
||||
t.Errorf("gateway/region = %q/%q, want the delivered values", cfg.GatewayURL, cfg.Region)
|
||||
}
|
||||
if cfg.DBInstanceIdentifier != "db-1" || cfg.EngineVersion != "18.1" {
|
||||
t.Errorf("identity = %q/%q, want db-1/18.1", cfg.DBInstanceIdentifier, cfg.EngineVersion)
|
||||
}
|
||||
if cfg.Engine != enginePostgres {
|
||||
t.Errorf("delivered engine = %q, want the one cloud-init carried", cfg.Engine)
|
||||
}
|
||||
if cfg.EnginePort != 6543 || cfg.PollWait != 5*time.Second {
|
||||
t.Errorf("port/pollWait = %d/%v, want 6543/5s", cfg.EnginePort, cfg.PollWait)
|
||||
}
|
||||
}
|
||||
|
||||
// An unparseable override falls back to the layout's own default rather than
|
||||
// leaving the agent with a zero port or a zero-length long poll.
|
||||
func TestLoadConfig_IgnoresUnusableOverrides(t *testing.T) {
|
||||
t.Setenv("RDS_ENGINE_PORT", "not-a-port")
|
||||
t.Setenv("RDS_POLL_WAIT", "forever")
|
||||
|
||||
cfg := testLoadConfig(t, enginePostgres)
|
||||
if cfg.EnginePort != engineLayouts[enginePostgres].port || cfg.PollWait != defaultPollWait {
|
||||
t.Errorf("port/pollWait = %d/%v, want the defaults", cfg.EnginePort, cfg.PollWait)
|
||||
}
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The in-guest half of the engine seam. Everything engine-shaped that a
|
||||
// control-plane command needs lives behind this, so the command registry stays
|
||||
// engine-agnostic.
|
||||
type engineOps interface {
|
||||
// Rotates the master role's password live. Never persisted anywhere in the
|
||||
// guest: it exists only for the length of this call.
|
||||
SetPassword(ctx context.Context, username, password string) error
|
||||
// Installs the resolved parameter set and reloads. Returns the settings the
|
||||
// engine accepted but will not honour until it restarts.
|
||||
ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error)
|
||||
// Shuts the engine down cleanly, so the data volume is checkpointed before
|
||||
// the VM is stopped or snapshotted.
|
||||
Stop(ctx context.Context) error
|
||||
// Puts the engine into backup mode so the datadir can be snapshotted at a
|
||||
// checkpoint. Releases itself after hold, whatever happens to the caller.
|
||||
Quiesce(ctx context.Context, label string, hold time.Duration) error
|
||||
// Takes the engine back out of backup mode. Fails when no hold is active,
|
||||
// which is how a hold that expired mid-snapshot becomes visible.
|
||||
Unquiesce(ctx context.Context) error
|
||||
}
|
||||
|
||||
// The rollback half of the parameter path, kept off engineOps because it is not
|
||||
// a control-plane directive: nothing issues it, the agent runs it when the
|
||||
// engine fails to come up after a parameter change.
|
||||
type parameterRecovery interface {
|
||||
// Reports whether the installed set differed and was replaced. True with a
|
||||
// non-nil error is a set already on disk whose re-derived TLS enforcement did
|
||||
// not land: the caller still has to restart and report.
|
||||
RestoreLastKnownGoodParameters(ctx context.Context) (bool, error)
|
||||
Restart(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Records the parameter file only after the probe observes the engine serving
|
||||
// it. This is separate from apply, where static values are installed but not
|
||||
// adopted until a later restart.
|
||||
type servingParameterRecorder interface {
|
||||
RecordServingParameters(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Everything one engine implementation owns. The narrow interfaces above are
|
||||
// what the heartbeat, the commander and the rollback guard each hold; this is
|
||||
// only what the agent has to construct.
|
||||
type engine interface {
|
||||
engineOps
|
||||
parameterRecovery
|
||||
servingParameterRecorder
|
||||
}
|
||||
|
||||
// The guest layout an engine preset lays down, keyed by the engine the image
|
||||
// bakes. Every default that belongs to one engine lives here, so nothing
|
||||
// outside an implementation names its paths, service, port or probe.
|
||||
type engineLayout struct {
|
||||
binDir string
|
||||
dataDir string
|
||||
socketDir string
|
||||
osUser string
|
||||
service string
|
||||
dataMount string
|
||||
port int
|
||||
// Where the engine records the pid the probe checks for liveness. Empty for
|
||||
// an engine whose probe answers without one.
|
||||
pidFile string
|
||||
// Where the engine records why it would not start. Empty for an engine whose
|
||||
// image does not direct one, which leaves the probe reporting its own reason
|
||||
// alone rather than guessing at a path.
|
||||
errorLog string
|
||||
// The engine's own liveness signal, which is the only part of engineProbe
|
||||
// that is not shared.
|
||||
newProbe func(cfg config, run probeRunner) *engineProbe
|
||||
// The implementation every control-plane directive is served by. It fails
|
||||
// when the control plane this agent was built against does not offer the
|
||||
// engine the image bakes, rather than serving one it has no definition for.
|
||||
newEngine func(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error)
|
||||
}
|
||||
|
||||
// The names the control plane knows these implementations by, which are also
|
||||
// what setup.sh stamps into each image.
|
||||
const (
|
||||
enginePostgres = "postgres"
|
||||
engineMariaDB = "mariadb"
|
||||
)
|
||||
|
||||
var engineLayouts = map[string]engineLayout{
|
||||
enginePostgres: {
|
||||
binDir: "/usr/libexec/postgresql18",
|
||||
dataDir: "/var/lib/postgresql/18/data",
|
||||
socketDir: "/run/postgresql",
|
||||
osUser: "postgres",
|
||||
service: "postgresql",
|
||||
dataMount: "/var/lib/postgresql",
|
||||
port: 5432,
|
||||
// logfile in the image's /etc/conf.d/postgresql. The postmaster states
|
||||
// which setting it refused there and nowhere else — the console shows only
|
||||
// that pg_ctl could not start it, on a guest with no SSH.
|
||||
errorLog: "/var/log/postgresql/postmaster.log",
|
||||
newProbe: newPostgresProbe,
|
||||
newEngine: newPostgresEngineFromCatalog,
|
||||
},
|
||||
engineMariaDB: {
|
||||
binDir: "/usr/bin",
|
||||
dataDir: "/var/lib/mysql/data",
|
||||
socketDir: "/run/mysqld",
|
||||
osUser: "mysql",
|
||||
service: "mariadb",
|
||||
dataMount: "/var/lib/mysql",
|
||||
port: 3306,
|
||||
// Alpine's service passes --pid-file=/run/mysqld/$RC_SVCNAME.pid, and the
|
||||
// command line beats the option file, so this is the path rather than
|
||||
// anything the drop-in names. setup.sh asserts it at build time.
|
||||
pidFile: "/run/mysqld/mariadb.pid",
|
||||
// log_error in the platform drop-in rds-init writes. Without it
|
||||
// mysqld_safe would send the server's errors to syslog, which nothing
|
||||
// collects off a guest that has no SSH.
|
||||
errorLog: "/var/log/mysql/error.log",
|
||||
newProbe: newMariaDBProbe,
|
||||
newEngine: newMariaDBEngineFromCatalog,
|
||||
},
|
||||
}
|
||||
|
||||
// How much of the engine's error log a probe reason carries. The server names
|
||||
// what it refused on in its last few lines, and the reason reaches a customer
|
||||
// through StatusInfos, so this quotes the tail rather than the whole file.
|
||||
const (
|
||||
engineErrorLogTailBytes = 4096
|
||||
engineErrorLogTailLines = 4
|
||||
)
|
||||
|
||||
// Appends the last few lines of the engine's error log to reason. An unset path,
|
||||
// an unreadable file or an empty one leaves reason alone: the probe's own
|
||||
// statement is still true, and a guessed cause would be worse than none.
|
||||
func withEngineLogTail(reason, path string) string {
|
||||
tail := engineLogTail(path)
|
||||
if tail == "" {
|
||||
return reason
|
||||
}
|
||||
return reason + "; the engine's log ends: " + tail
|
||||
}
|
||||
|
||||
func engineLogTail(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
offset := max(info.Size()-engineErrorLogTailBytes, 0)
|
||||
buf := make([]byte, info.Size()-offset)
|
||||
if _, err := f.ReadAt(buf, offset); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(buf), "\n")
|
||||
// A read that started mid-file almost certainly started mid-line, and half a
|
||||
// line is more misleading than one fewer.
|
||||
if offset > 0 && len(lines) > 0 {
|
||||
lines = lines[1:]
|
||||
}
|
||||
kept := make([]string, 0, engineErrorLogTailLines)
|
||||
for i := len(lines) - 1; i >= 0 && len(kept) < engineErrorLogTailLines; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
slices.Reverse(kept)
|
||||
return strings.Join(kept, " | ")
|
||||
}
|
||||
|
||||
// Builds the health probe the image's engine supplies.
|
||||
func newProbe(cfg config, run probeRunner) (*engineProbe, error) {
|
||||
layout, err := layoutFor(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return layout.newProbe(cfg, run), nil
|
||||
}
|
||||
|
||||
// Builds the implementation the image bakes. Nothing here consults the control
|
||||
// plane for which engine to be: the agent runs the engine it is made of, and the
|
||||
// delivered engine is only ever checked against it.
|
||||
func newEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
|
||||
layout, err := layoutFor(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return layout.newEngine(cfg, run, startSess, probe)
|
||||
}
|
||||
|
||||
func layoutFor(cfg config) (engineLayout, error) {
|
||||
if cfg.BakedEngine == "" {
|
||||
return engineLayout{}, fmt.Errorf("this image carries no engine stamp at %s", cfg.EngineFile)
|
||||
}
|
||||
layout, ok := engineLayouts[cfg.BakedEngine]
|
||||
if !ok {
|
||||
return engineLayout{}, fmt.Errorf("this image bakes engine %q, which this agent does not implement", cfg.BakedEngine)
|
||||
}
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
// One child process. Env replaces the agent's own environment rather than
|
||||
// extending it, so a secret placed here reaches only the process that needs it.
|
||||
type command struct {
|
||||
Name string
|
||||
Args []string
|
||||
Env []string
|
||||
Stdin string
|
||||
// The OS user to drop to before exec. Empty runs as the agent itself.
|
||||
User string
|
||||
// The statement that makes this client print the session sentinel. Read only
|
||||
// when the command is started as a held session, whose reader has to know
|
||||
// when the engine has finished each script it was fed.
|
||||
SentinelStatement string
|
||||
}
|
||||
|
||||
// Returns stdout. stderr is folded into the error, because both clients report
|
||||
// the actual SQL failure there while exiting with a bare status.
|
||||
type commandRunner func(ctx context.Context, c command) (string, error)
|
||||
|
||||
func execCommandRunner(ctx context.Context, c command) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, c.Name, c.Args...)
|
||||
cmd.Env = c.Env
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
if c.User != "" {
|
||||
credential, err := lookupCredential(c.User)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return stdout.String(), fmt.Errorf("%s: %s", filepath.Base(c.Name), message)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func lookupCredential(username string) (*syscall.Credential, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve user %s: %w", username, err)
|
||||
}
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse uid of %s: %w", username, err)
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse gid of %s: %w", username, err)
|
||||
}
|
||||
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
|
||||
}
|
||||
|
||||
// Through the service manager rather than the engine's own control tool, so the
|
||||
// supervisor records the state and does not restart the engine underneath a VM
|
||||
// that is going down.
|
||||
func serviceAction(ctx context.Context, run commandRunner, rcService, service, action string) error {
|
||||
if _, err := run(ctx, command{
|
||||
Name: rcService,
|
||||
Args: []string{service, action},
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%s the %s service: %w", action, service, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Whether the installed set requires TLS of client connections. Read back from
|
||||
// the file rather than taken from the set being applied, because the restore and
|
||||
// repair paths put a file in place without holding one. Both engines' generated
|
||||
// parameter files are written in the same syntax, so one reader serves them.
|
||||
func installedTLSEnforcement(name, installedPath string) (bool, error) {
|
||||
values, err := readOptionFile(installedPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("read the installed parameters: %w", err)
|
||||
}
|
||||
value, ok := values[name]
|
||||
if !ok {
|
||||
// The ordinary state of an instance whose resolved set predates the
|
||||
// parameter, and the whole of the migration story: it begins enforcing at
|
||||
// its next boot with no control-plane work at all.
|
||||
return true, nil
|
||||
}
|
||||
switch value {
|
||||
case "1":
|
||||
return true, nil
|
||||
case "0":
|
||||
return false, nil
|
||||
}
|
||||
// The resolver canonicalises every boolean, so anything else means the file
|
||||
// was written by something other than the platform. The permissive reading is
|
||||
// the one that must not be chosen for a setting that turns TLS off.
|
||||
return false, fmt.Errorf("the installed parameters set %s to %q, which is neither 1 nor 0", name, value)
|
||||
}
|
||||
|
||||
// A minimal environment for a child that is not carrying secrets.
|
||||
const defaultGuestPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
func redact(s, secret string) string {
|
||||
if secret == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, secret, "[REDACTED]")
|
||||
}
|
||||
@@ -1,762 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The control plane's own statements about this engine, resolved once at
|
||||
// startup. Held as functions rather than as the Engine value so nothing here
|
||||
// re-derives a rule the API already owns.
|
||||
type controlPlaneRules struct {
|
||||
// The whole master-username rule rather than only the reserved set. MariaDB's
|
||||
// client has no identifier quoting, so the name is interpolated into SQL by
|
||||
// this process and every part of the rule is load-bearing here.
|
||||
validateUsername func(username string) error
|
||||
// Whether a setting takes effect only at a restart. Already customer-facing
|
||||
// and authoritative, since the API refuses ApplyMethod=immediate on one.
|
||||
isStatic func(name string) bool
|
||||
// The catalog name behind a spelling read back out of an option file. Only a
|
||||
// setting whose startup spelling differs from the customer's moves.
|
||||
catalogName func(optionFileName string) string
|
||||
// The catalog's data type for a setting, which decides how its value is
|
||||
// rendered into SQL. Empty for a name the catalog does not carry.
|
||||
dataType func(name string) string
|
||||
// The engine's own name for the setting that requires TLS of a client
|
||||
// connection. Held rather than spelled out here, so the guest derives
|
||||
// enforcement from the same key the control plane resolves it under.
|
||||
tlsEnforcementParameter string
|
||||
}
|
||||
|
||||
func controlPlaneRulesFrom(meta handlers_rds.Engine) controlPlaneRules {
|
||||
// Built once per engine: everything read back out of a generated file has to
|
||||
// return to the catalog's namespace before it is classified or reported, or a
|
||||
// startup spelling would read as an unknown name.
|
||||
catalogNames := map[string]string{}
|
||||
for _, name := range meta.CatalogParameterNames() {
|
||||
if optionFileName := meta.OptionFileName(name); optionFileName != name {
|
||||
catalogNames[optionFileName] = name
|
||||
}
|
||||
}
|
||||
|
||||
return controlPlaneRules{
|
||||
validateUsername: meta.ValidateMasterUsername,
|
||||
isStatic: func(name string) bool {
|
||||
spec, ok := meta.LookupParameter(name)
|
||||
// A name the catalog does not carry cannot be shown to have been adopted
|
||||
// without a restart, and is never issued as a live SET GLOBAL either.
|
||||
return !ok || spec.ApplyType == handlers_rds.ApplyTypeStatic
|
||||
},
|
||||
catalogName: func(optionFileName string) string {
|
||||
if name, ok := catalogNames[optionFileName]; ok {
|
||||
return name
|
||||
}
|
||||
return optionFileName
|
||||
},
|
||||
dataType: func(name string) string {
|
||||
spec, ok := meta.LookupParameter(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return spec.DataType
|
||||
},
|
||||
tlsEnforcementParameter: meta.TLSEnforcementParameter(),
|
||||
}
|
||||
}
|
||||
|
||||
// Reached over its unix socket as root, which the datadir's unix_socket plugin
|
||||
// authenticates from the connecting process' own uid. That is why the agent
|
||||
// holds no password of its own.
|
||||
type mariadbEngine struct {
|
||||
quiesceState
|
||||
parameterManager
|
||||
|
||||
rules controlPlaneRules
|
||||
run commandRunner
|
||||
startSess sessionRunner
|
||||
client string
|
||||
socket string
|
||||
rcService string
|
||||
service string
|
||||
}
|
||||
|
||||
var _ engine = (*mariadbEngine)(nil)
|
||||
|
||||
const (
|
||||
mariadbClientBinary = "mariadb"
|
||||
mariadbAdminBinary = "mariadb-admin"
|
||||
// The account mariadb-install-db creates for the unix_socket plugin, which
|
||||
// rds-init keeps for the platform and never hands to the customer.
|
||||
mariadbSuperuser = "root"
|
||||
mariadbProbeConnectTimeoutSeconds = 3
|
||||
// How much of the probe client's stderr a reason carries, on the same grounds
|
||||
// as the log tail above.
|
||||
mariadbProbeStderrMaxBytes = 512
|
||||
// Named by the platform drop-in rds-init writes, so both halves reach the
|
||||
// same socket without either asserting it to the other.
|
||||
mariadbSocketFile = "mysqld.sock"
|
||||
// The customer reaches the instance over its customer ENI, so the master is
|
||||
// created and rotated on the wildcard host rather than on localhost.
|
||||
mariadbMasterHost = "%"
|
||||
)
|
||||
|
||||
// The generated drop-ins on the data volume. The rollback and serving copies
|
||||
// deliberately do not end in .cnf: !includedir reads *.cnf, and neither is a
|
||||
// second set of settings.
|
||||
const (
|
||||
mariadbParametersFile = "10-rds-parameters.cnf"
|
||||
mariadbLastGoodFile = "10-rds-parameters.last-good"
|
||||
mariadbServingFile = "10-rds-parameters.serving"
|
||||
// MariaDB treats a setting before any group as a fatal parsing error, so
|
||||
// every generated file carries the header rds-init writes.
|
||||
mariadbParametersHeader = "[mysqld]\n"
|
||||
)
|
||||
|
||||
// The layout's factory. The rules come from the control plane's own definition
|
||||
// of this engine, so a build whose control plane does not offer MariaDB refuses
|
||||
// to run this implementation rather than inventing a definition for it.
|
||||
func newMariaDBEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
|
||||
meta, err := handlers_rds.LookupEngine(engineMariaDB)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", engineMariaDB, err)
|
||||
}
|
||||
return newMariaDBEngine(cfg, controlPlaneRulesFrom(meta), run, startSess, probe), nil
|
||||
}
|
||||
|
||||
func newMariaDBEngine(cfg config, rules controlPlaneRules, run commandRunner, startSess sessionRunner, probe *engineProbe) *mariadbEngine {
|
||||
return &mariadbEngine{
|
||||
rules: rules,
|
||||
run: run,
|
||||
startSess: startSess,
|
||||
client: filepath.Join(cfg.EngineBinDir, mariadbClientBinary),
|
||||
socket: mariadbSocketPath(cfg),
|
||||
rcService: cfg.RCService,
|
||||
service: cfg.EngineService,
|
||||
parameterManager: parameterManager{
|
||||
probe: probe,
|
||||
params: parameterStore{
|
||||
// The mount point rather than the datadir one level inside it: the
|
||||
// include directory has to outlive the sweep a failed bootstrap runs.
|
||||
dir: filepath.Join(cfg.DataMount, "conf.d"),
|
||||
installed: mariadbParametersFile,
|
||||
lastGood: mariadbLastGoodFile,
|
||||
serving: mariadbServingFile,
|
||||
header: mariadbParametersHeader,
|
||||
osUser: cfg.EngineUser,
|
||||
engine: engineMariaDB,
|
||||
},
|
||||
repairTimeout: parameterRepairTimeout,
|
||||
repairPoll: parameterRepairPoll,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mariadbSocketPath(cfg config) string {
|
||||
return filepath.Join(cfg.SocketDir, mariadbSocketFile)
|
||||
}
|
||||
|
||||
// Every local connection, from the probe's ping to a held quiesce session.
|
||||
// --no-defaults so no option file can move it; --skip-ssl because the serving
|
||||
// cert names the endpoint rather than this socket, so a verifying client fails.
|
||||
func mariadbSocketConnectArgs(socket string) []string {
|
||||
return []string{
|
||||
"--no-defaults", "--protocol=socket", "--socket=" + socket,
|
||||
"--user=" + mariadbSuperuser, "--skip-ssl",
|
||||
}
|
||||
}
|
||||
|
||||
// Reports whether a pid is a live process. EPERM is a process this agent may not
|
||||
// signal rather than one that is gone, which matters because a stale pidfile
|
||||
// read as "alive" is far less dangerous than a live engine read as "absent".
|
||||
type processLivenessFn func(pid int) bool
|
||||
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
func newMariaDBProbe(cfg config, run probeRunner) *engineProbe {
|
||||
return newEngineProbe(cfg.EnginePort, mariadbProbeState(cfg, run, processAlive))
|
||||
}
|
||||
|
||||
// Three stages: during InnoDB crash recovery mariadbd opens neither socket nor
|
||||
// port, so a ping cannot tell a recovering engine from an absent one — and
|
||||
// reading recovery as absent has the rollback guard restart a server mid-replay.
|
||||
func mariadbProbeState(cfg config, run probeRunner, alive processLivenessFn) probeStateFn {
|
||||
pidFile, socket := cfg.EnginePidFile, mariadbSocketPath(cfg)
|
||||
admin, client := filepath.Join(cfg.EngineBinDir, mariadbAdminBinary), filepath.Join(cfg.EngineBinDir, mariadbClientBinary)
|
||||
// The probe alone bounds the connect: it must come back and report, where a
|
||||
// statement the agent issues is allowed to take as long as the engine needs.
|
||||
connect := append(mariadbSocketConnectArgs(socket),
|
||||
fmt.Sprintf("--connect-timeout=%d", mariadbProbeConnectTimeoutSeconds))
|
||||
probeTimeout := cfg.EngineProbeTimeout
|
||||
if probeTimeout <= 0 {
|
||||
probeTimeout = defaultEngineProbeTimeout
|
||||
}
|
||||
runBounded := func(ctx context.Context, name string, args ...string) (int, string, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
return run(probeCtx, name, args...)
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _ int64) (engineState, string) {
|
||||
pid, err := readPidFile(pidFile)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
return engineAbsent, fmt.Sprintf("the engine has written no pidfile at %s", pidFile)
|
||||
case err != nil:
|
||||
return engineAbsent, fmt.Sprintf("the engine pidfile %s could not be read: %v", pidFile, err)
|
||||
case !alive(pid):
|
||||
return engineAbsent, fmt.Sprintf("the engine pidfile %s names pid %d, which is not running", pidFile, pid)
|
||||
}
|
||||
|
||||
// The process is up from here on, so every remaining failure is an engine
|
||||
// not serving yet rather than one that is gone. Reporting absent against a
|
||||
// live server would have the rollback guard restart one making progress.
|
||||
switch code, stderr, err := runBounded(ctx, admin, append(slices.Clone(connect), "ping")...); {
|
||||
case err != nil:
|
||||
return engineRecovering, fmt.Sprintf("engine probe could not run: %v", err)
|
||||
case code != 0:
|
||||
// A server that is genuinely recovering and one that refused the client
|
||||
// are indistinguishable from the socket. The server states its side in
|
||||
// its own log, and the client states its side on stderr.
|
||||
return engineRecovering, withProbeStderr(withEngineLogTail(
|
||||
"engine is not answering on its socket yet (startup or crash recovery)",
|
||||
cfg.EngineErrorLog), stderr)
|
||||
}
|
||||
|
||||
// ping answers even on ER_ACCESS_DENIED, so on its own it certifies a server
|
||||
// that may be unable to execute anything. The statement rides argv rather
|
||||
// than stdin: it carries nothing secret and a probe reads no result back.
|
||||
query := append(slices.Clone(connect), "--batch", "--skip-column-names", "--execute=SELECT 1")
|
||||
switch code, stderr, err := runBounded(ctx, client, query...); {
|
||||
case err != nil:
|
||||
return engineRecovering, fmt.Sprintf("engine probe could not run: %v", err)
|
||||
case code != 0:
|
||||
return engineRecovering, withProbeStderr(
|
||||
"engine answered its socket but would not execute a statement", stderr)
|
||||
}
|
||||
return engineServing, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Appends what the probe client said on stderr. A client that fails during the
|
||||
// handshake is refused by the server without the server ever logging why, so
|
||||
// this is the only account of that half of the exchange.
|
||||
func withProbeStderr(reason, stderr string) string {
|
||||
stderr = strings.TrimSpace(stderr)
|
||||
if stderr == "" {
|
||||
return reason
|
||||
}
|
||||
return reason + "; the probe client reported: " + collapseProbeStderr(stderr)
|
||||
}
|
||||
|
||||
// One line, bounded: the reason reaches a customer through StatusInfos, and a
|
||||
// client that repeats itself per attempt would otherwise crowd out the rest.
|
||||
func collapseProbeStderr(stderr string) string {
|
||||
line := strings.Join(strings.Fields(strings.ReplaceAll(stderr, "\n", " ")), " ")
|
||||
if len(line) > mariadbProbeStderrMaxBytes {
|
||||
return line[:mariadbProbeStderrMaxBytes] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func readPidFile(path string) (int, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s does not hold a pid: %w", path, err)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) Stop(ctx context.Context) error {
|
||||
return serviceAction(ctx, e.run, e.rcService, e.service, "stop")
|
||||
}
|
||||
|
||||
// Only the parameter rollback and a failed apply call this: a restart the
|
||||
// control plane wants goes through RebootDBInstance, which cycles the VM. The
|
||||
// serving copy is refreshed first, since the server starts on what is installed.
|
||||
func (e *mariadbEngine) Restart(ctx context.Context) error {
|
||||
if err := e.recordServingCopy(); err != nil {
|
||||
return err
|
||||
}
|
||||
return serviceAction(ctx, e.run, e.rcService, e.service, "restart")
|
||||
}
|
||||
|
||||
// The installed set copied to the name the pending-restart comparison reads. A
|
||||
// live SET GLOBAL apply deliberately does not touch it: that value was adopted
|
||||
// without a restart, which is what "not pending" means.
|
||||
func (e *mariadbEngine) recordServingCopy() error {
|
||||
content, err := os.ReadFile(e.params.installedPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read the installed parameters: %w", err)
|
||||
}
|
||||
return e.params.write(e.params.servingPath(), content)
|
||||
}
|
||||
|
||||
// A value safe to interpolate into a single-quoted SQL literal. The quote is
|
||||
// doubled, which holds in every sql_mode, and the backslash is doubled, which is
|
||||
// why every statement built here pins a mode with backslash escapes on.
|
||||
func sqlLiteral(value string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `''`)
|
||||
}
|
||||
|
||||
// The spellings MariaDB parses for a boolean system variable, and the only ones
|
||||
// the catalog's own enum narrows a boolean to.
|
||||
var mariadbBooleanValues = map[string]string{
|
||||
"on": "ON", "off": "OFF", "true": "ON", "false": "OFF", "1": "ON", "0": "OFF",
|
||||
}
|
||||
|
||||
// One SET GLOBAL right-hand side. MariaDB refuses a quoted literal for a numeric
|
||||
// or boolean with ER_WRONG_TYPE_FOR_VAR, so the catalog's data type picks the
|
||||
// form; re-rendering from the parsed value is what keeps it safe to interpolate.
|
||||
func mariadbSetValue(dataType, value string) (string, error) {
|
||||
switch dataType {
|
||||
case handlers_rds.ParamTypeInteger:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("value %q is not an integer", value)
|
||||
}
|
||||
return strconv.FormatInt(n, 10), nil
|
||||
case handlers_rds.ParamTypeReal:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("value %q is not a real number", value)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'g', -1, 64), nil
|
||||
case handlers_rds.ParamTypeBoolean:
|
||||
if literal, ok := mariadbBooleanValues[strings.ToLower(strings.TrimSpace(value))]; ok {
|
||||
return literal, nil
|
||||
}
|
||||
return "", fmt.Errorf("value %q is not a boolean", value)
|
||||
default:
|
||||
return "'" + sqlLiteral(value) + "'", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Turns off the two logs that would otherwise copy a statement's text, and pins
|
||||
// the mode the escaping above assumes. All three are session-scoped: a rotation
|
||||
// must not leave the customer's own general log switched off behind it.
|
||||
const mariadbSessionGuard = `SET SESSION sql_log_off = 1;
|
||||
SET SESSION slow_query_log = 0;
|
||||
SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';
|
||||
`
|
||||
|
||||
// Rotates the master's password. The statement is built here rather than by the
|
||||
// client, which has neither psql's identifier interpolation nor its parameter
|
||||
// quoting, and it rides stdin so it is never visible in the process table.
|
||||
func (e *mariadbEngine) SetPassword(ctx context.Context, username, password string) error {
|
||||
if username == "" || password == "" {
|
||||
return fmt.Errorf("set-password requires both %s and %s",
|
||||
handlers_rds.CommandParamMasterUsername, handlers_rds.CommandParamMasterUserPassword)
|
||||
}
|
||||
// This runs as the server's own superuser, so a reserved name in the payload
|
||||
// would rotate the platform's account. The whole rule matters here, not just
|
||||
// the reserved set: the name is interpolated below rather than client-quoted.
|
||||
if err := e.rules.validateUsername(username); err != nil {
|
||||
return fmt.Errorf("refusing to set the password of role %q: %w", username, err)
|
||||
}
|
||||
|
||||
escaped := sqlLiteral(password)
|
||||
sql := fmt.Sprintf("%sALTER USER '%s'@'%s' IDENTIFIED BY '%s';\n",
|
||||
mariadbSessionGuard, username, mariadbMasterHost, escaped)
|
||||
if _, err := e.clientRun(ctx, sql); err != nil {
|
||||
// The client echoes the failing statement, which carries the escaped form
|
||||
// of the password rather than the raw one — so redacting only the raw one
|
||||
// would leak exactly the passwords that needed escaping.
|
||||
return fmt.Errorf("apply the master password: %s", redact(redact(err.Error(), password), escaped))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Installs the resolved set and applies the half a running server can adopt.
|
||||
// MariaDB re-reads no file while up, so SET GLOBAL is the only immediate path.
|
||||
// It has no offline parser, so the safety net is the boot-time rollback.
|
||||
func (e *mariadbEngine) ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error) {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
|
||||
initialState, _ := e.probe.state(ctx)
|
||||
if initialState == engineRecovering {
|
||||
return nil, errors.New("apply parameters while the engine is still starting or recovering")
|
||||
}
|
||||
if initialState == engineServing {
|
||||
// A command can arrive before the first heartbeat. Seed the configuration
|
||||
// currently being served before replacing its file in that window.
|
||||
if err := e.recordServingParametersLocked(ctx); err != nil {
|
||||
return nil, fmt.Errorf("record the parameters serving before the apply: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
restore, err := e.params.install(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, _ := e.probe.state(ctx)
|
||||
switch state {
|
||||
case engineAbsent:
|
||||
return e.restartOnRepairSetLocked(ctx)
|
||||
case engineRecovering:
|
||||
return nil, errors.Join(errors.New("the engine entered startup or recovery during the parameter apply"), restore())
|
||||
}
|
||||
|
||||
// Before the set reaches the server: enforcement is one of the values below,
|
||||
// and a server told to require a transport it cannot offer would refuse every
|
||||
// client the statement after.
|
||||
if err := e.requireServingTLSForEnforcement(ctx); err != nil {
|
||||
return nil, errors.Join(err, restore())
|
||||
}
|
||||
|
||||
if err := e.applyDynamicParameters(ctx, params); err != nil {
|
||||
// The engine may have gone down after the first probe. In that case start it
|
||||
// on the newly installed set rather than restoring one that already left it
|
||||
// unable to serve.
|
||||
if state, _ := e.probe.state(ctx); state == engineAbsent {
|
||||
return e.restartOnRepairSetLocked(ctx)
|
||||
}
|
||||
return nil, errors.Join(err, restore())
|
||||
}
|
||||
// Last, and on its own: the batch above is not a transaction, so moving the
|
||||
// TLS posture inside it would leave enforcement changed on a set the server
|
||||
// went on to refuse, with only the parameter file rolled back.
|
||||
if err := e.applyTLSEnforcement(ctx); err != nil {
|
||||
if state, _ := e.probe.state(ctx); state == engineAbsent {
|
||||
return e.restartOnRepairSetLocked(ctx)
|
||||
}
|
||||
return nil, errors.Join(err, restore())
|
||||
}
|
||||
return e.pendingRestartParameters(ctx)
|
||||
}
|
||||
|
||||
// The dynamic half, in one invocation that stops at the first refusal so the
|
||||
// server names the setting it would not take. Only catalog names are emitted,
|
||||
// which is what makes the identifier safe to build into the statement.
|
||||
//
|
||||
// TLS enforcement is excluded: applyTLSEnforcement issues it after this batch
|
||||
// has succeeded.
|
||||
func (e *mariadbEngine) applyDynamicParameters(ctx context.Context, params []handlers_rds.Parameter) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';\n")
|
||||
applied := 0
|
||||
for _, p := range params {
|
||||
if p.Name == "" || p.Name == e.rules.tlsEnforcementParameter || e.rules.isStatic(p.Name) {
|
||||
continue
|
||||
}
|
||||
value, err := mariadbSetValue(e.rules.dataType(p.Name), p.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply the dynamic parameters: %s: %w", p.Name, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "SET GLOBAL %s = %s;\n", p.Name, value)
|
||||
applied++
|
||||
}
|
||||
if applied == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := e.clientRun(ctx, b.String()); err != nil {
|
||||
return fmt.Errorf("apply the dynamic parameters: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Puts the installed set's enforcement on the running server. The value comes
|
||||
// from the installed file rather than the set in hand, so a set that predates
|
||||
// the parameter enforces here rather than leaving the server on mariadbd's own
|
||||
// default of off while the API reports enforcement.
|
||||
func (e *mariadbEngine) applyTLSEnforcement(ctx context.Context) error {
|
||||
enforce, err := installedTLSEnforcement(e.rules.tlsEnforcementParameter, e.params.installedPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous, err := e.readTLSEnforcement(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous == enforce {
|
||||
return nil
|
||||
}
|
||||
applyErr := e.setTLSEnforcement(ctx, enforce)
|
||||
if applyErr == nil {
|
||||
return nil
|
||||
}
|
||||
// The client can fail without saying whether the server ran the statement, so
|
||||
// the posture it left behind is read rather than assumed unchanged: the one
|
||||
// outcome that must not survive a failed apply is plaintext nobody asked for.
|
||||
live, readErr := e.readTLSEnforcement(ctx)
|
||||
if readErr != nil {
|
||||
return errors.Join(applyErr, readErr)
|
||||
}
|
||||
if live == previous {
|
||||
return applyErr
|
||||
}
|
||||
return errors.Join(applyErr, e.setTLSEnforcement(ctx, previous))
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) readTLSEnforcement(ctx context.Context) (bool, error) {
|
||||
name := e.rules.tlsEnforcementParameter
|
||||
out, err := e.clientRun(ctx, "SELECT @@global."+name+";\n")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read whether the engine requires TLS of clients: %w", err)
|
||||
}
|
||||
switch value := strings.TrimSpace(out); value {
|
||||
case "1":
|
||||
return true, nil
|
||||
case "0":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("the engine reports %s as %q, which is neither 1 nor 0", name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) setTLSEnforcement(ctx context.Context, enforce bool) error {
|
||||
name, value := e.rules.tlsEnforcementParameter, "0"
|
||||
if enforce {
|
||||
value = "1"
|
||||
}
|
||||
// Rendered the way the batch renders every other boolean, so the statement the
|
||||
// server sees does not depend on which path issued it.
|
||||
literal, err := mariadbSetValue(handlers_rds.ParamTypeBoolean, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render %s: %w", name, err)
|
||||
}
|
||||
sql := fmt.Sprintf("SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';\nSET GLOBAL %s = %s;\n", name, literal)
|
||||
if _, err := e.clientRun(ctx, sql); err != nil {
|
||||
return fmt.Errorf("set %s to %s: %w", name, literal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refuses to require TLS of clients from a server that is not serving it, which
|
||||
// would reject every TCP connection while the parameter path reported success.
|
||||
// Read back from the installed file rather than taken from the set in hand, so
|
||||
// what is checked is also what the server reads at its next start.
|
||||
func (e *mariadbEngine) requireServingTLSForEnforcement(ctx context.Context) error {
|
||||
enforce, err := installedTLSEnforcement(e.rules.tlsEnforcementParameter, e.params.installedPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enforce {
|
||||
return nil
|
||||
}
|
||||
// DISABLED is a server built with TLS and started without a certificate,
|
||||
// which is what a boot that was handed none leaves behind. The agent's own
|
||||
// connections are unaffected either way: MariaDB counts a unix socket as a
|
||||
// secure transport.
|
||||
out, err := e.clientRun(ctx, "SELECT @@global.have_ssl;\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read whether the engine is serving TLS: %w", err)
|
||||
}
|
||||
if serving := strings.TrimSpace(out); serving != "YES" {
|
||||
return fmt.Errorf("the engine is serving without TLS (have_ssl = %q), so requiring it of clients would reject every connection", serving)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) restartOnRepairSetLocked(ctx context.Context) ([]string, error) {
|
||||
return awaitRepairedEngine(ctx, e.probe, e.Restart, e.pendingRestartParameters, e.repairTimeout, e.repairPoll)
|
||||
}
|
||||
|
||||
// MariaDB has no pending_restart, so this is computed from the files: the
|
||||
// catalog-static keys whose installed value differs from the one the server
|
||||
// started on. Live values are not compared — mysqld silently rewrites them.
|
||||
func (e *mariadbEngine) pendingRestartParameters(context.Context) ([]string, error) {
|
||||
installed, err := readOptionFile(e.params.installedPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Nothing is installed, so nothing is waiting to be adopted.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read the installed parameters: %w", err)
|
||||
}
|
||||
serving, err := readOptionFile(e.params.servingPath())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read the parameters the engine started on: %w", err)
|
||||
}
|
||||
// Back to the catalog's names before anything is classified or reported: the
|
||||
// files are written in the engine's startup spellings, and the answer is a
|
||||
// customer-facing list the API refuses ApplyMethod=immediate against.
|
||||
installed = e.catalogKeyed(installed)
|
||||
serving = e.catalogKeyed(serving)
|
||||
|
||||
// An absent serving copy is a set the engine has not started on: rds-init
|
||||
// writes the two together on every boot, so the whole static half counts as
|
||||
// pending rather than being promoted on the strength of a missing file.
|
||||
var pending []string
|
||||
for name, value := range installed {
|
||||
if !e.rules.isStatic(name) {
|
||||
continue
|
||||
}
|
||||
if served, ok := serving[name]; !ok || served != value {
|
||||
pending = append(pending, name)
|
||||
}
|
||||
}
|
||||
// A static setting the group stopped naming reverts to its default at the next
|
||||
// start, which the running server has not adopted either.
|
||||
for name := range serving {
|
||||
if _, ok := installed[name]; !ok && e.rules.isStatic(name) {
|
||||
pending = append(pending, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(pending)
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// Snapshots the installed set as the rollback target, but only when nothing in
|
||||
// it is still waiting for a restart. The parameter mutex keeps an apply from
|
||||
// replacing the file between that check and the copy.
|
||||
func (e *mariadbEngine) RecordServingParameters(ctx context.Context) error {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
return e.recordServingParametersLocked(ctx)
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) recordServingParametersLocked(ctx context.Context) error {
|
||||
return recordLastGood(ctx, e.params, e.pendingRestartParameters)
|
||||
}
|
||||
|
||||
// Puts the last set the engine accepted back in place, for a start that failed
|
||||
// after a parameter change.
|
||||
func (e *mariadbEngine) RestoreLastKnownGoodParameters(ctx context.Context) (bool, error) {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
return restoreLastGood(ctx, e.params, e.probe)
|
||||
}
|
||||
|
||||
// The same settings under the names the catalog and the API know them by, since
|
||||
// a file is written in the engine's startup spellings and those are not always
|
||||
// the customer's.
|
||||
func (e *mariadbEngine) catalogKeyed(values map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(values))
|
||||
for name, value := range values {
|
||||
out[e.rules.catalogName(name)] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The subset of MariaDB's option-file syntax the generated drop-ins are written
|
||||
// in: a group header, comments, and one `name = value` per line. Nothing else
|
||||
// reaches these two files, because only rds-init and this agent write them.
|
||||
func readOptionFile(path string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]string)
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
// A bare option such as skip-log-bin carries no value; it is still a setting
|
||||
// whose presence or absence differs.
|
||||
name, value, _ := strings.Cut(line, "=")
|
||||
values[normaliseOptionName(name)] = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// MariaDB reads - and _ as the same character in an option name, so the two
|
||||
// spellings of one setting must not compare as two settings.
|
||||
func normaliseOptionName(name string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(name), "-", "_")
|
||||
}
|
||||
|
||||
// How long any one BACKUP STAGE waits for its metadata locks. Well under the
|
||||
// control plane's quiesce timeout, so a stage that cannot take its lock is
|
||||
// reported rather than left queued in front of live traffic.
|
||||
const mariadbQuiesceLockWait = 20 * time.Second
|
||||
|
||||
// MariaDB's own backup API rather than FLUSH TABLES WITH READ LOCK: FTWRL makes
|
||||
// the whole database read-only for the full hold, and its acquisition phase is
|
||||
// unbounded. BACKUP STAGE blocks commits instead, and covers Aria and MyISAM.
|
||||
func (e *mariadbEngine) Quiesce(ctx context.Context, label string, hold time.Duration) error {
|
||||
if err := validateQuiesceRequest(label, hold); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Held across the whole start, so a second quiesce waits and then finds the
|
||||
// first one's hold rather than opening a concurrent backup alongside it.
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.held != nil {
|
||||
return fmt.Errorf("the engine is already quiesced for backup %s", e.held.label)
|
||||
}
|
||||
|
||||
// Deliberately not the command's context: the session has to outlive the call
|
||||
// that started it, and its own deadline is what bounds it instead.
|
||||
session, err := e.startSess(context.WithoutCancel(ctx), command{
|
||||
Name: e.client,
|
||||
Args: e.clientArgs(),
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
SentinelStatement: "SELECT '" + sessionSentinel + "';\n",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open a backup session: %w", err)
|
||||
}
|
||||
|
||||
// In order and on one connection: MariaDB releases the hold with the session
|
||||
// that took it, which bounds a control plane that dies mid-snapshot. Fed one
|
||||
// at a time so a stage that cannot take its locks is named.
|
||||
stages := []string{
|
||||
fmt.Sprintf("SET SESSION lock_wait_timeout = %d;\n", int(mariadbQuiesceLockWait.Seconds())),
|
||||
"BACKUP STAGE START;\n",
|
||||
"BACKUP STAGE FLUSH;\n",
|
||||
"BACKUP STAGE BLOCK_DDL;\n",
|
||||
"BACKUP STAGE BLOCK_COMMIT;\n",
|
||||
}
|
||||
for _, sql := range stages {
|
||||
if err := session.Exec(ctx, sql); err != nil {
|
||||
if closeErr := session.Close(); closeErr != nil {
|
||||
slog.Warn("rds-agent: closing a failed backup session", "err", closeErr)
|
||||
}
|
||||
return fmt.Errorf("put the engine into backup mode at %q: %w", strings.TrimSpace(sql), err)
|
||||
}
|
||||
}
|
||||
|
||||
e.beginHoldLocked(label, session, hold)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *mariadbEngine) Unquiesce(ctx context.Context) error {
|
||||
return e.releaseHold(ctx, "BACKUP STAGE END;\n")
|
||||
}
|
||||
|
||||
// Feeds sql on stdin rather than as an argument, so a statement is never visible
|
||||
// in the process table.
|
||||
func (e *mariadbEngine) clientRun(ctx context.Context, sql string) (string, error) {
|
||||
return e.run(ctx, command{
|
||||
Name: e.client,
|
||||
Args: e.clientArgs(),
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
Stdin: sql,
|
||||
})
|
||||
}
|
||||
|
||||
// --unbuffered so a held session's output reaches the reader as each statement
|
||||
// finishes rather than when a pipe buffer fills. No User on the command: the
|
||||
// connection args carry it, and the agent runs as the account they name.
|
||||
func (e *mariadbEngine) clientArgs() []string {
|
||||
return append(mariadbSocketConnectArgs(e.socket),
|
||||
"--batch", "--skip-column-names", "--unbuffered")
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The engine is reached over its unix socket under peer authentication, so the
|
||||
// agent drops to the postgres OS user rather than holding a password of its own.
|
||||
type postgresEngine struct {
|
||||
quiesceState
|
||||
parameterManager
|
||||
|
||||
// The control plane's own metadata for this engine, resolved once at
|
||||
// startup: the live password apply runs as the cluster superuser, so it
|
||||
// re-checks the role name against the same reserved set the API validates.
|
||||
meta handlers_rds.Engine
|
||||
run commandRunner
|
||||
startSess sessionRunner
|
||||
psql string
|
||||
rcService string
|
||||
service string
|
||||
pgData string
|
||||
socketDir string
|
||||
osUser string
|
||||
}
|
||||
|
||||
var _ engine = (*postgresEngine)(nil)
|
||||
|
||||
// pg_isready gives PostgreSQL three states for free: exit 0 is serving, exit 1
|
||||
// is a postmaster up and rejecting connections, anything else is an engine that
|
||||
// is not there. Resolved on PATH, where the client package puts it.
|
||||
const postgresProbeBinary = "pg_isready"
|
||||
|
||||
// The socket directory rather than loopback: the engine binds the customer ENI
|
||||
// and nothing else, so there is no TCP path for the probe to take. It also keeps
|
||||
// liveness independent of the network path, of the pg_hba scope and of the TLS
|
||||
// enforcement rule — libpq reads a host beginning with / as a socket directory,
|
||||
// and the generated `local ... peer` line covers it.
|
||||
func newPostgresProbe(cfg config, run probeRunner) *engineProbe {
|
||||
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, cfg.EngineErrorLog, run))
|
||||
}
|
||||
|
||||
func postgresProbeState(host, errorLog string, run probeRunner) probeStateFn {
|
||||
return func(ctx context.Context, port int64) (engineState, string) {
|
||||
portArg := strconv.FormatInt(port, 10)
|
||||
code, _, err := run(ctx, postgresProbeBinary, "-h", host, "-p", portArg, "-q")
|
||||
switch {
|
||||
case err != nil:
|
||||
// A missing binary or broken image. Reporting healthy on the strength of
|
||||
// nothing would hide it, so this reads as absent like an engine that did
|
||||
// not answer.
|
||||
return engineAbsent, fmt.Sprintf("engine probe could not run: %v", err)
|
||||
case code == 0:
|
||||
return engineServing, ""
|
||||
case code == 1:
|
||||
return engineRecovering, withEngineLogTail(
|
||||
"engine is rejecting connections (startup or recovery)", errorLog)
|
||||
default:
|
||||
// A postmaster that refused a setting never opens the socket, so this is
|
||||
// the state a bad parameter lands in. The reason for it is only in the
|
||||
// server's own log, and it reaches the customer through StatusInfos.
|
||||
return engineAbsent, withEngineLogTail(
|
||||
fmt.Sprintf("engine did not respond on %s:%s", host, portArg), errorLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The include the resolved set is rendered to, and the copy of the last one the
|
||||
// engine accepted. Both live beside the data rather than in /etc, matching
|
||||
// rds-init: a class change boots a fresh root volume and would revert them.
|
||||
const (
|
||||
postgresParametersFile = "10-rds-parameters.conf"
|
||||
// Deliberately not a .conf name: include_dir globs *.conf, so the rollback
|
||||
// copy must not be read as a second set of settings.
|
||||
postgresLastGoodFile = "10-rds-parameters.last-good"
|
||||
)
|
||||
|
||||
// The pg_hba rule that implements TLS enforcement, and the directory the
|
||||
// generated pg_hba includes it from. Its content is a constant, so the copy
|
||||
// rds-init writes in shell and the one written here have no template to drift
|
||||
// apart on: enforcement is the presence of the file and nothing more.
|
||||
const (
|
||||
postgresHBADir = "hba.d"
|
||||
postgresForceSSLRuleFile = "20-rds-force-ssl.conf"
|
||||
// An explicit reject rather than an omitted permit: both refuse the
|
||||
// connection, and only this one puts the reason in the server log and in the
|
||||
// client's error.
|
||||
postgresForceSSLRules = `# Managed by the platform: the rule that requires TLS of client connections.
|
||||
# Written by rds-init at boot and by rds-agent on a parameter apply.
|
||||
hostnossl all all 0.0.0.0/0 reject
|
||||
hostnossl all all ::/0 reject
|
||||
`
|
||||
// What the read-back must count once the file is in place. Asserting the shape
|
||||
// rather than only the absence of parse errors is what catches the failure
|
||||
// that will actually happen: the derivation above writing the wrong thing.
|
||||
postgresForceSSLRuleCount = 2
|
||||
)
|
||||
|
||||
// The layout's factory resolves the control plane metadata during startup.
|
||||
func newPostgresEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
|
||||
meta, err := handlers_rds.LookupEngine(enginePostgres)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", enginePostgres, err)
|
||||
}
|
||||
return newPostgresEngine(cfg, meta, run, startSess, probe), nil
|
||||
}
|
||||
|
||||
func newPostgresEngine(cfg config, meta handlers_rds.Engine, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
|
||||
return &postgresEngine{
|
||||
meta: meta,
|
||||
run: run,
|
||||
startSess: startSess,
|
||||
psql: filepath.Join(cfg.EngineBinDir, "psql"),
|
||||
rcService: cfg.RCService,
|
||||
service: cfg.EngineService,
|
||||
pgData: cfg.EngineDataDir,
|
||||
socketDir: cfg.SocketDir,
|
||||
osUser: cfg.EngineUser,
|
||||
parameterManager: parameterManager{
|
||||
probe: probe,
|
||||
params: parameterStore{
|
||||
dir: filepath.Join(cfg.EngineDataDir, "conf.d"),
|
||||
installed: postgresParametersFile,
|
||||
lastGood: postgresLastGoodFile,
|
||||
osUser: cfg.EngineUser,
|
||||
engine: enginePostgres,
|
||||
},
|
||||
repairTimeout: parameterRepairTimeout,
|
||||
repairPoll: parameterRepairPoll,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The role name and the password ride the environment and are re-quoted by
|
||||
// psql, so neither reaches a shell word or an argv another process can read.
|
||||
func (e *postgresEngine) SetPassword(ctx context.Context, username, password string) error {
|
||||
if username == "" || password == "" {
|
||||
return fmt.Errorf("set-password requires both %s and %s",
|
||||
handlers_rds.CommandParamMasterUsername, handlers_rds.CommandParamMasterUserPassword)
|
||||
}
|
||||
// The statement below runs as the cluster superuser over the socket under
|
||||
// peer auth, so a reserved name in the command payload would hand the
|
||||
// customer the bootstrap superuser rather than rotate their own role.
|
||||
if err := e.meta.ValidateUsernameNotReserved(username); err != nil {
|
||||
return fmt.Errorf("refusing to set the password of a role the engine reserves: %w", err)
|
||||
}
|
||||
// psql interpolates the password into the ALTER ROLE before the server sees
|
||||
// it, and these three are what would write it to the log — the last on any
|
||||
// failure at its own default. SUSET, so the parameter group cannot win.
|
||||
const sql = `SET log_statement = 'none';
|
||||
SET log_min_duration_statement = -1;
|
||||
SET log_min_error_statement = 'panic';
|
||||
\getenv master RDS_MASTER_USERNAME
|
||||
\getenv password RDS_MASTER_PASSWORD
|
||||
ALTER ROLE :"master" WITH LOGIN PASSWORD :'password';
|
||||
`
|
||||
_, err := e.psqlRun(ctx, sql,
|
||||
"RDS_MASTER_USERNAME="+username,
|
||||
"RDS_MASTER_PASSWORD="+password,
|
||||
)
|
||||
if err != nil {
|
||||
// psql echoes the failing statement, which here would carry the new
|
||||
// password back to the control plane and into the event ring.
|
||||
return fmt.Errorf("apply the master password: %s", redact(err.Error(), password))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Installs the resolved set, validates it with the engine's own parser before
|
||||
// the engine adopts it, and reloads. A refused value is rolled back here rather
|
||||
// than left on the data volume, where it would survive every VM replace.
|
||||
func (e *postgresEngine) ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error) {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
|
||||
initialState, _ := e.probe.state(ctx)
|
||||
if initialState == engineRecovering {
|
||||
return nil, errors.New("apply parameters while the engine is still starting or recovering")
|
||||
}
|
||||
if initialState == engineServing {
|
||||
// A command can arrive before the first heartbeat. Seed the configuration
|
||||
// currently being served before replacing its file in that window.
|
||||
if err := e.recordServingParametersLocked(ctx); err != nil {
|
||||
return nil, fmt.Errorf("record the parameters serving before the apply: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The check runs against the file in place, because the engine parses the
|
||||
// datadir's own include_dir. The window that leaves is closed by the rollback
|
||||
// below and the last-known-good restore the agent runs at boot.
|
||||
restoreParameters, err := e.params.install(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.checkConfig(ctx); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("the engine rejected the parameter set: %w", err), restoreParameters())
|
||||
}
|
||||
|
||||
state, _ := e.probe.state(ctx)
|
||||
switch state {
|
||||
case engineAbsent:
|
||||
return e.restartOnRepairSetLocked(ctx)
|
||||
case engineRecovering:
|
||||
return nil, errors.Join(errors.New("the engine entered startup or recovery during the parameter apply"), restoreParameters())
|
||||
}
|
||||
|
||||
// After the engine's own parser has passed the set and before the reload that
|
||||
// adopts both files at once.
|
||||
enforce, restoreEnforcement, err := e.syncTLSEnforcement(ctx, state)
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("apply the TLS enforcement of the parameter set: %w", err), restoreParameters())
|
||||
}
|
||||
// The two files carry one apply from here: a rejected set withdrawn without
|
||||
// its enforcement rule would leave the instance serving the old parameters
|
||||
// under the new set's TLS posture.
|
||||
restore := func() error { return errors.Join(restoreEnforcement(), restoreParameters()) }
|
||||
|
||||
if err := e.reload(ctx); err != nil {
|
||||
// The engine may have gone down after the first probe. In that case start
|
||||
// it on the new, parser-checked repair set rather than restoring the set
|
||||
// that already left it unable to serve.
|
||||
if state, _ := e.probe.state(ctx); state == engineAbsent {
|
||||
return e.restartOnRepairSetLocked(ctx)
|
||||
}
|
||||
return nil, errors.Join(err, restore())
|
||||
}
|
||||
if err := e.verifyTLSEnforcement(ctx, enforce); err != nil {
|
||||
// The reload already happened, so putting the files back is not enough: a
|
||||
// pg_hba that parsed but says the wrong thing has been adopted, and only a
|
||||
// second reload takes the engine off it.
|
||||
return nil, errors.Join(err, restore(), e.reload(ctx))
|
||||
}
|
||||
return e.pendingRestartParameters(ctx)
|
||||
}
|
||||
|
||||
// Re-derived before the restart: the engine reads pg_hba when it starts, so a
|
||||
// repair set started under the previous set's enforcement would serve one TLS
|
||||
// posture while the API reported the other.
|
||||
func (e *postgresEngine) restartOnRepairSetLocked(ctx context.Context) ([]string, error) {
|
||||
if _, _, err := e.syncTLSEnforcement(ctx, engineAbsent); err != nil {
|
||||
return nil, fmt.Errorf("apply the TLS enforcement of the repair set: %w", err)
|
||||
}
|
||||
return awaitRepairedEngine(ctx, e.probe, e.Restart, e.pendingRestartParameters, e.repairTimeout, e.repairPoll)
|
||||
}
|
||||
|
||||
// The function's own answer is the reload, not psql's exit status: it returns f
|
||||
// when it could not signal the postmaster, and the read-back that follows parses
|
||||
// pg_hba from disk, so an unsignalled reload would otherwise verify clean.
|
||||
func (e *postgresEngine) reload(ctx context.Context) error {
|
||||
out, err := e.psqlRun(ctx, "SELECT pg_reload_conf();\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload the engine configuration: %w", err)
|
||||
}
|
||||
if result := strings.TrimSpace(out); result != "t" {
|
||||
return fmt.Errorf("reload the engine configuration: pg_reload_conf() returned %q", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The postmaster's own answer: a static setting it has stored but not adopted is
|
||||
// what it reports here, so nothing in the guest has to classify one.
|
||||
func (e *postgresEngine) pendingRestartParameters(ctx context.Context) ([]string, error) {
|
||||
out, err := e.psqlRun(ctx, "SELECT name FROM pg_settings WHERE pending_restart ORDER BY name;\n")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the settings pending a restart: %w", err)
|
||||
}
|
||||
var pending []string
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
if name := strings.TrimSpace(line); name != "" {
|
||||
pending = append(pending, name)
|
||||
}
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// Snapshots the include only when the running postmaster has no settings still
|
||||
// pending a restart. The parameter mutex keeps an apply from replacing the file
|
||||
// between that check and the copy.
|
||||
func (e *postgresEngine) RecordServingParameters(ctx context.Context) error {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
return e.recordServingParametersLocked(ctx)
|
||||
}
|
||||
|
||||
func (e *postgresEngine) recordServingParametersLocked(ctx context.Context) error {
|
||||
return recordLastGood(ctx, e.params, e.pendingRestartParameters)
|
||||
}
|
||||
|
||||
// Puts the last set the engine accepted back in place, for a restart that failed
|
||||
// after a parameter change.
|
||||
func (e *postgresEngine) RestoreLastKnownGoodParameters(ctx context.Context) (bool, error) {
|
||||
e.paramMu.Lock()
|
||||
defer e.paramMu.Unlock()
|
||||
restored, err := restoreLastGood(ctx, e.params, e.probe)
|
||||
if err != nil || !restored {
|
||||
return restored, err
|
||||
}
|
||||
// A service restart does not re-run rds-init, so a restore that stopped at the
|
||||
// parameter file would bring the last known good set up under the enforcement
|
||||
// of the set that was just rejected. restoreLastGood only replaces the file of
|
||||
// an engine that is down.
|
||||
if _, _, err := e.syncTLSEnforcement(ctx, engineAbsent); err != nil {
|
||||
return true, fmt.Errorf("apply the TLS enforcement of the restored parameter set: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Turns the installed enforcement value into the pg_hba fact that implements it.
|
||||
// PostgreSQL has no server setting for this, so the parameter file on its own
|
||||
// would report the change applied and enforce nothing.
|
||||
//
|
||||
// The serving-TLS guard runs only against an engine that is up. The restore and
|
||||
// repair paths reach this with the engine down, where the rule is read by the
|
||||
// start that follows rather than by a reload.
|
||||
func (e *postgresEngine) syncTLSEnforcement(ctx context.Context, state engineState) (enforce bool, restore func() error, err error) {
|
||||
enforce, err = installedTLSEnforcement(e.meta.TLSEnforcementParameter(), e.params.installedPath())
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if enforce && state == engineServing {
|
||||
if err := e.requireServingTLS(ctx); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
restore, err = e.writeTLSEnforcement(enforce)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return enforce, restore, nil
|
||||
}
|
||||
|
||||
// Writes or removes the enforcement rule, returning a func putting back whatever
|
||||
// was there. Removing matters as much as writing: a restored data volume carries
|
||||
// hba.d with it, so a stale rule would keep enforcing under a parameter group
|
||||
// that turns enforcement off.
|
||||
func (e *postgresEngine) writeTLSEnforcement(enforce bool) (func() error, error) {
|
||||
path := e.forceSSLRulePath()
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return nil, fmt.Errorf("read the TLS enforcement rule at %s: %w", path, readErr)
|
||||
}
|
||||
existed := readErr == nil
|
||||
restore := func() error {
|
||||
if !existed {
|
||||
return e.removeTLSEnforcement()
|
||||
}
|
||||
return e.params.write(path, previous)
|
||||
}
|
||||
|
||||
if !enforce {
|
||||
if err := e.removeTLSEnforcement(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return restore, nil
|
||||
}
|
||||
// rds-init lays the directory down on every boot, so this covers an agent that
|
||||
// reached an apply before one ran rather than the ordinary case.
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
if err := e.params.chownToEngine(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.params.write(path, []byte(postgresForceSSLRules)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return restore, nil
|
||||
}
|
||||
|
||||
func (e *postgresEngine) removeTLSEnforcement() error {
|
||||
path := e.forceSSLRulePath()
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("withdraw the TLS enforcement rule at %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *postgresEngine) forceSSLRulePath() string {
|
||||
return filepath.Join(e.pgData, postgresHBADir, postgresForceSSLRuleFile)
|
||||
}
|
||||
|
||||
// Refuses to require TLS of clients from an engine that is not serving it, which
|
||||
// would reject every connection while the parameter path reported success.
|
||||
func (e *postgresEngine) requireServingTLS(ctx context.Context) error {
|
||||
out, err := e.psqlRun(ctx, "SHOW ssl;\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read whether the engine is serving TLS: %w", err)
|
||||
}
|
||||
if serving := strings.TrimSpace(out); serving != "on" {
|
||||
return fmt.Errorf("the engine is serving without TLS (ssl = %q), so requiring it of clients would reject every connection", serving)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostgreSQL discards a pg_hba it cannot parse on reload and keeps the rules it
|
||||
// is already serving, which presents as an apply that succeeded and enforces
|
||||
// nothing. So the file is read back.
|
||||
//
|
||||
// pg_hba_file_rules re-parses from disk, so this proves the file would be
|
||||
// adopted rather than that the postmaster has adopted it. The inference holds
|
||||
// because a reloaded pg_hba is discarded only on a parse error.
|
||||
const postgresHBAReadBack = `SELECT count(*) FILTER (WHERE type = 'hostnossl' AND auth_method = 'reject'),
|
||||
count(*) FILTER (WHERE error IS NOT NULL) FROM pg_hba_file_rules;
|
||||
`
|
||||
|
||||
func (e *postgresEngine) verifyTLSEnforcement(ctx context.Context, enforce bool) error {
|
||||
out, err := e.psqlRun(ctx, postgresHBAReadBack)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read back the client authentication rules: %w", err)
|
||||
}
|
||||
rules, unparsable, err := parseHBAReadBack(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if unparsable > 0 {
|
||||
return fmt.Errorf("the client authentication file carries %d rules the engine could not parse, so it would keep the rules it is already serving", unparsable)
|
||||
}
|
||||
want := 0
|
||||
if enforce {
|
||||
want = postgresForceSSLRuleCount
|
||||
}
|
||||
if rules != want {
|
||||
return fmt.Errorf("the client authentication file carries %d TLS enforcement rules, want %d", rules, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHBAReadBack(out string) (rules, unparsable int, err error) {
|
||||
answer := strings.TrimSpace(out)
|
||||
malformed := fmt.Errorf("the client authentication read-back returned %q, want two counts", answer)
|
||||
counts := strings.Split(answer, "|")
|
||||
if len(counts) != 2 {
|
||||
return 0, 0, malformed
|
||||
}
|
||||
parsed := make([]int, len(counts))
|
||||
for i, count := range counts {
|
||||
if parsed[i], err = strconv.Atoi(strings.TrimSpace(count)); err != nil {
|
||||
return 0, 0, malformed
|
||||
}
|
||||
}
|
||||
return parsed[0], parsed[1], nil
|
||||
}
|
||||
|
||||
// The engine's own parser, run offline against the datadir. Reading one setting
|
||||
// back is enough: postgres parses every include first and exits non-zero naming
|
||||
// the file and line of an unknown parameter or an out-of-range value.
|
||||
func (e *postgresEngine) checkConfig(ctx context.Context) error {
|
||||
if _, err := e.run(ctx, command{
|
||||
Name: filepath.Join(filepath.Dir(e.psql), "postgres"),
|
||||
Args: []string{"-D", e.pgData, "-C", "shared_buffers"},
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
User: e.osUser,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *postgresEngine) Stop(ctx context.Context) error {
|
||||
return serviceAction(ctx, e.run, e.rcService, e.service, "stop")
|
||||
}
|
||||
|
||||
// Only the parameter rollback calls this: a restart the control plane wants goes
|
||||
// through RebootDBInstance, which cycles the VM.
|
||||
func (e *postgresEngine) Restart(ctx context.Context) error {
|
||||
return serviceAction(ctx, e.run, e.rcService, e.service, "restart")
|
||||
}
|
||||
|
||||
// Feeds sql on stdin rather than as an argument, so a statement is never
|
||||
// visible in the process table.
|
||||
func (e *postgresEngine) psqlRun(ctx context.Context, sql string, env ...string) (string, error) {
|
||||
return e.run(ctx, command{
|
||||
Name: e.psql,
|
||||
Args: e.psqlArgs(),
|
||||
Env: append([]string{"PATH=" + defaultGuestPath}, env...),
|
||||
Stdin: sql,
|
||||
User: e.osUser,
|
||||
})
|
||||
}
|
||||
|
||||
// Reading the script from stdin is what lets one invocation serve both a
|
||||
// one-shot run and a session held open across several statements.
|
||||
func (e *postgresEngine) psqlArgs() []string {
|
||||
return []string{
|
||||
"--no-psqlrc", "--quiet", "--no-align", "--tuples-only",
|
||||
"-v", "ON_ERROR_STOP=1",
|
||||
"-h", e.socketDir,
|
||||
"-p", strconv.FormatInt(e.probe.port.Load(), 10),
|
||||
"-U", e.osUser,
|
||||
"-d", "postgres",
|
||||
"-f", "-",
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the engine into backup mode: the datadir is checkpointed and the engine
|
||||
// stops writing over the pages a snapshot is about to read. The hold is released
|
||||
// by Unquiesce, or by its own deadline, whichever comes first.
|
||||
func (e *postgresEngine) Quiesce(ctx context.Context, label string, hold time.Duration) error {
|
||||
if err := validateQuiesceRequest(label, hold); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Held across the whole start, so a second quiesce waits and then finds the
|
||||
// first one's hold rather than opening a concurrent backup alongside it.
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.held != nil {
|
||||
return fmt.Errorf("the engine is already quiesced for backup %s", e.held.label)
|
||||
}
|
||||
|
||||
// Deliberately not the command's context: the session has to outlive the call
|
||||
// that started it, and its own deadline is what bounds it instead.
|
||||
session, err := e.startSess(context.WithoutCancel(ctx), command{
|
||||
Name: e.psql,
|
||||
Args: e.psqlArgs(),
|
||||
Env: []string{"PATH=" + defaultGuestPath, "RDS_BACKUP_LABEL=" + label},
|
||||
User: e.osUser,
|
||||
SentinelStatement: `\echo ` + sessionSentinel + "\n",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open a backup session: %w", err)
|
||||
}
|
||||
|
||||
// fast forces an immediate checkpoint rather than spreading it over the
|
||||
// checkpoint interval, which would hold the snapshot open for minutes.
|
||||
const sql = `\getenv label RDS_BACKUP_LABEL
|
||||
SELECT pg_backup_start(:'label', fast => true);
|
||||
`
|
||||
if err := session.Exec(ctx, sql); err != nil {
|
||||
if closeErr := session.Close(); closeErr != nil {
|
||||
slog.Warn("rds-agent: closing a failed backup session", "err", closeErr)
|
||||
}
|
||||
return fmt.Errorf("put the engine into backup mode: %w", err)
|
||||
}
|
||||
|
||||
e.beginHoldLocked(label, session, hold)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *postgresEngine) Unquiesce(ctx context.Context) error {
|
||||
return e.releaseHold(ctx, "SELECT pg_backup_stop();\n")
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// Stands in for the engine in the registry tests, where what matters is that a
|
||||
// command reaches the right method with the right arguments.
|
||||
type fakeEngine struct {
|
||||
username string
|
||||
password string
|
||||
params []handlers_rds.Parameter
|
||||
pending []string
|
||||
stopped bool
|
||||
label string
|
||||
hold time.Duration
|
||||
released bool
|
||||
err error
|
||||
}
|
||||
|
||||
var _ engineOps = (*fakeEngine)(nil)
|
||||
|
||||
func (f *fakeEngine) SetPassword(_ context.Context, username, password string) error {
|
||||
f.username, f.password = username, password
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeEngine) ApplyParameters(_ context.Context, params []handlers_rds.Parameter) ([]string, error) {
|
||||
f.params = params
|
||||
return f.pending, f.err
|
||||
}
|
||||
|
||||
func (f *fakeEngine) Stop(context.Context) error {
|
||||
f.stopped = true
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeEngine) Quiesce(_ context.Context, label string, hold time.Duration) error {
|
||||
f.label, f.hold = label, hold
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeEngine) Unquiesce(context.Context) error {
|
||||
f.released = true
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestCommandRegistry_SetPasswordCarriesBothParameters(t *testing.T) {
|
||||
engine := &fakeEngine{}
|
||||
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
|
||||
CommandID: "cmd-1",
|
||||
Type: handlers_rds.CommandSetPassword,
|
||||
Parameters: []handlers_rds.Parameter{
|
||||
{Name: handlers_rds.CommandParamMasterUsername, Value: "mulgamaster"},
|
||||
{Name: handlers_rds.CommandParamMasterUserPassword, Value: "n3w-pw"},
|
||||
},
|
||||
})
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
||||
t.Fatalf("reply = %+v, want succeeded", reply)
|
||||
}
|
||||
if engine.username != "mulgamaster" || engine.password != "n3w-pw" {
|
||||
t.Errorf("engine got %q/%q, want mulgamaster/n3w-pw", engine.username, engine.password)
|
||||
}
|
||||
// The reply is recorded by the control plane; the new password must not be
|
||||
// in it.
|
||||
if strings.Contains(reply.Message, "n3w-pw") {
|
||||
t.Errorf("reply message %q carries the new password", reply.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// CommandReply has no structured payload, so the settings still awaiting a
|
||||
// restart come back as the message the issuer parses.
|
||||
func TestCommandRegistry_ApplyParamsReportsPendingRestart(t *testing.T) {
|
||||
engine := &fakeEngine{pending: []string{"shared_buffers", "max_connections"}}
|
||||
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
|
||||
CommandID: "cmd-2",
|
||||
Type: handlers_rds.CommandApplyParams,
|
||||
Parameters: []handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}},
|
||||
})
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
||||
t.Fatalf("reply = %+v, want succeeded", reply)
|
||||
}
|
||||
if reply.Message != "shared_buffers,max_connections" {
|
||||
t.Errorf("reply message = %q, want the pending-restart list", reply.Message)
|
||||
}
|
||||
if len(engine.params) != 1 || engine.params[0].Name != "shared_buffers" {
|
||||
t.Errorf("engine got %+v, want the command's parameters", engine.params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRegistry_StopEngineReachesTheEngine(t *testing.T) {
|
||||
engine := &fakeEngine{}
|
||||
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
|
||||
handlers_rds.Command{CommandID: "cmd-3", Type: handlers_rds.CommandStopEngine})
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded {
|
||||
t.Fatalf("reply = %+v, want succeeded", reply)
|
||||
}
|
||||
if !engine.stopped {
|
||||
t.Error("stop-engine did not reach the engine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRegistry_FailureRepliesFailed(t *testing.T) {
|
||||
engine := &fakeEngine{err: errors.New("the engine did not stop")}
|
||||
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
|
||||
handlers_rds.Command{CommandID: "cmd-4", Type: handlers_rds.CommandStopEngine})
|
||||
|
||||
if reply.Status != handlers_rds.CommandStatusFailed {
|
||||
t.Fatalf("reply = %+v, want failed", reply)
|
||||
}
|
||||
if !strings.Contains(reply.Message, "the engine did not stop") {
|
||||
t.Errorf("reply message = %q, want the engine's error", reply.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Records what a postgresEngine would have run, so the SQL and the argv are
|
||||
// asserted without a live cluster.
|
||||
type recordingRunner struct {
|
||||
calls []command
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingRunner) run(_ context.Context, c command) (string, error) {
|
||||
r.calls = append(r.calls, c)
|
||||
return r.out, r.err
|
||||
}
|
||||
|
||||
func testPostgresEngineMeta(t *testing.T) handlers_rds.Engine {
|
||||
t.Helper()
|
||||
meta, err := handlers_rds.LookupEngine(enginePostgres)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEngine(%s): %v", enginePostgres, err)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func newTestEngine(t *testing.T, run commandRunner) *postgresEngine {
|
||||
t.Helper()
|
||||
cfg := testEngineConfig(t)
|
||||
return newPostgresEngine(cfg, testPostgresEngineMeta(t), withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0)))
|
||||
}
|
||||
|
||||
// The same engine with the read-backs left to the runner, for the cases that are
|
||||
// about what the engine does with each answer rather than about the rest of an
|
||||
// apply.
|
||||
func newScriptedTestEngine(t *testing.T, run commandRunner) *postgresEngine {
|
||||
t.Helper()
|
||||
cfg := testEngineConfig(t)
|
||||
return newPostgresEngine(cfg, testPostgresEngineMeta(t), run, nil, newPostgresProbe(cfg, staticProbe(0)))
|
||||
}
|
||||
|
||||
func testEngineConfig(t *testing.T) config {
|
||||
t.Helper()
|
||||
cfg := testLoadConfig(t, enginePostgres)
|
||||
cfg.EngineDataDir = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(cfg.EngineDataDir, "conf.d"), 0o700); err != nil {
|
||||
t.Fatalf("create conf.d: %v", err)
|
||||
}
|
||||
// The guest's postgres user does not exist here, and the chown of the
|
||||
// installed parameter file has to resolve to something real.
|
||||
current, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve the current user: %v", err)
|
||||
}
|
||||
cfg.EngineUser = current.Username
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Answers the read-backs every apply runs against a live postmaster, from the
|
||||
// datadir the case is working in: an engine serving TLS, a reload the postmaster
|
||||
// took, and a pg_hba carrying whatever the agent last wrote. The call still
|
||||
// reaches the runner, so a case counting commands sees them; one that is about
|
||||
// the read-backs themselves builds its own engine.
|
||||
func withPostgresReadBacks(dataDir string, run commandRunner) commandRunner {
|
||||
rule := filepath.Join(dataDir, postgresHBADir, postgresForceSSLRuleFile)
|
||||
return func(ctx context.Context, c command) (string, error) {
|
||||
out, err := run(ctx, c)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(c.Stdin, "SHOW ssl"):
|
||||
return "on\n", nil
|
||||
case strings.Contains(c.Stdin, "pg_reload_conf"):
|
||||
return "t\n", nil
|
||||
case strings.Contains(c.Stdin, "pg_hba_file_rules"):
|
||||
rules := 0
|
||||
if _, statErr := os.Stat(rule); statErr == nil {
|
||||
rules = postgresForceSSLRuleCount
|
||||
}
|
||||
return fmt.Sprintf("%d|0\n", rules), nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// The password reaches psql through the environment and is re-quoted there,
|
||||
// so it is never a shell word or an argv another process can read.
|
||||
func TestPostgresEngine_SetPasswordKeepsTheSecretOutOfArgv(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
|
||||
t.Fatalf("SetPassword: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("ran %d commands, want 1", len(runner.calls))
|
||||
}
|
||||
call := runner.calls[0]
|
||||
for _, arg := range call.Args {
|
||||
if strings.Contains(arg, "n3w-pw") {
|
||||
t.Fatalf("argv %v carries the password", call.Args)
|
||||
}
|
||||
}
|
||||
if strings.Contains(call.Stdin, "n3w-pw") {
|
||||
t.Errorf("SQL %q carries the password literally, want it read from the environment", call.Stdin)
|
||||
}
|
||||
if !slices.Contains(call.Env, "RDS_MASTER_PASSWORD=n3w-pw") {
|
||||
t.Errorf("env %v does not carry the password", call.Env)
|
||||
}
|
||||
if !strings.Contains(call.Stdin, "ALTER ROLE") {
|
||||
t.Errorf("SQL %q does not alter the role", call.Stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// psql interpolates the password into the ALTER ROLE before the server sees it,
|
||||
// so a parameter group that turns statement logging on would write the rotated
|
||||
// password into a postmaster.log every snapshot then carries.
|
||||
func TestPostgresEngine_SetPasswordSilencesStatementLogging(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
|
||||
t.Fatalf("SetPassword: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("ran %d commands, want 1", len(runner.calls))
|
||||
}
|
||||
sql := runner.calls[0].Stdin
|
||||
alter := strings.Index(sql, "ALTER ROLE")
|
||||
if alter < 0 {
|
||||
t.Fatalf("SQL %q does not alter the role", sql)
|
||||
}
|
||||
for _, guard := range []string{
|
||||
"SET log_statement = 'none';",
|
||||
"SET log_min_duration_statement = -1;",
|
||||
"SET log_min_error_statement = 'panic';",
|
||||
} {
|
||||
at := strings.Index(sql, guard)
|
||||
if at < 0 {
|
||||
t.Errorf("SQL %q is missing %q", sql, guard)
|
||||
continue
|
||||
}
|
||||
if at > alter {
|
||||
t.Errorf("SQL %q sets %q only after the ALTER ROLE has already been logged", sql, guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// psql echoes the failing statement, which here would carry the new password
|
||||
// back into a reply the control plane records.
|
||||
func TestPostgresEngine_SetPasswordRedactsTheSecretFromAFailure(t *testing.T) {
|
||||
runner := &recordingRunner{err: errors.New(`ERROR: syntax error at or near "n3w-pw"`)}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw")
|
||||
if err == nil {
|
||||
t.Fatal("SetPassword succeeded against a failing psql")
|
||||
}
|
||||
if strings.Contains(err.Error(), "n3w-pw") {
|
||||
t.Errorf("error %q leaks the password", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[REDACTED]") {
|
||||
t.Errorf("error %q does not mark the redaction", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresEngine_SetPasswordRejectsAnIncompleteCommand(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
if err := engine.SetPassword(context.Background(), "mulgamaster", ""); err == nil {
|
||||
t.Error("SetPassword accepted an empty password")
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("ran %d commands for an incomplete request, want 0", len(runner.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// The apply runs as the cluster superuser under peer auth, so a reserved
|
||||
// name arriving in a command payload would rotate the bootstrap superuser's
|
||||
// password rather than the customer's own role.
|
||||
func TestPostgresEngine_SetPasswordRefusesReservedRoles(t *testing.T) {
|
||||
for _, username := range []string{"postgres", "rds_superuser", "rdsadmin", "pg_toast_owner", "PostGres"} {
|
||||
t.Run(username, func(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
err := engine.SetPassword(context.Background(), username, "n3w-pw")
|
||||
if err == nil {
|
||||
t.Fatalf("SetPassword accepted the reserved role %q", username)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("ran %d commands for a reserved role, want 0", len(runner.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The parameters land where rds-init installs them, so the next boot overwrites
|
||||
// the file rather than shadowing it with a second copy.
|
||||
func TestPostgresEngine_ApplyParametersInstallsAndReloads(t *testing.T) {
|
||||
runner := &recordingRunner{out: "shared_buffers\nmax_connections\n"}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
pending, err := engine.ApplyParameters(context.Background(),
|
||||
[]handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParameters: %v", err)
|
||||
}
|
||||
|
||||
installed, err := os.ReadFile(filepath.Join(engine.pgData, "conf.d", "10-rds-parameters.conf"))
|
||||
if err != nil {
|
||||
t.Fatalf("read the installed parameters: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(installed), "shared_buffers = '256MB'") {
|
||||
t.Errorf("installed parameters = %q, want the resolved value", installed)
|
||||
}
|
||||
if len(runner.calls) != 5 {
|
||||
t.Fatalf("ran %d commands, want a config check, the TLS guard, a reload, the rule read-back and a pending-restart read", len(runner.calls))
|
||||
}
|
||||
// The check and the TLS guard both run before the reload, so neither a value
|
||||
// the engine would refuse nor a rule rejecting every client reaches a running
|
||||
// cluster. The read-back of the rules comes after, since the reload is what
|
||||
// puts them in force.
|
||||
if !slices.Contains(runner.calls[0].Args, "-C") {
|
||||
t.Errorf("first command = %v, want the offline config check", runner.calls[0].Args)
|
||||
}
|
||||
if !strings.Contains(runner.calls[1].Stdin, "SHOW ssl") {
|
||||
t.Errorf("second statement = %q, want the serving-TLS guard", runner.calls[1].Stdin)
|
||||
}
|
||||
if !strings.Contains(runner.calls[2].Stdin, "pg_reload_conf") {
|
||||
t.Errorf("third statement = %q, want the reload", runner.calls[2].Stdin)
|
||||
}
|
||||
if !strings.Contains(runner.calls[3].Stdin, "pg_hba_file_rules") {
|
||||
t.Errorf("fourth statement = %q, want the client authentication read-back", runner.calls[3].Stdin)
|
||||
}
|
||||
if !strings.Contains(runner.calls[4].Stdin, "pending_restart") {
|
||||
t.Errorf("fifth statement = %q, want the pending-restart read", runner.calls[4].Stdin)
|
||||
}
|
||||
if len(pending) != 2 || pending[0] != "shared_buffers" || pending[1] != "max_connections" {
|
||||
t.Errorf("pending = %v, want both settings the engine reported", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// A reload that failed leaves the engine on its old values, so reporting the
|
||||
// apply as successful would tell the customer a change took effect that did not.
|
||||
func TestPostgresEngine_ApplyParametersSurfacesAFailedReload(t *testing.T) {
|
||||
runner := &reloadFailingRunner{reloadErr: errors.New("reload was rejected")}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
|
||||
if _, err := engine.ApplyParameters(context.Background(),
|
||||
[]handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}}); err == nil {
|
||||
t.Fatal("ApplyParameters succeeded against a failing reload")
|
||||
}
|
||||
}
|
||||
|
||||
// pg_reload_conf() returns f when it could not signal the postmaster, and psql
|
||||
// exits 0 all the same. Every read-back after it parses files from disk, so an
|
||||
// apply that trusted the exit status would verify clean against a server still
|
||||
// serving the old rules.
|
||||
func TestPostgresEngine_ApplyParametersSurfacesAnUnsignalledReload(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount, reload: "f"}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
_, err := engine.ApplyParameters(context.Background(),
|
||||
[]handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}})
|
||||
if err == nil || !strings.Contains(err.Error(), `pg_reload_conf() returned "f"`) {
|
||||
t.Fatalf("ApplyParameters error = %v, want a refusal naming the answer the postmaster gave", err)
|
||||
}
|
||||
}
|
||||
|
||||
type reloadFailingRunner struct {
|
||||
recordingRunner
|
||||
|
||||
reloadErr error
|
||||
}
|
||||
|
||||
func (r *reloadFailingRunner) run(ctx context.Context, c command) (string, error) {
|
||||
if strings.Contains(c.Stdin, "pg_reload_conf") {
|
||||
r.calls = append(r.calls, c)
|
||||
return "", r.reloadErr
|
||||
}
|
||||
return r.recordingRunner.run(ctx, c)
|
||||
}
|
||||
|
||||
// A failed instance cannot reload through psql. Its corrected, parser-checked
|
||||
// include must start the service and remain installed as the serving set.
|
||||
func TestPostgresEngine_ApplyParametersRestartsOnARepairSetWhileEngineIsDown(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
codes := []int{2, 2, 0}
|
||||
probeCall := 0
|
||||
cfg := testProbeConfig()
|
||||
engine.probe = newPostgresProbe(cfg, func(context.Context, string, ...string) (int, string, error) {
|
||||
code := codes[min(probeCall, len(codes)-1)]
|
||||
probeCall++
|
||||
return code, "", nil
|
||||
})
|
||||
engine.repairTimeout = 100 * time.Millisecond
|
||||
engine.repairPoll = time.Millisecond
|
||||
params := []handlers_rds.Parameter{
|
||||
{Name: "shared_buffers", Value: "32768"},
|
||||
{Name: "work_mem", Value: "8192"},
|
||||
}
|
||||
|
||||
pending, err := engine.ApplyParameters(t.Context(), params)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyParameters: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Errorf("pending = %v, want the restarted engine's actual pending set", pending)
|
||||
}
|
||||
if len(runner.calls) != 3 {
|
||||
t.Fatalf("calls = %+v, want config check, service restart and pending read", runner.calls)
|
||||
}
|
||||
if !slices.Contains(runner.calls[0].Args, "-C") {
|
||||
t.Errorf("first call = %+v, want the offline config check", runner.calls[0])
|
||||
}
|
||||
if !slices.Equal(runner.calls[1].Args, []string{"postgresql", "restart"}) {
|
||||
t.Errorf("second call args = %v, want the service restart", runner.calls[1].Args)
|
||||
}
|
||||
if !strings.Contains(runner.calls[2].Stdin, "pending_restart") {
|
||||
t.Errorf("third call = %+v, want the pending-restart read", runner.calls[2])
|
||||
}
|
||||
installed, err := os.ReadFile(engine.params.installedPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read installed parameters: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(installed), "work_mem = '8192'") {
|
||||
t.Errorf("installed parameters = %q, want the repair set retained", installed)
|
||||
}
|
||||
}
|
||||
|
||||
// Answers the two read-backs an apply runs from fields the case sets, so what
|
||||
// the engine does with each answer is what is under test.
|
||||
type tlsReadBackRunner struct {
|
||||
recordingRunner
|
||||
|
||||
ssl string
|
||||
ruleRows int
|
||||
brokenRows int
|
||||
// What pg_reload_conf() answered. Empty is the postmaster having taken the
|
||||
// signal, which is every case but the one about it not having.
|
||||
reload string
|
||||
}
|
||||
|
||||
func (r *tlsReadBackRunner) run(ctx context.Context, c command) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(c.Stdin, "SHOW ssl"):
|
||||
r.calls = append(r.calls, c)
|
||||
return r.ssl + "\n", nil
|
||||
case strings.Contains(c.Stdin, "pg_reload_conf"):
|
||||
r.calls = append(r.calls, c)
|
||||
if r.reload == "" {
|
||||
return "t\n", nil
|
||||
}
|
||||
return r.reload + "\n", nil
|
||||
case strings.Contains(c.Stdin, "pg_hba_file_rules"):
|
||||
r.calls = append(r.calls, c)
|
||||
return fmt.Sprintf("%d|%d\n", r.ruleRows, r.brokenRows), nil
|
||||
}
|
||||
return r.recordingRunner.run(ctx, c)
|
||||
}
|
||||
|
||||
func forceSSLParameter(t *testing.T, value string) []handlers_rds.Parameter {
|
||||
t.Helper()
|
||||
return []handlers_rds.Parameter{
|
||||
{Name: "work_mem", Value: "4096"},
|
||||
{Name: testPostgresEngineMeta(t).TLSEnforcementParameter(), Value: value},
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL has no server setting for this, so the value in the parameter file
|
||||
// is inert: the rule the generated pg_hba includes is the whole of it.
|
||||
func TestPostgresEngine_ApplyParametersWritesTheEnforcementRule(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
|
||||
t.Fatalf("ApplyParameters: %v", err)
|
||||
}
|
||||
rule, err := os.ReadFile(engine.forceSSLRulePath())
|
||||
if err != nil {
|
||||
t.Fatalf("read the enforcement rule: %v", err)
|
||||
}
|
||||
if string(rule) != postgresForceSSLRules {
|
||||
t.Errorf("enforcement rule = %q, want the constant rds-init also writes", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot carries hba.d with it, so restoring one taken while enforcing into
|
||||
// a group that turns enforcement off has to take the rule with it.
|
||||
func TestPostgresEngine_ApplyParametersRemovesAStaleEnforcementRule(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on"}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
if err := os.MkdirAll(filepath.Dir(engine.forceSSLRulePath()), 0o700); err != nil {
|
||||
t.Fatalf("create hba.d: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(engine.forceSSLRulePath(), []byte(postgresForceSSLRules), 0o600); err != nil {
|
||||
t.Fatalf("write the rule the restored volume carried: %v", err)
|
||||
}
|
||||
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
|
||||
t.Fatalf("ApplyParameters: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
|
||||
t.Errorf("the stale enforcement rule is still in place (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary state of an instance whose resolved set predates the parameter,
|
||||
// and the whole of the migration: it begins requiring TLS with no control-plane
|
||||
// work at all.
|
||||
func TestPostgresEngine_ApplyParametersEnforcesOnAnAbsentKey(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
if _, err := engine.ApplyParameters(t.Context(),
|
||||
[]handlers_rds.Parameter{{Name: "work_mem", Value: "4096"}}); err != nil {
|
||||
t.Fatalf("ApplyParameters: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
|
||||
t.Errorf("a set predating the parameter did not enforce (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver canonicalises every boolean, so a value that is neither means the
|
||||
// file was written by something other than the platform. Reading an unparsable
|
||||
// security setting as off is the one choice not open here.
|
||||
func TestPostgresEngine_ApplyParametersRefusesAnUnparsableEnforcementValue(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on"}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "yes"))
|
||||
if err == nil || !strings.Contains(err.Error(), "neither 1 nor 0") {
|
||||
t.Fatalf("ApplyParameters error = %v, want a refusal naming the unreadable value", err)
|
||||
}
|
||||
if _, statErr := os.Stat(engine.params.installedPath()); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the refused set is still installed (stat err = %v)", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Without this, a live enable against an engine started with no certificate
|
||||
// would reject every subsequent connection and report the parameter applied.
|
||||
func TestPostgresEngine_ApplyParametersRefusesToEnforceWithoutTLS(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "off"}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1"))
|
||||
if err == nil || !strings.Contains(err.Error(), "serving without TLS") {
|
||||
t.Fatalf("ApplyParameters error = %v, want a refusal naming the engine's own TLS state", err)
|
||||
}
|
||||
if _, statErr := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the enforcement rule was written anyway (stat err = %v)", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL discards a pg_hba it cannot parse and keeps the rules it is already
|
||||
// serving, which presents as an apply that succeeded and enforces nothing. The
|
||||
// shape is asserted as well as the errors, because our own derivation writing
|
||||
// the wrong thing is the failure that will actually happen.
|
||||
func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
ruleRows int
|
||||
brokenRows int
|
||||
want string
|
||||
}{
|
||||
{name: "rule missing", value: "1", ruleRows: 0, want: "carries 0 TLS enforcement rules, want 2"},
|
||||
{name: "rule present when it should not be", value: "0", ruleRows: 2, want: "carries 2 TLS enforcement rules, want 0"},
|
||||
{name: "a rule the engine could not parse", value: "1", ruleRows: 2, brokenRows: 1, want: "could not parse"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: tc.ruleRows, brokenRows: tc.brokenRows}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, tc.value))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("ApplyParameters error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected set has to take its enforcement with it. Withdrawing one without
|
||||
// the other would leave the instance serving the previous parameters under the
|
||||
// rejected set's TLS posture.
|
||||
func TestPostgresEngine_ApplyParametersWithdrawsBothFilesTogether(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
|
||||
t.Fatalf("first ApplyParameters: %v", err)
|
||||
}
|
||||
|
||||
// Turning enforcement off, verified against a pg_hba that still carries the
|
||||
// rule — so the apply fails after both files have already been replaced.
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err == nil {
|
||||
t.Fatal("ApplyParameters succeeded against an unverifiable enforcement")
|
||||
}
|
||||
installed, err := os.ReadFile(engine.params.installedPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read the installed parameters: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(installed), "rds.force_ssl = '1'") {
|
||||
t.Errorf("installed parameters = %q, want the previous accepted set restored", installed)
|
||||
}
|
||||
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
|
||||
t.Errorf("the enforcement of the previous set was not restored with it (stat err = %v)", err)
|
||||
}
|
||||
// The reload already adopted the rejected files, so putting them back is not
|
||||
// enough on its own.
|
||||
if !strings.Contains(runner.calls[len(runner.calls)-1].Stdin, "pg_reload_conf") {
|
||||
t.Errorf("last statement = %q, want a reload taking the engine off the withdrawn set", runner.calls[len(runner.calls)-1].Stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// A service restart does not re-run rds-init, so a restore stopping at the
|
||||
// parameter file would bring the last known good set up under the enforcement of
|
||||
// the set that was just rejected.
|
||||
func TestPostgresEngine_RestoreLastKnownGoodReDerivesEnforcement(t *testing.T) {
|
||||
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
|
||||
engine := newScriptedTestEngine(t, runner.run)
|
||||
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
|
||||
t.Fatalf("ApplyParameters(enforcing): %v", err)
|
||||
}
|
||||
if err := engine.RecordServingParameters(t.Context()); err != nil {
|
||||
t.Fatalf("RecordServingParameters: %v", err)
|
||||
}
|
||||
runner.ruleRows = 0
|
||||
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
|
||||
t.Fatalf("ApplyParameters(not enforcing): %v", err)
|
||||
}
|
||||
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("the enforcement rule survived the set that turned it off (stat err = %v)", err)
|
||||
}
|
||||
|
||||
engine.probe = stubProbe(t, 2, nil)
|
||||
restored, err := engine.RestoreLastKnownGoodParameters(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreLastKnownGoodParameters: %v", err)
|
||||
}
|
||||
if !restored {
|
||||
t.Fatal("did not restore a set that differs from the last known good")
|
||||
}
|
||||
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
|
||||
t.Errorf("the restored set's enforcement was not re-derived (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresEngine_ApplyParametersKeepsRepairSetWhenRestartTimesOut(t *testing.T) {
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestEngine(t, runner.run)
|
||||
engine.probe = stubProbe(t, 2, nil)
|
||||
engine.repairTimeout = 10 * time.Millisecond
|
||||
engine.repairPoll = time.Millisecond
|
||||
|
||||
_, err := engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{{Name: "work_mem", Value: "8192"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "wait for the engine") {
|
||||
t.Fatalf("ApplyParameters error = %v, want the repair wait failure", err)
|
||||
}
|
||||
installed, readErr := os.ReadFile(engine.params.installedPath())
|
||||
if readErr != nil {
|
||||
t.Fatalf("read installed parameters: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(installed), "work_mem = '8192'") {
|
||||
t.Errorf("installed parameters = %q, want the checked repair set retained", installed)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// A non-zero exit is a result, not a fault, so it comes back as a code; err is
|
||||
// reserved for the probe failing to run at all. The client's stderr comes back
|
||||
// with it: on a refusal it is the only place the reason is ever stated.
|
||||
type probeRunner func(ctx context.Context, name string, args ...string) (int, string, error)
|
||||
|
||||
func execProbeRunner(ctx context.Context, name string, args ...string) (int, string, error) {
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
out := strings.TrimSpace(stderr.String())
|
||||
if err == nil {
|
||||
return 0, out, nil
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return -1, out, ctxErr
|
||||
}
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return exitErr.ExitCode(), out, nil
|
||||
}
|
||||
return -1, out, err
|
||||
}
|
||||
|
||||
// What the engine reports about itself, which is the only part of the probe
|
||||
// that differs between engines. The port is passed in rather than read from the
|
||||
// engine, so the atomic below stays the one place it is followed from.
|
||||
type probeStateFn func(ctx context.Context, port int64) (engineState, string)
|
||||
|
||||
// Engine-neutral: the latch, the port, the degraded mapping and the translation
|
||||
// to a reported health are shared, and only the state function is supplied by
|
||||
// the engine the image bakes.
|
||||
type engineProbe struct {
|
||||
stateFn probeStateFn
|
||||
// Set from the bootstrap config once it lands, from a different goroutine
|
||||
// than the heartbeat that reads it.
|
||||
port atomic.Int64
|
||||
// Latches once the engine has answered. The agent is up well before initdb
|
||||
// finishes, so a down engine is "starting" until it has served once.
|
||||
seenHealthy bool
|
||||
}
|
||||
|
||||
func newEngineProbe(port int, stateFn probeStateFn) *engineProbe {
|
||||
p := &engineProbe{stateFn: stateFn}
|
||||
p.port.Store(int64(port))
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *engineProbe) setPort(port int) {
|
||||
p.port.Store(int64(port))
|
||||
}
|
||||
|
||||
// What the engine is doing, before the seenHealthy latch collapses "never up"
|
||||
// and "went away" into one health. The rollback needs them apart: an engine
|
||||
// replaying WAL comes back on its own, one that is not running will not.
|
||||
type engineState int
|
||||
|
||||
const (
|
||||
// Not answering at all, or the probe could not run.
|
||||
engineAbsent engineState = iota
|
||||
// The process is up but not yet serving: startup or crash recovery.
|
||||
engineRecovering
|
||||
engineServing
|
||||
)
|
||||
|
||||
// Only the heartbeat calls this, so the seenHealthy latch is single-goroutine
|
||||
// state.
|
||||
func (p *engineProbe) Check(ctx context.Context) (handlers_rds.EngineHealth, string) {
|
||||
state, message := p.state(ctx)
|
||||
switch state {
|
||||
case engineServing:
|
||||
p.seenHealthy = true
|
||||
return handlers_rds.EngineHealthHealthy, ""
|
||||
case engineRecovering:
|
||||
return handlers_rds.EngineHealthStarting, message
|
||||
default:
|
||||
return p.degraded(), message
|
||||
}
|
||||
}
|
||||
|
||||
// The raw probe result. Safe to call from a goroutine other than the
|
||||
// heartbeat's: it touches no latched state.
|
||||
func (p *engineProbe) state(ctx context.Context) (engineState, string) {
|
||||
return p.stateFn(ctx, p.port.Load())
|
||||
}
|
||||
|
||||
// A non-answering engine: starting until it has answered once, unhealthy after.
|
||||
func (p *engineProbe) degraded() handlers_rds.EngineHealth {
|
||||
if p.seenHealthy {
|
||||
return handlers_rds.EngineHealthUnhealthy
|
||||
}
|
||||
return handlers_rds.EngineHealthStarting
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
func TestEngineProbe_MapsExitCodesToHealth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
runErr error
|
||||
seenHealthy bool
|
||||
want handlers_rds.EngineHealth
|
||||
}{
|
||||
{name: "accepting connections", code: 0, want: handlers_rds.EngineHealthHealthy},
|
||||
{name: "rejecting during startup", code: 1, want: handlers_rds.EngineHealthStarting},
|
||||
{name: "silent before first success", code: 2, want: handlers_rds.EngineHealthStarting},
|
||||
{name: "silent after first success", code: 2, seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
|
||||
{name: "no attempt made", code: 3, want: handlers_rds.EngineHealthStarting},
|
||||
{name: "probe missing before first success", runErr: errors.New("executable not found"), want: handlers_rds.EngineHealthStarting},
|
||||
{name: "probe missing after first success", runErr: errors.New("executable not found"), seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
|
||||
return tt.code, "", tt.runErr
|
||||
})
|
||||
probe.seenHealthy = tt.seenHealthy
|
||||
|
||||
got, message := probe.Check(context.Background())
|
||||
if got != tt.want {
|
||||
t.Errorf("health = %q, want %q", got, tt.want)
|
||||
}
|
||||
if got != handlers_rds.EngineHealthHealthy && message == "" {
|
||||
t.Error("a non-healthy result carried no message explaining it")
|
||||
}
|
||||
if got == handlers_rds.EngineHealthHealthy && message != "" {
|
||||
t.Errorf("a healthy result carried a message: %q", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The latch is what separates a boot still in progress from a failure, and it
|
||||
// has to flip the first time the engine answers.
|
||||
func TestEngineProbe_LatchesAfterFirstHealthy(t *testing.T) {
|
||||
code := 0
|
||||
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
|
||||
return code, "", nil
|
||||
})
|
||||
|
||||
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthHealthy {
|
||||
t.Fatalf("first check = %q, want healthy", got)
|
||||
}
|
||||
code = 2
|
||||
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthUnhealthy {
|
||||
t.Errorf("check after the engine went away = %q, want unhealthy", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The port the engine listens on comes from the bootstrap config, so the probe
|
||||
// must follow it rather than keep asking the default.
|
||||
func TestEngineProbe_ProbesTheAssignedPort(t *testing.T) {
|
||||
var gotArgs []string
|
||||
probe := newPostgresProbe(testProbeConfig(), func(_ context.Context, _ string, args ...string) (int, string, error) {
|
||||
gotArgs = args
|
||||
return 0, "", nil
|
||||
})
|
||||
|
||||
probe.setPort(6543)
|
||||
probe.Check(context.Background())
|
||||
|
||||
if !strings.Contains(strings.Join(gotArgs, " "), "-p 6543") {
|
||||
t.Errorf("probe args = %v, want the assigned port 6543", gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecProbeRunner_PreservesTheProbeDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
code, _, err := execProbeRunner(ctx, "sh", "-c", "while :; do :; done")
|
||||
if code != -1 {
|
||||
t.Errorf("exit code = %d, want -1 for a timed-out probe", code)
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("error = %v, want the context deadline", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The client's account of a refusal is the reason a probe failure is legible at
|
||||
// all, so a runner that dropped it would leave the caller nothing to report.
|
||||
func TestExecProbeRunner_CapturesTheClientsStderr(t *testing.T) {
|
||||
code, stderr, err := execProbeRunner(t.Context(), "sh", "-c", "echo 'TLS handshake failed' >&2; exit 1")
|
||||
if err != nil {
|
||||
t.Fatalf("execProbeRunner: %v", err)
|
||||
}
|
||||
if code != 1 {
|
||||
t.Errorf("exit code = %d, want 1", code)
|
||||
}
|
||||
if stderr != "TLS handshake failed" {
|
||||
t.Errorf("stderr = %q, want the client's own message", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine binds the customer ENI and nothing else, so a loopback probe would
|
||||
// report a serving engine as absent. libpq reads a host beginning with / as a
|
||||
// socket directory, which is also what keeps liveness off the network path.
|
||||
func TestPostgresProbe_ProbesTheUnixSocket(t *testing.T) {
|
||||
cfg := testProbeConfig()
|
||||
var probed []string
|
||||
probe := newPostgresProbe(cfg, func(_ context.Context, name string, args ...string) (int, string, error) {
|
||||
probed = append([]string{name}, args...)
|
||||
return 0, "", nil
|
||||
})
|
||||
|
||||
if state, _ := probe.state(context.Background()); state != engineServing {
|
||||
t.Fatalf("probe state = %v, want engineServing", state)
|
||||
}
|
||||
host := ""
|
||||
for i, arg := range probed {
|
||||
if arg == "-h" && i+1 < len(probed) {
|
||||
host = probed[i+1]
|
||||
}
|
||||
}
|
||||
if host != cfg.SocketDir {
|
||||
t.Errorf("probe host = %q, want the socket directory %q", host, cfg.SocketDir)
|
||||
}
|
||||
if !strings.HasPrefix(host, "/") {
|
||||
t.Errorf("probe host = %q, which libpq reads as a TCP host rather than a socket directory", host)
|
||||
}
|
||||
}
|
||||
|
||||
func testProbeConfig() config {
|
||||
return config{
|
||||
SocketDir: engineLayouts[enginePostgres].socketDir,
|
||||
EnginePort: engineLayouts[enginePostgres].port,
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
const (
|
||||
servingParameterRecordTimeout = 15 * time.Second
|
||||
maxBootstrapFailureBytes = 512
|
||||
)
|
||||
|
||||
// The beat carries the probe's result, not the agent's own liveness: an agent
|
||||
// up while the engine is down is what the recovery reconciler must see.
|
||||
type heartbeater struct {
|
||||
cp controlPlane
|
||||
probe *engineProbe
|
||||
recorder servingParameterRecorder
|
||||
id identity
|
||||
interval time.Duration
|
||||
|
||||
// Bootstrap and heartbeat run concurrently. Keeping the latest failure here
|
||||
// makes a VM stuck before engine startup explain why through its next beat.
|
||||
bootstrapFailure atomic.Pointer[string]
|
||||
}
|
||||
|
||||
func newHeartbeater(cp controlPlane, probe *engineProbe, recorder servingParameterRecorder, interval time.Duration) *heartbeater {
|
||||
if interval <= 0 {
|
||||
interval = handlers_rds.HeartbeatInterval
|
||||
}
|
||||
return &heartbeater{cp: cp, probe: probe, recorder: recorder, interval: interval}
|
||||
}
|
||||
|
||||
// Called before Run starts and from Run's own goroutine after, so the interval
|
||||
// is never written concurrently with the loop reading it.
|
||||
func (h *heartbeater) setInterval(d time.Duration) {
|
||||
if d > 0 {
|
||||
h.interval = d
|
||||
}
|
||||
}
|
||||
|
||||
func (h *heartbeater) setBootstrapFailure(what string, err error) {
|
||||
message := what + " failed: " + err.Error()
|
||||
if len(message) > maxBootstrapFailureBytes {
|
||||
limit := maxBootstrapFailureBytes - len("...")
|
||||
for limit > 0 && !utf8.RuneStart(message[limit]) {
|
||||
limit--
|
||||
}
|
||||
message = message[:limit] + "..."
|
||||
}
|
||||
h.bootstrapFailure.Store(&message)
|
||||
}
|
||||
|
||||
func (h *heartbeater) clearBootstrapFailure() {
|
||||
h.bootstrapFailure.Store(nil)
|
||||
}
|
||||
|
||||
func (h *heartbeater) beat(ctx context.Context) {
|
||||
health, message := h.probe.Check(ctx)
|
||||
if failure := h.bootstrapFailure.Load(); failure != nil {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += *failure
|
||||
}
|
||||
if health == handlers_rds.EngineHealthHealthy && h.recorder != nil {
|
||||
// Checking every healthy beat also observes a restart that completed
|
||||
// between probes. The recorder skips unchanged and pending-restart sets.
|
||||
recordCtx, cancel := context.WithTimeout(ctx, servingParameterRecordTimeout)
|
||||
err := h.recorder.RecordServingParameters(recordCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Warn("rds-agent: recording the serving parameters failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := h.cp.SubmitState(ctx, h.id, health, message)
|
||||
if err != nil {
|
||||
// Not escalated: the control plane already treats a missing heartbeat as
|
||||
// staleness, and the next tick retries.
|
||||
slog.Warn("rds-agent: heartbeat failed", "health", health, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("rds-agent: heartbeat", "health", health, "persisted", out.Persisted)
|
||||
h.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
|
||||
}
|
||||
|
||||
// How often a not-yet-serving engine is reported. The control plane owns the
|
||||
// steady-state cadence, but while the engine has never answered it is actively
|
||||
// waiting on this instance — a restart is not finished until a healthy beat
|
||||
// lands — and a full interval of silence there is pure added downtime.
|
||||
const startupHeartbeatInterval = 5 * time.Second
|
||||
|
||||
func (h *heartbeater) nextInterval() time.Duration {
|
||||
if h.probe != nil && !h.probe.seenHealthy && startupHeartbeatInterval < h.interval {
|
||||
return startupHeartbeatInterval
|
||||
}
|
||||
return h.interval
|
||||
}
|
||||
|
||||
// A timer rather than a ticker, so a cadence change takes effect on the next
|
||||
// beat, not at a restart. The first beat is not waited for: a just-registered
|
||||
// agent has told the control plane nothing about the engine yet, and holding
|
||||
// that back for an interval makes every restart look longer than it was.
|
||||
func (h *heartbeater) Run(ctx context.Context) {
|
||||
h.beat(ctx)
|
||||
timer := time.NewTimer(h.nextInterval())
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
h.beat(ctx)
|
||||
timer.Reset(h.nextInterval())
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-18
@@ -1,11 +1,7 @@
|
||||
// rds-agent runs inside a Spinifex RDS instance. It registers the VM with the
|
||||
// control plane over TLS+SigV4 (never NATS), writes the bootstrap handoff the
|
||||
// engine's first-boot script consumes, then heartbeats health and polls for
|
||||
// directives.
|
||||
//
|
||||
// It is the only path by which a secret reaches the VM: the master password is
|
||||
// served once, to an authenticated caller. Static config is read from the
|
||||
// cloud-init env file /etc/spinifex-rds/agent.env; real env vars override it.
|
||||
// Command rds-agent runs inside a Spinifex RDS instance; the implementation is
|
||||
// in agents/rds/agent. This binary owns process lifecycle: signal handling,
|
||||
// treating a cancelled startup as a clean stop, failure logging and the exit
|
||||
// code.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -17,23 +13,69 @@ import (
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
rdsagent "github.com/mulgadc/spinifex/spinifex/agents/rds/agent"
|
||||
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
|
||||
)
|
||||
|
||||
// version is the agent build version, reported at registration.
|
||||
// Overridable via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
// engineCatalogAdapter composes the agent's narrow EngineCatalog over the
|
||||
// control plane's own engine package. This binary is the only place in the
|
||||
// agent's dependency graph that imports it.
|
||||
type engineCatalogAdapter struct {
|
||||
engine rdsengine.Engine
|
||||
}
|
||||
|
||||
var _ rdsagent.EngineCatalog = engineCatalogAdapter{}
|
||||
|
||||
func (a engineCatalogAdapter) OptionFileName(name string) string {
|
||||
return a.engine.OptionFileName(name)
|
||||
}
|
||||
|
||||
func (a engineCatalogAdapter) LookupParameter(name string) (rdsagent.CatalogParameter, bool) {
|
||||
spec, ok := a.engine.LookupParameter(name)
|
||||
if !ok {
|
||||
return rdsagent.CatalogParameter{}, false
|
||||
}
|
||||
return rdsagent.CatalogParameter{DataType: spec.DataType, Static: spec.ApplyType == rdsengine.ApplyTypeStatic}, true
|
||||
}
|
||||
|
||||
func (a engineCatalogAdapter) CatalogParameterNames() []string {
|
||||
return a.engine.CatalogParameterNames()
|
||||
}
|
||||
|
||||
func (a engineCatalogAdapter) TLSEnforcementParameter() string {
|
||||
return a.engine.TLSEnforcementParameter()
|
||||
}
|
||||
|
||||
func (a engineCatalogAdapter) ValidateMasterUsername(username string) error {
|
||||
return a.engine.ValidateMasterUsername(username)
|
||||
}
|
||||
|
||||
func (a engineCatalogAdapter) ValidateUsernameNotReserved(username string) error {
|
||||
return a.engine.ValidateUsernameNotReserved(username)
|
||||
}
|
||||
|
||||
func lookupEngineCatalog(name string) (rdsagent.EngineCatalog, error) {
|
||||
e, err := rdsengine.LookupEngine(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return engineCatalogAdapter{engine: e}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig(defaultEnvFile)
|
||||
cfg := rdsagent.LoadConfig(rdsagent.DefaultEnvFile)
|
||||
cfg.AgentVersion = version
|
||||
cfg.EngineCatalog = lookupEngineCatalog
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Retried rather than fatal: construction resolves the IMDS credential chain,
|
||||
// so a metadata service that is not answering yet would otherwise end the
|
||||
// process with the engine still down and nothing left to bring it back.
|
||||
var agent *Agent
|
||||
err := retry(ctx, "startup", func(context.Context) error {
|
||||
var err error
|
||||
agent, err = New(cfg)
|
||||
return err
|
||||
})
|
||||
agent, err := rdsagent.Start(ctx, cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain shortens the boot-retry backoff for the whole package: the tests
|
||||
// assert which calls are retried, not how long the agent waits between them.
|
||||
func TestMain(m *testing.M) {
|
||||
retryMin, retryMax = time.Millisecond, time.Millisecond
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// The generated parameter files and what it takes to install one. They live on
|
||||
// the data volume at the paths rds-init writes: a class change replaces the VM,
|
||||
// so a set under /etc would revert to defaults while the API reported otherwise.
|
||||
type parameterStore struct {
|
||||
dir string
|
||||
// The include the resolved set is rendered to.
|
||||
installed string
|
||||
// The copy of the last set the engine accepted, which a boot loop is rolled
|
||||
// back to. Deliberately not a name the engine's own include glob reads, so it
|
||||
// is never parsed as a second set of settings.
|
||||
lastGood string
|
||||
// What the engine actually started on, for an engine that cannot be asked
|
||||
// which of its settings are still pending a restart. Empty for one that can.
|
||||
serving string
|
||||
// Prepended to every file written here, for a configuration format that has
|
||||
// no setting outside a group.
|
||||
header string
|
||||
// The engine parses its own configuration, so a root-owned file would be
|
||||
// unreadable to it.
|
||||
osUser string
|
||||
// Names the engine whose option-file spellings these files are written in.
|
||||
engine string
|
||||
}
|
||||
|
||||
func (s parameterStore) installedPath() string { return filepath.Join(s.dir, s.installed) }
|
||||
func (s parameterStore) lastGoodPath() string { return filepath.Join(s.dir, s.lastGood) }
|
||||
func (s parameterStore) servingPath() string { return filepath.Join(s.dir, s.serving) }
|
||||
|
||||
// Byte-for-byte what rds-init writes for the same set, so the copies compared
|
||||
// against it are comparing values rather than formatting.
|
||||
func (s parameterStore) render(params []handlers_rds.Parameter) ([]byte, error) {
|
||||
body, err := renderParameters(s.engine, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(s.header + body), nil
|
||||
}
|
||||
|
||||
// Installs the resolved set at the path rds-init uses, so a later boot
|
||||
// overwrites rather than shadows it. Returns a func putting back whatever was
|
||||
// there, so the caller can undo the install without re-rendering anything.
|
||||
func (s parameterStore) install(params []handlers_rds.Parameter) (restore func() error, err error) {
|
||||
path := s.installedPath()
|
||||
// A missing file is the first-ever apply, and its rollback is a removal.
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return nil, fmt.Errorf("read the installed parameters at %s: %w", path, readErr)
|
||||
}
|
||||
existed := readErr == nil
|
||||
|
||||
rendered, err := s.render(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.write(path, rendered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
if !existed {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("withdraw the rejected parameters at %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return s.write(path, previous)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Atomic: a temp file in the same directory, renamed over the target. The temp
|
||||
// name deliberately does not end in the engine's include suffix, so a crash
|
||||
// between write and rename cannot leave the engine reading a half-written file.
|
||||
func (s parameterStore) write(path string, content []byte) error {
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := s.chownToEngine(tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("install %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A guest with no engine user is a broken image, not something to work around.
|
||||
func (s parameterStore) chownToEngine(path string) error {
|
||||
credential, err := lookupCredential(s.osUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(path, int(credential.Uid), int(credential.Gid)); err != nil {
|
||||
return fmt.Errorf("hand %s to %s: %w", path, s.osUser, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshots the installed set as the rollback target, but only once the engine
|
||||
// is running all of it. A set still pending a restart has not been shown to
|
||||
// serve, so promoting it would point the rollback at a config about to fail.
|
||||
func recordLastGood(ctx context.Context, store parameterStore, pending pendingRestartFn) error {
|
||||
if _, err := os.Stat(store.installedPath()); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect the serving parameters: %w", err)
|
||||
}
|
||||
names, err := pending(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(store.installedPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the serving parameters: %w", err)
|
||||
}
|
||||
lastGood, err := os.ReadFile(store.lastGoodPath())
|
||||
if err == nil && bytes.Equal(content, lastGood) {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read the last known good parameters: %w", err)
|
||||
}
|
||||
return store.write(store.lastGoodPath(), content)
|
||||
}
|
||||
|
||||
// Puts the last set the engine accepted back in place, for a restart that failed
|
||||
// after a parameter change. The probe is checked again under the caller's
|
||||
// parameter lock, so a repair that just brought the engine back is not reversed.
|
||||
func restoreLastGood(ctx context.Context, store parameterStore, probe *engineProbe) (bool, error) {
|
||||
if state, _ := probe.state(ctx); state != engineAbsent {
|
||||
return false, nil
|
||||
}
|
||||
lastGood, err := os.ReadFile(store.lastGoodPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read the last known good parameters: %w", err)
|
||||
}
|
||||
current, err := os.ReadFile(store.installedPath())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("read the installed parameters: %w", err)
|
||||
}
|
||||
if bytes.Equal(current, lastGood) {
|
||||
return false, nil
|
||||
}
|
||||
if err := store.write(store.installedPath(), lastGood); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// The settings the engine has accepted but will not honour until it restarts.
|
||||
// Each implementation answers it its own way, and both the apply and the serving
|
||||
// snapshot are gated on the same answer.
|
||||
type pendingRestartFn func(ctx context.Context) ([]string, error)
|
||||
|
||||
// The parameter-file state every engine keeps. The operations over it are free
|
||||
// functions above rather than methods here, so each implementation names its own
|
||||
// restart and pending answers at the call site where the compiler checks them.
|
||||
type parameterManager struct {
|
||||
params parameterStore
|
||||
probe *engineProbe
|
||||
// Serializes parameter installs, serving snapshots and rollback restores so
|
||||
// none can copy or replace an intermediate configuration.
|
||||
paramMu sync.Mutex
|
||||
repairTimeout time.Duration
|
||||
repairPoll time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
parameterRepairTimeout = 90 * time.Second
|
||||
parameterRepairPoll = time.Second
|
||||
)
|
||||
|
||||
// Starts a down engine on the set just installed and waits for it to serve.
|
||||
// Reached when the engine went away during an apply: the installed set is the
|
||||
// newer, so starting on it beats restoring one that already failed.
|
||||
func awaitRepairedEngine(ctx context.Context, probe *engineProbe, restart func(context.Context) error,
|
||||
pending pendingRestartFn, timeout, poll time.Duration) ([]string, error) {
|
||||
repairCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := restart(repairCtx); err != nil {
|
||||
return nil, fmt.Errorf("start the engine on the repaired parameter set: %w", err)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
lastMessage := "engine did not respond"
|
||||
for {
|
||||
select {
|
||||
case <-repairCtx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("wait for the engine on the repaired parameter set: %w", ctx.Err())
|
||||
}
|
||||
return nil, fmt.Errorf("wait for the engine on the repaired parameter set: %s: %w", lastMessage, repairCtx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
state, message := probe.state(repairCtx)
|
||||
if state == engineServing {
|
||||
names, err := pending(repairCtx)
|
||||
if err == nil {
|
||||
return names, nil
|
||||
}
|
||||
lastMessage = err.Error()
|
||||
} else if message != "" {
|
||||
lastMessage = message
|
||||
}
|
||||
timer.Reset(poll)
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// The in-guest half of a storage grow: the control plane has already grown the
|
||||
// block device, so all that is left is the filesystem. Both ext4 and XFS grow
|
||||
// while mounted, so this needs no ordering against the engine start.
|
||||
type storageOps interface {
|
||||
// Extends the filesystem at the data mount onto its whole device, and
|
||||
// reports what it did for the command reply.
|
||||
GrowFilesystem(ctx context.Context) (string, error)
|
||||
// Flushes every dirty page on the data mount to its device. A backup hold
|
||||
// settles the engine's own files and nothing else on that filesystem.
|
||||
SyncDataMount(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Tool names rather than paths: the image installs them from
|
||||
// cloud-utils-growpart, e2fsprogs-extra and xfsprogs, all on PATH.
|
||||
const (
|
||||
defaultGrowpart = "growpart"
|
||||
defaultResize2fs = "resize2fs"
|
||||
defaultXFSGrowfs = "xfs_growfs"
|
||||
defaultMountsFile = "/proc/mounts"
|
||||
defaultSysBlock = "/sys/class/block"
|
||||
// growpart says this when the partition already fills the disk, which a
|
||||
// resumed grow is the normal way to reach.
|
||||
growpartNoChange = "NOCHANGE"
|
||||
)
|
||||
|
||||
type guestStorage struct {
|
||||
run commandRunner
|
||||
dataMount string
|
||||
mountsFile string
|
||||
sysBlock string
|
||||
}
|
||||
|
||||
var _ storageOps = (*guestStorage)(nil)
|
||||
|
||||
func newGuestStorage(cfg config, run commandRunner) *guestStorage {
|
||||
return &guestStorage{
|
||||
run: run,
|
||||
dataMount: cfg.DataMount,
|
||||
mountsFile: cfg.MountsFile,
|
||||
sysBlock: cfg.SysBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// syncfs rather than fsync: the whole filesystem, not one file. Config the
|
||||
// engine never opens lives on this volume too, and a snapshot that catches it
|
||||
// half-written leaves a restored instance the engine cannot start from.
|
||||
func (g *guestStorage) SyncDataMount(ctx context.Context) error {
|
||||
f, err := os.Open(g.dataMount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open the data mount %s to flush it: %w", g.dataMount, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := unix.Syncfs(int(f.Fd())); err != nil {
|
||||
return fmt.Errorf("flush the data mount %s: %w", g.dataMount, err)
|
||||
}
|
||||
slog.InfoContext(ctx, "rds-agent: data mount flushed for backup", "dataMount", g.dataMount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The device and filesystem behind the data mount, resolved from the kernel's
|
||||
// own mount table rather than from anything the control plane asserted.
|
||||
type dataMount struct {
|
||||
device string
|
||||
fstype string
|
||||
}
|
||||
|
||||
func (g *guestStorage) GrowFilesystem(ctx context.Context) (string, error) {
|
||||
mount, err := g.resolveDataMount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// rds-datadir formats the whole device and refuses a disk that already
|
||||
// carries a partition table, so the data volume is normally unpartitioned
|
||||
// and there is no partition to push out first.
|
||||
if err := g.growPartition(ctx, mount.device); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(mount.fstype, "ext"):
|
||||
// resize2fs grows a mounted ext filesystem online, and takes the device.
|
||||
if _, err := g.run(ctx, command{
|
||||
Name: defaultResize2fs,
|
||||
Args: []string{mount.device},
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("grow the %s filesystem on %s: %w", mount.fstype, mount.device, err)
|
||||
}
|
||||
case mount.fstype == "xfs":
|
||||
// xfs_growfs is addressed by mount point, and XFS can only grow mounted.
|
||||
if _, err := g.run(ctx, command{
|
||||
Name: defaultXFSGrowfs,
|
||||
Args: []string{g.dataMount},
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("grow the xfs filesystem at %s: %w", g.dataMount, err)
|
||||
}
|
||||
default:
|
||||
// Guessing a tool here would either do nothing or corrupt a filesystem
|
||||
// this image was never meant to be holding.
|
||||
return "", fmt.Errorf("%s holds an unsupported filesystem %q; refusing to grow it", mount.device, mount.fstype)
|
||||
}
|
||||
|
||||
slog.Info("rds-agent: data filesystem grown", "device", mount.device, "fstype", mount.fstype, "mount", g.dataMount)
|
||||
return fmt.Sprintf("grew %s (%s) at %s", mount.device, mount.fstype, g.dataMount), nil
|
||||
}
|
||||
|
||||
// Pushes the partition out to the end of its disk when the data mount happens
|
||||
// to sit on one. A device that is not a partition needs nothing, and a
|
||||
// partition that already fills its disk is the normal state of a resumed grow.
|
||||
func (g *guestStorage) growPartition(ctx context.Context, device string) error {
|
||||
disk, number, ok := g.partitionOf(device)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out, err := g.run(ctx, command{
|
||||
Name: defaultGrowpart,
|
||||
Args: []string{disk, number},
|
||||
Env: []string{"PATH=" + defaultGuestPath},
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(out, growpartNoChange) || strings.Contains(err.Error(), growpartNoChange) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("grow partition %s of %s: %w", number, disk, err)
|
||||
}
|
||||
|
||||
// Whether device is a partition, and if so its disk and number. Read from
|
||||
// sysfs, which states it directly rather than inferring it from a trailing
|
||||
// digit — nvme0n1 is a disk and vdb1 is a partition on the same rule.
|
||||
func (g *guestStorage) partitionOf(device string) (disk, number string, ok bool) {
|
||||
name := filepath.Base(device)
|
||||
raw, err := os.ReadFile(filepath.Join(g.sysBlock, name, "partition"))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
number = strings.TrimSpace(string(raw))
|
||||
if number == "" {
|
||||
return "", "", false
|
||||
}
|
||||
// A partition's sysfs entry hangs off its disk's, so the parent directory
|
||||
// names the disk without any parsing of the device name.
|
||||
target, err := os.Readlink(filepath.Join(g.sysBlock, name))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return filepath.Join(filepath.Dir(device), filepath.Base(filepath.Dir(target))), number, true
|
||||
}
|
||||
|
||||
// The mount table entry for the data mount. An absent one means the data volume
|
||||
// is not mounted, which is a broken guest rather than something to grow.
|
||||
func (g *guestStorage) resolveDataMount() (dataMount, error) {
|
||||
f, err := os.Open(g.mountsFile)
|
||||
if err != nil {
|
||||
return dataMount{}, fmt.Errorf("read %s: %w", g.mountsFile, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Later entries shadow earlier ones at the same point, so the last match is
|
||||
// the filesystem actually visible there.
|
||||
found := dataMount{}
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 3 || fields[1] != g.dataMount {
|
||||
continue
|
||||
}
|
||||
found = dataMount{device: fields[0], fstype: fields[2]}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return dataMount{}, fmt.Errorf("read %s: %w", g.mountsFile, err)
|
||||
}
|
||||
if found.device == "" {
|
||||
return dataMount{}, fmt.Errorf("no filesystem is mounted at %s; the data volume is not attached", g.dataMount)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
)
|
||||
|
||||
// Stands in for the guest's storage in the registry tests, where what matters
|
||||
// is that the command reaches it rather than what it does to a filesystem.
|
||||
type fakeStorage struct {
|
||||
grown bool
|
||||
synced bool
|
||||
message string
|
||||
err error
|
||||
syncErr error
|
||||
}
|
||||
|
||||
var _ storageOps = (*fakeStorage)(nil)
|
||||
|
||||
func (f *fakeStorage) GrowFilesystem(context.Context) (string, error) {
|
||||
f.grown = true
|
||||
return f.message, f.err
|
||||
}
|
||||
|
||||
func (f *fakeStorage) SyncDataMount(context.Context) error {
|
||||
f.synced = true
|
||||
return f.syncErr
|
||||
}
|
||||
|
||||
// runLog records what a grow shelled out to, in order, so the test asserts on
|
||||
// the tools invoked rather than on a filesystem it would have to create.
|
||||
type runLog struct {
|
||||
calls []command
|
||||
// errOn fails the first command whose name matches, which is how growpart's
|
||||
// tolerated failure and resize2fs's fatal one are told apart.
|
||||
errOn string
|
||||
errOut string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *runLog) run(_ context.Context, c command) (string, error) {
|
||||
r.calls = append(r.calls, c)
|
||||
if r.errOn != "" && c.Name == r.errOn {
|
||||
return r.errOut, r.err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *runLog) names() []string {
|
||||
names := make([]string, 0, len(r.calls))
|
||||
for _, c := range r.calls {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// A guest whose data volume is mounted as fstype from device, with a sysfs tree
|
||||
// that reports the device as a whole disk unless the test says otherwise.
|
||||
func newTestStorage(t *testing.T, device, fstype string, run commandRunner) *guestStorage {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
mounts := filepath.Join(dir, "mounts")
|
||||
content := "proc /proc proc rw 0 0\n"
|
||||
if device != "" {
|
||||
content += device + " " + testDataMount + " " + fstype + " rw,relatime 0 0\n"
|
||||
}
|
||||
if err := os.WriteFile(mounts, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write the mount table: %v", err)
|
||||
}
|
||||
cfg := config{DataMount: testDataMount, MountsFile: mounts, SysBlock: filepath.Join(dir, "block")}
|
||||
if err := os.MkdirAll(cfg.SysBlock, 0o755); err != nil {
|
||||
t.Fatalf("create the sysfs tree: %v", err)
|
||||
}
|
||||
return newGuestStorage(cfg, run)
|
||||
}
|
||||
|
||||
// Makes name look like partition number of disk, the way sysfs states it: a
|
||||
// "partition" file under the device, hanging off its disk's directory.
|
||||
func seedPartition(t *testing.T, g *guestStorage, disk, name, number string) {
|
||||
t.Helper()
|
||||
// sysfs holds the real entry under the device tree and links to it from
|
||||
// /sys/class/block, so the partition's disk is its link target's parent.
|
||||
target := filepath.Join("..", "devices", disk, name)
|
||||
if err := os.MkdirAll(filepath.Join(g.sysBlock, target), 0o755); err != nil {
|
||||
t.Fatalf("create the partition tree: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(g.sysBlock, target, "partition"), []byte(number+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write the partition number: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(g.sysBlock, name)); err != nil {
|
||||
t.Fatalf("link the partition: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The data volume is formatted whole, so the usual grow is resize2fs against
|
||||
// the device with no partition to push out first.
|
||||
// Where rds-datadir mounts the data volume in the PostgreSQL preset.
|
||||
const testDataMount = "/var/lib/postgresql"
|
||||
|
||||
func TestGrowFilesystem_ResizesAnExt4DeviceOnline(t *testing.T) {
|
||||
log := &runLog{}
|
||||
g := newTestStorage(t, "/dev/vdb", "ext4", log.run)
|
||||
|
||||
message, err := g.GrowFilesystem(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GrowFilesystem: %v", err)
|
||||
}
|
||||
|
||||
if got := log.names(); len(got) != 1 || got[0] != defaultResize2fs {
|
||||
t.Fatalf("ran %v, want just %s", got, defaultResize2fs)
|
||||
}
|
||||
if args := log.calls[0].Args; len(args) != 1 || args[0] != "/dev/vdb" {
|
||||
t.Errorf("resize2fs args = %v, want the device", args)
|
||||
}
|
||||
if !strings.Contains(message, "/dev/vdb") || !strings.Contains(message, testDataMount) {
|
||||
t.Errorf("reply message = %q, want the device and mount it grew", message)
|
||||
}
|
||||
}
|
||||
|
||||
// XFS is addressed by mount point and can only grow mounted, unlike ext.
|
||||
func TestGrowFilesystem_GrowsXFSByMountPoint(t *testing.T) {
|
||||
log := &runLog{}
|
||||
g := newTestStorage(t, "/dev/vdb", "xfs", log.run)
|
||||
|
||||
if _, err := g.GrowFilesystem(context.Background()); err != nil {
|
||||
t.Fatalf("GrowFilesystem: %v", err)
|
||||
}
|
||||
|
||||
if got := log.names(); len(got) != 1 || got[0] != defaultXFSGrowfs {
|
||||
t.Fatalf("ran %v, want just %s", got, defaultXFSGrowfs)
|
||||
}
|
||||
if args := log.calls[0].Args; len(args) != 1 || args[0] != testDataMount {
|
||||
t.Errorf("xfs_growfs args = %v, want the mount point", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A partitioned data device has to have the partition pushed out to the end of
|
||||
// the disk before the filesystem can be extended onto it.
|
||||
func TestGrowFilesystem_PushesThePartitionOutFirst(t *testing.T) {
|
||||
log := &runLog{}
|
||||
g := newTestStorage(t, "/dev/vdb1", "ext4", log.run)
|
||||
seedPartition(t, g, "vdb", "vdb1", "1")
|
||||
|
||||
if _, err := g.GrowFilesystem(context.Background()); err != nil {
|
||||
t.Fatalf("GrowFilesystem: %v", err)
|
||||
}
|
||||
|
||||
if got := log.names(); len(got) != 2 || got[0] != defaultGrowpart || got[1] != defaultResize2fs {
|
||||
t.Fatalf("ran %v, want growpart then resize2fs", got)
|
||||
}
|
||||
if args := log.calls[0].Args; len(args) != 2 || args[0] != "/dev/vdb" || args[1] != "1" {
|
||||
t.Errorf("growpart args = %v, want the disk and the partition number", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A resumed grow finds the partition already filling its disk, which growpart
|
||||
// reports as a failure. Treating it as one would fail a grow that has nothing
|
||||
// left to do at that step.
|
||||
func TestGrowFilesystem_ToleratesAnAlreadyFullPartition(t *testing.T) {
|
||||
log := &runLog{errOn: defaultGrowpart, errOut: "NOCHANGE: partition 1 is size 41940992. it cannot be grown", err: errors.New("exit status 1")}
|
||||
g := newTestStorage(t, "/dev/vdb1", "ext4", log.run)
|
||||
seedPartition(t, g, "vdb", "vdb1", "1")
|
||||
|
||||
if _, err := g.GrowFilesystem(context.Background()); err != nil {
|
||||
t.Fatalf("GrowFilesystem: %v", err)
|
||||
}
|
||||
if got := log.names(); len(got) != 2 || got[1] != defaultResize2fs {
|
||||
t.Errorf("ran %v, want the resize to follow the tolerated growpart", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other growpart failure is a real one, and extending a filesystem onto
|
||||
// capacity the partition does not have would be the corrupting move.
|
||||
func TestGrowFilesystem_FailsOnARealGrowpartError(t *testing.T) {
|
||||
log := &runLog{errOn: defaultGrowpart, errOut: "unable to read partition table", err: errors.New("exit status 2")}
|
||||
g := newTestStorage(t, "/dev/vdb1", "ext4", log.run)
|
||||
seedPartition(t, g, "vdb", "vdb1", "1")
|
||||
|
||||
if _, err := g.GrowFilesystem(context.Background()); err == nil {
|
||||
t.Fatal("GrowFilesystem succeeded on a failed growpart, want an error")
|
||||
}
|
||||
if got := log.names(); len(got) != 1 {
|
||||
t.Errorf("ran %v, want nothing after the failed growpart", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Guessing a tool for an unrecognised filesystem would either do nothing or
|
||||
// corrupt one this image was never meant to be holding.
|
||||
func TestGrowFilesystem_RefusesAnUnsupportedFilesystem(t *testing.T) {
|
||||
log := &runLog{}
|
||||
g := newTestStorage(t, "/dev/vdb", "btrfs", log.run)
|
||||
|
||||
_, err := g.GrowFilesystem(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "btrfs") {
|
||||
t.Fatalf("err = %v, want a refusal naming the filesystem", err)
|
||||
}
|
||||
if len(log.calls) != 0 {
|
||||
t.Errorf("ran %v against an unsupported filesystem, want nothing", log.names())
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing mounted at the data mount is a broken guest rather than a grow to
|
||||
// attempt against whatever else happens to be there.
|
||||
func TestGrowFilesystem_FailsWhenTheDataVolumeIsNotMounted(t *testing.T) {
|
||||
log := &runLog{}
|
||||
g := newTestStorage(t, "", "", log.run)
|
||||
|
||||
if _, err := g.GrowFilesystem(context.Background()); err == nil {
|
||||
t.Fatal("GrowFilesystem succeeded with no data volume mounted, want an error")
|
||||
}
|
||||
if len(log.calls) != 0 {
|
||||
t.Errorf("ran %v with nothing mounted, want nothing", log.names())
|
||||
}
|
||||
}
|
||||
|
||||
// The control plane's grow-filesystem directive has to reach the guest's
|
||||
// storage, or a grown volume never becomes usable capacity.
|
||||
func TestCommandRegistry_GrowFilesystemReachesTheGuestStorage(t *testing.T) {
|
||||
storage := &fakeStorage{message: "grew /dev/vdb (ext4) at /var/lib/postgresql"}
|
||||
|
||||
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, storage), 0).execute(context.Background(),
|
||||
handlers_rds.Command{CommandID: "cmd-9", Type: handlers_rds.CommandGrowFilesystem})
|
||||
|
||||
if !storage.grown {
|
||||
t.Error("the grow-filesystem command did not reach the guest storage")
|
||||
}
|
||||
if reply.Status != handlers_rds.CommandStatusSucceeded || reply.Message != storage.message {
|
||||
t.Errorf("reply = %+v, want succeeded carrying what the grow reported", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncDataMount_FlushesTheMountItWasGiven(t *testing.T) {
|
||||
g := &guestStorage{dataMount: t.TempDir()}
|
||||
|
||||
if err := g.SyncDataMount(context.Background()); err != nil {
|
||||
t.Fatalf("SyncDataMount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The control plane snapshots on the strength of this reply, so a mount that is
|
||||
// not there has to be an error rather than a flush of nothing.
|
||||
func TestSyncDataMount_FailsWhenTheMountIsNotThere(t *testing.T) {
|
||||
g := &guestStorage{dataMount: filepath.Join(t.TempDir(), "absent")}
|
||||
|
||||
err := g.SyncDataMount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("SyncDataMount succeeded on a mount that does not exist")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent") {
|
||||
t.Errorf("error = %v, want the mount it could not open", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/mulgadc/spinifex/spinifex/accountteardown"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// captureOutput redirects os.Stdout for the duration of fn and returns what was
|
||||
// written. The teardown printers are the operator's only view of what is about
|
||||
// to be destroyed, so what they print is the thing under test.
|
||||
func captureOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
orig := os.Stdout
|
||||
os.Stdout = w //nolint:reassign // test-local stdout capture, restored below
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
os.Stdout = orig //nolint:reassign // restoring the real os.Stdout captured above
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// feedStdin swaps os.Stdin for a pipe carrying content, mirroring the stdout
|
||||
// capture above.
|
||||
func feedStdin(t *testing.T, content string, fn func()) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
orig := os.Stdin
|
||||
os.Stdin = r //nolint:reassign // test-local stdin swap, restored below
|
||||
t.Cleanup(func() { os.Stdin = orig }) //nolint:reassign // restoring the real os.Stdin captured above
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(w, content)
|
||||
_ = w.Close()
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
func teardownPlan() *accountteardown.Result {
|
||||
return &accountteardown.Result{
|
||||
AccountID: "000000000042",
|
||||
AccountName: "tenant@example.com",
|
||||
DryRun: true,
|
||||
Stages: []accountteardown.StageResult{
|
||||
{
|
||||
Stage: accountteardown.StageCompute,
|
||||
Deleted: []accountteardown.Resource{{Kind: "instance", ID: "i-1", Detail: "running"}},
|
||||
Elapsed: "0s",
|
||||
},
|
||||
{Stage: accountteardown.StageAttachments, Elapsed: "0s"},
|
||||
{
|
||||
Stage: accountteardown.StageStorage,
|
||||
Deleted: []accountteardown.Resource{{Kind: "volume", ID: "vol-1"}},
|
||||
Elapsed: "0s",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The plan is what the operator confirms against, so every resource has to
|
||||
// appear under its stage — and an empty stage must not pad the list.
|
||||
func TestPrintTeardownPlanListsEveryResource(t *testing.T) {
|
||||
output := captureOutput(t, func() { cmd.PrintTeardownPlan(teardownPlan()) })
|
||||
|
||||
assert.Contains(t, output, "000000000042")
|
||||
assert.Contains(t, output, "tenant@example.com")
|
||||
assert.Contains(t, output, "i-1")
|
||||
assert.Contains(t, output, "vol-1")
|
||||
assert.Contains(t, output, "compute")
|
||||
assert.Contains(t, output, "storage")
|
||||
assert.NotContains(t, output, "attachments", "an empty stage is noise, not information")
|
||||
}
|
||||
|
||||
// An account with nothing in it must say so. A bare header reads as truncated
|
||||
// output, which is exactly when an operator stops and asks.
|
||||
func TestPrintTeardownPlanSaysWhenTheAccountIsEmpty(t *testing.T) {
|
||||
output := captureOutput(t, func() {
|
||||
cmd.PrintTeardownPlan(&accountteardown.Result{
|
||||
AccountID: "000000000042",
|
||||
AccountName: "tenant@example.com",
|
||||
Stages: []accountteardown.StageResult{{Stage: accountteardown.StageCompute, Elapsed: "0s"}},
|
||||
})
|
||||
})
|
||||
|
||||
assert.Contains(t, output, "holds no resources")
|
||||
}
|
||||
|
||||
// A stuck resource is the most valuable thing a teardown reports: each one is a
|
||||
// delete path that does not work. It must be named, with the reason.
|
||||
func TestPrintTeardownResultNamesStuckResources(t *testing.T) {
|
||||
result := &accountteardown.Result{
|
||||
AccountID: "000000000042",
|
||||
Stages: []accountteardown.StageResult{
|
||||
{
|
||||
Stage: accountteardown.StageCompute,
|
||||
Deleted: []accountteardown.Resource{{Kind: "instance", ID: "i-1"}},
|
||||
Elapsed: "5s",
|
||||
},
|
||||
{Stage: accountteardown.StageAttachments, Elapsed: "0s"},
|
||||
{
|
||||
Stage: accountteardown.StageStorage,
|
||||
Stuck: []accountteardown.Stuck{{
|
||||
Resource: accountteardown.Resource{Kind: "volume", ID: "vol-1"},
|
||||
Reason: "still attached to i-1",
|
||||
}},
|
||||
Elapsed: "5m0s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output := captureOutput(t, func() { cmd.PrintTeardownResult(result) })
|
||||
|
||||
assert.Contains(t, output, "1 deleted, 0 stuck")
|
||||
assert.Contains(t, output, "STUCK")
|
||||
assert.Contains(t, output, "vol-1")
|
||||
assert.Contains(t, output, "still attached to i-1")
|
||||
assert.NotContains(t, output, "attachments")
|
||||
}
|
||||
|
||||
// The typed name is the confirmation. Surrounding whitespace is a paste
|
||||
// artefact rather than a mismatch, but an empty line is a cancellation.
|
||||
func TestPromptAccountNameReadsTheConfirmation(t *testing.T) {
|
||||
var name string
|
||||
var err error
|
||||
|
||||
captureOutput(t, func() {
|
||||
feedStdin(t, " tenant@example.com \n", func() {
|
||||
name, err = cmd.PromptAccountName("000000000042")
|
||||
})
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "tenant@example.com", name)
|
||||
}
|
||||
|
||||
func TestPromptAccountNameTreatsAnEmptyReplyAsCancelled(t *testing.T) {
|
||||
var err error
|
||||
|
||||
captureOutput(t, func() {
|
||||
feedStdin(t, "\n", func() {
|
||||
_, err = cmd.PromptAccountName("000000000042")
|
||||
})
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cancelled")
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kvCompareCmd = &cobra.Command{
|
||||
Use: "compare <digest.json>...",
|
||||
Short: "Compare the kv digests of every node and report replicas that have diverged",
|
||||
Long: `Read the "spx admin kv digest --json --seqs" output of every node in the
|
||||
cluster, one file per node ("-" reads any number of them from stdin), and
|
||||
check that every stream's replicas hold the same content.
|
||||
|
||||
A replica that is merely behind is not divergent, so only these fail:
|
||||
|
||||
- the same sequence holds different content on two replicas;
|
||||
- a message held by one replica is missing from another, and nothing on
|
||||
that replica explains it: no later write to the same subject, no expiry
|
||||
under max_age or a message TTL, and no stream limit dropping its head;
|
||||
- a stream is held by fewer nodes than its replica count, or by more;
|
||||
- replicas disagree on the replica count;
|
||||
- no sequence range is common to every replica, so nothing can be checked.
|
||||
|
||||
Sequences past the slowest replica's last one are not compared. Digests are
|
||||
taken from live stores a few seconds apart, so re-run before trusting a
|
||||
failure that could be a write landing between two copies.
|
||||
|
||||
Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runKVCompare,
|
||||
}
|
||||
|
||||
func init() {
|
||||
kvCmd.AddCommand(kvCompareCmd)
|
||||
}
|
||||
|
||||
func runKVCompare(cmd *cobra.Command, args []string) {
|
||||
var nodes []nodeDigest
|
||||
for _, arg := range args {
|
||||
var r io.Reader
|
||||
if arg == "-" {
|
||||
r = cmd.InOrStdin()
|
||||
} else {
|
||||
f, err := os.Open(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
got, err := loadNodeDigests(r, arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
nodes = append(nodes, got...)
|
||||
}
|
||||
res, err := compareNodeDigests(nodes)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
writeCompareResult(cmd.OutOrStdout(), nodes, res)
|
||||
if res.divergent() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadNodeDigests reads every JSON digest document in r.
|
||||
func loadNodeDigests(r io.Reader, source string) ([]nodeDigest, error) {
|
||||
dec := json.NewDecoder(r)
|
||||
var out []nodeDigest
|
||||
for {
|
||||
var d nodeDigest
|
||||
err := dec.Decode(&d)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: document %d: %w", source, len(out)+1, err)
|
||||
}
|
||||
if d.Host == "" {
|
||||
return nil, fmt.Errorf("%s: document %d has no host; is it \"kv digest --json\" output?", source, len(out)+1)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s: no digest documents", source)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxFindingsPerStream caps what one stream prints; the rest are counted.
|
||||
const maxFindingsPerStream = 5
|
||||
|
||||
type streamVerdict struct {
|
||||
Name string
|
||||
Holders []string
|
||||
Problems []string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
type kvCompareResult struct {
|
||||
Streams []streamVerdict
|
||||
}
|
||||
|
||||
func (r kvCompareResult) divergent() bool {
|
||||
return slices.ContainsFunc(r.Streams, func(v streamVerdict) bool { return len(v.Problems) > 0 })
|
||||
}
|
||||
|
||||
// replicaView is one node's copy of one stream, indexed for the per-sequence rules.
|
||||
type replicaView struct {
|
||||
host string
|
||||
copiedAt time.Time
|
||||
d *streamDigest
|
||||
bySeq map[uint64]seqDigest
|
||||
lastBySubject map[string]uint64
|
||||
}
|
||||
|
||||
func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) {
|
||||
if len(nodes) < 2 {
|
||||
return kvCompareResult{}, fmt.Errorf("need the digests of at least two nodes, got %d", len(nodes))
|
||||
}
|
||||
hosts := make([]string, 0, len(nodes))
|
||||
byName := map[string][]*replicaView{}
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if slices.Contains(hosts, n.Host) {
|
||||
return kvCompareResult{}, fmt.Errorf("two digests are from host %s", n.Host)
|
||||
}
|
||||
hosts = append(hosts, n.Host)
|
||||
for j := range n.Streams {
|
||||
d := &n.Streams[j]
|
||||
v := &replicaView{host: n.Host, copiedAt: n.CopiedAt, d: d,
|
||||
bySeq: make(map[uint64]seqDigest, len(d.Seqs)), lastBySubject: map[string]uint64{}}
|
||||
for _, s := range d.Seqs {
|
||||
v.bySeq[s.Seq] = s
|
||||
if s.Hash != deletedHash && s.Seq > v.lastBySubject[s.Subject] {
|
||||
v.lastBySubject[s.Subject] = s.Seq
|
||||
}
|
||||
}
|
||||
byName[d.Name] = append(byName[d.Name], v)
|
||||
}
|
||||
}
|
||||
names := slices.Sorted(maps.Keys(byName))
|
||||
|
||||
var res kvCompareResult
|
||||
for _, name := range names {
|
||||
res.Streams = append(res.Streams, compareStream(name, hosts, byName[name]))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func compareStream(name string, hosts []string, views []*replicaView) streamVerdict {
|
||||
v := streamVerdict{Name: name}
|
||||
for _, r := range views {
|
||||
v.Holders = append(v.Holders, r.host)
|
||||
}
|
||||
|
||||
counts := map[int][]string{}
|
||||
replicas := 0
|
||||
for _, r := range views {
|
||||
if n := r.d.Replicas; n > 0 {
|
||||
counts[n] = append(counts[n], r.host)
|
||||
replicas = max(replicas, n)
|
||||
}
|
||||
}
|
||||
if len(counts) > 1 {
|
||||
var parts []string
|
||||
for n, hs := range counts {
|
||||
parts = append(parts, fmt.Sprintf("%d on %s", n, strings.Join(hs, ",")))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
v.Problems = append(v.Problems, "replicas disagree on the replica count: "+strings.Join(parts, "; "))
|
||||
}
|
||||
switch {
|
||||
case replicas > 0 && len(views) < replicas:
|
||||
missing := slices.DeleteFunc(slices.Clone(hosts), func(h string) bool { return slices.Contains(v.Holders, h) })
|
||||
msg := fmt.Sprintf("held by %d of %d replicas; missing from %s", len(views), replicas, strings.Join(missing, ","))
|
||||
if len(hosts) < replicas {
|
||||
msg += fmt.Sprintf(" (only %d digests given)", len(hosts))
|
||||
}
|
||||
v.Problems = append(v.Problems, msg)
|
||||
case replicas > 0 && len(views) > replicas:
|
||||
v.Problems = append(v.Problems, fmt.Sprintf("held by %d nodes but configured for %d replicas; a node outside the stream's group still holds a copy", len(views), replicas))
|
||||
}
|
||||
if len(views) < 2 {
|
||||
v.Notes = append(v.Notes, "single copy, nothing to compare")
|
||||
return v
|
||||
}
|
||||
|
||||
identical := true
|
||||
for _, r := range views[1:] {
|
||||
if r.d.Digest != views[0].d.Digest || r.d.FirstSeq != views[0].d.FirstSeq || r.d.LastSeq != views[0].d.LastSeq {
|
||||
identical = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if identical {
|
||||
v.Notes = append(v.Notes, fmt.Sprintf("identical, seqs %d-%d", views[0].d.FirstSeq, views[0].d.LastSeq))
|
||||
return v
|
||||
}
|
||||
for _, r := range views {
|
||||
if r.d.Msgs > 0 && len(r.d.Seqs) == 0 {
|
||||
v.Problems = append(v.Problems, "contents differ and "+r.host+"'s digest has no per-sequence detail; re-run digest with --seqs")
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
lo, hi := views[0].d.FirstSeq, views[0].d.LastSeq
|
||||
seqSet := map[uint64]struct{}{}
|
||||
for _, r := range views {
|
||||
lo, hi = max(lo, r.d.FirstSeq), min(hi, r.d.LastSeq)
|
||||
for s := range r.bySeq {
|
||||
seqSet[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
v.Problems = append(v.Problems, "no sequence range is common to every replica, so nothing could be compared")
|
||||
return v
|
||||
}
|
||||
seqs := make([]uint64, 0, len(seqSet))
|
||||
for s := range seqSet {
|
||||
if s <= hi {
|
||||
seqs = append(seqs, s)
|
||||
}
|
||||
}
|
||||
slices.Sort(seqs)
|
||||
|
||||
var problems []string
|
||||
explained := 0
|
||||
for _, seq := range seqs {
|
||||
var present, absent []*replicaView
|
||||
for _, r := range views {
|
||||
if e, ok := r.bySeq[seq]; ok && e.Hash != deletedHash {
|
||||
present = append(present, r)
|
||||
} else {
|
||||
absent = append(absent, r)
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
continue
|
||||
}
|
||||
byHash := map[string][]string{}
|
||||
for _, r := range present {
|
||||
h := r.bySeq[seq].Hash
|
||||
byHash[h] = append(byHash[h], r.host)
|
||||
}
|
||||
if len(byHash) > 1 {
|
||||
var parts []string
|
||||
for h, hs := range byHash {
|
||||
parts = append(parts, strings.Join(hs, ",")+"="+h)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
problems = append(problems, fmt.Sprintf("seq %d %s holds different content: %s", seq, present[0].bySeq[seq].Subject, strings.Join(parts, " ")))
|
||||
continue
|
||||
}
|
||||
msg := present[0].bySeq[seq]
|
||||
for _, r := range absent {
|
||||
if absenceExplained(r, msg) {
|
||||
explained++
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("seq %d %s is on %s but missing from %s, with no later write to that subject, expiry or limit to explain it",
|
||||
seq, msg.Subject, hostsOf(present), r.host))
|
||||
}
|
||||
}
|
||||
if len(problems) > maxFindingsPerStream {
|
||||
problems = append(problems[:maxFindingsPerStream], fmt.Sprintf("... and %d more", len(problems)-maxFindingsPerStream))
|
||||
}
|
||||
v.Problems = append(v.Problems, problems...)
|
||||
|
||||
note := fmt.Sprintf("seqs %d-%d compared", lo, hi)
|
||||
if explained > 0 {
|
||||
note += fmt.Sprintf(", %d superseded or expired messages missing from some replicas", explained)
|
||||
}
|
||||
var ahead []string
|
||||
for _, r := range views {
|
||||
if r.d.LastSeq > hi {
|
||||
ahead = append(ahead, fmt.Sprintf("%s +%d", r.host, r.d.LastSeq-hi))
|
||||
}
|
||||
}
|
||||
if len(ahead) > 0 {
|
||||
note += ", tail not compared: " + strings.Join(ahead, " ")
|
||||
}
|
||||
v.Notes = append(v.Notes, note)
|
||||
return v
|
||||
}
|
||||
|
||||
// absenceExplained reports whether a replica lacking msg removed it legitimately:
|
||||
// the subject was written again later, the message aged out before that replica
|
||||
// was copied, or a stream limit dropped it from a head the replica has moved past.
|
||||
func absenceExplained(r *replicaView, msg seqDigest) bool {
|
||||
if msg.Subject == "" {
|
||||
return false
|
||||
}
|
||||
if r.lastBySubject[msg.Subject] > msg.Seq {
|
||||
return true
|
||||
}
|
||||
meta := r.d.streamMeta
|
||||
if meta.Retention != "" && meta.Retention != "limits" {
|
||||
return true
|
||||
}
|
||||
if !msg.Time.IsZero() {
|
||||
if meta.MaxAge > 0 && msg.Time.Add(meta.MaxAge).Before(r.copiedAt) {
|
||||
return true
|
||||
}
|
||||
if msg.TTL > 0 && msg.Time.Add(msg.TTL).Before(r.copiedAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return msg.Seq < r.d.FirstSeq && (meta.MaxMsgs > 0 || meta.MaxBytes > 0)
|
||||
}
|
||||
|
||||
func hostsOf(views []*replicaView) string {
|
||||
hs := make([]string, len(views))
|
||||
for i, r := range views {
|
||||
hs[i] = r.host
|
||||
}
|
||||
return strings.Join(hs, ",")
|
||||
}
|
||||
|
||||
func writeCompareResult(w io.Writer, nodes []nodeDigest, res kvCompareResult) {
|
||||
fmt.Fprintf(w, "compared %d digests:", len(nodes))
|
||||
for _, n := range nodes {
|
||||
fmt.Fprintf(w, " %s@%s", n.Host, n.CopiedAt.Format(time.TimeOnly))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
bad := 0
|
||||
for _, v := range res.Streams {
|
||||
status := "ok"
|
||||
if len(v.Problems) > 0 {
|
||||
status = "DIVERGED"
|
||||
bad++
|
||||
}
|
||||
fmt.Fprintf(w, "%-9s %s [%s]\n", status, v.Name, strings.Join(v.Holders, ","))
|
||||
for _, p := range v.Problems {
|
||||
fmt.Fprintf(w, " ! %s\n", p)
|
||||
}
|
||||
for _, n := range v.Notes {
|
||||
fmt.Fprintf(w, " %s\n", n)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%d streams: %d consistent, %d diverged\n", len(res.Streams), len(res.Streams)-bad, bad)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
type msg struct {
|
||||
seq uint64
|
||||
subject string
|
||||
hash string // "deleted" for a gap
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type stream struct {
|
||||
name string
|
||||
replicas int
|
||||
maxAge time.Duration
|
||||
maxMsgs int64
|
||||
msgs []msg
|
||||
noSeqs bool
|
||||
}
|
||||
|
||||
// doc renders one node's digest the way "kv digest --json --seqs" prints it.
|
||||
func doc(t *testing.T, host string, copiedAt time.Time, streams ...stream) string {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
for _, s := range streams {
|
||||
var seqs []map[string]any
|
||||
var first, last, count uint64
|
||||
digest := host + ":" // only equality matters to compare
|
||||
for _, m := range s.msgs {
|
||||
if first == 0 {
|
||||
first = m.seq
|
||||
}
|
||||
last = m.seq
|
||||
e := map[string]any{"seq": m.seq, "hash": m.hash}
|
||||
if m.hash != "deleted" {
|
||||
count++
|
||||
e["subject"] = m.subject
|
||||
at := m.at
|
||||
if at.IsZero() {
|
||||
at = t0
|
||||
}
|
||||
e["time"] = at
|
||||
}
|
||||
digest += m.hash + ","
|
||||
seqs = append(seqs, e)
|
||||
}
|
||||
sd := map[string]any{"name": s.name, "first_seq": first, "last_seq": last, "msgs": count, "sha256": digest}
|
||||
if s.replicas > 0 {
|
||||
sd["replicas"] = s.replicas
|
||||
}
|
||||
if s.maxAge > 0 {
|
||||
sd["max_age_ns"] = s.maxAge
|
||||
}
|
||||
if s.maxMsgs > 0 {
|
||||
sd["max_msgs"] = s.maxMsgs
|
||||
}
|
||||
if !s.noSeqs {
|
||||
sd["seqs"] = seqs
|
||||
}
|
||||
out = append(out, sd)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"host": host, "store": "/s", "copied_at": copiedAt, "streams": out})
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func kvs(msgs ...msg) []msg { return msgs }
|
||||
|
||||
func TestKVCompareIdentical(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
|
||||
// The digest field embeds the host, so this exercises the per-seq path too.
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, s))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "1 streams: 1 consistent, 0 diverged")
|
||||
}
|
||||
|
||||
// The prod failure: each node wrote its own copy before formation, so the same
|
||||
// sequence holds different content on different replicas.
|
||||
func TestKVCompareSameSeqDifferentContent(t *testing.T) {
|
||||
a := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "aaaa", t0})}
|
||||
b := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "bbbb", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "node1", t0, a), doc(t, "node2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "DIVERGED KV_acct")
|
||||
assert.Contains(t, report, "seq 1 $KV.acct.000000000002 holds different content: node1=aaaa node2=bbbb")
|
||||
}
|
||||
|
||||
func TestKVCompareMissingMessage(t *testing.T) {
|
||||
full := kvs(msg{1, "k", "h1", t0}, msg{2, "j", "h2", t0}, msg{3, "k", "h3", t0})
|
||||
|
||||
t.Run("superseded by a later write to the subject", func(t *testing.T) {
|
||||
lagging := stream{name: "KV_a", replicas: 2, msgs: full}
|
||||
purged := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "", "deleted", time.Time{}}, full[1], full[2])}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, lagging), doc(t, "n2", t0, purged))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "1 superseded or expired")
|
||||
})
|
||||
|
||||
t.Run("nothing explains it", func(t *testing.T) {
|
||||
have := stream{name: "KV_a", replicas: 2, msgs: full}
|
||||
lost := stream{name: "KV_a", replicas: 2, msgs: kvs(full[0], msg{2, "", "deleted", time.Time{}}, full[2])}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, lost))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "seq 2 j is on n1 but missing from n2")
|
||||
})
|
||||
|
||||
t.Run("aged out under max_age before the replica was copied", func(t *testing.T) {
|
||||
old := t0.Add(-time.Hour)
|
||||
have := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{1, "l", "h1", old}, msg{2, "m", "h2", t0})}
|
||||
gone := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{2, "m", "h2", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
|
||||
have.maxAge, gone.maxAge = 0, 0
|
||||
divergent, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent, "without max_age the same gap is unexplained")
|
||||
})
|
||||
|
||||
t.Run("head dropped by a stream limit", func(t *testing.T) {
|
||||
behind := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{1, "a", "h1", t0}, msg{2, "b", "h2", t0})}
|
||||
ahead := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{2, "b", "h2", t0}, msg{3, "c", "h3", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKVCompareTailLag(t *testing.T) {
|
||||
behind := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
|
||||
ahead := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0}, msg{3, "k", "h3", t0}, msg{4, "k", "h4", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
assert.Contains(t, report, "tail not compared: n2 +2")
|
||||
}
|
||||
|
||||
func TestKVCompareNoCommonRange(t *testing.T) {
|
||||
a := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
b := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{5, "k", "h5", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "no sequence range is common")
|
||||
}
|
||||
|
||||
func TestKVCompareReplicaPlacement(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
other := stream{name: "KV_b", replicas: 1, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
|
||||
t.Run("fewer holders than replicas", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, other))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "held by 2 of 3 replicas; missing from n3")
|
||||
})
|
||||
|
||||
t.Run("more holders than replicas", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0, other))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "held by 2 nodes but configured for 1 replicas")
|
||||
})
|
||||
|
||||
t.Run("replica counts disagree", func(t *testing.T) {
|
||||
r1 := s
|
||||
r1.replicas = 1
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, r1))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "replicas disagree on the replica count")
|
||||
})
|
||||
|
||||
t.Run("single replica stream on one node", func(t *testing.T) {
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent, report)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKVCompareNeedsSeqs(t *testing.T) {
|
||||
a := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
b := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h2", t0})}
|
||||
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, divergent)
|
||||
assert.Contains(t, report, "re-run digest with --seqs")
|
||||
}
|
||||
|
||||
func TestKVCompareInput(t *testing.T) {
|
||||
s := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
|
||||
|
||||
// Several documents in one stream, as "-" reads them from stdin.
|
||||
divergent, _, err := cmd.CompareKVDigests(doc(t, "n1", t0, s) + "\n" + doc(t, "n2", t0, s))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, divergent)
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s))
|
||||
assert.ErrorContains(t, err, "at least two nodes")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n1", t0, s))
|
||||
assert.ErrorContains(t, err, "two digests are from host n1")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(`KV_a first=1 last=1`)
|
||||
assert.Error(t, err, "text digest output is rejected")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(`{"streams":[]}`)
|
||||
assert.ErrorContains(t, err, "no host")
|
||||
|
||||
_, _, err = cmd.CompareKVDigests(" ")
|
||||
assert.ErrorContains(t, err, "no digest documents")
|
||||
}
|
||||
|
||||
func TestAdminInitDiscardsJetStreamByDefault(t *testing.T) {
|
||||
assert.Equal(t, "true", cmd.AdminInitFlagDefault("discard-jetstream"))
|
||||
assert.True(t, strings.HasPrefix(cmd.AdminInitFlagDefault("ipsec"), "true"))
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
// Exercises unexported ochre vector CLI command internals with no
|
||||
// exported surface to drive them through.
|
||||
//
|
||||
//test:in-package
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
handlers_ochrevector "github.com/mulgadc/spinifex/spinifex/handlers/ochrevector"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeVectorService serves scripted responses so the CLI's testable cores
|
||||
// can be exercised without a daemon or a live NATS connection.
|
||||
type fakeVectorService struct {
|
||||
createIndex handlers_ochrevector.CreateIndexResponse
|
||||
createIndexErr error
|
||||
deleteIndexErr error
|
||||
list []handlers_ochrevector.Record
|
||||
listErr error
|
||||
ingest handlers_ochrevector.IngestResponse
|
||||
ingestErr error
|
||||
describeJob handlers_ochrevector.JobRecord
|
||||
describeErr error
|
||||
query []handlers_ochrevector.QueryResult
|
||||
queryErr error
|
||||
listJobs []handlers_ochrevector.JobRecord
|
||||
listJobsErr error
|
||||
stopJob handlers_ochrevector.JobRecord
|
||||
stopJobErr error
|
||||
|
||||
createIndexInputs []*handlers_ochrevector.CreateIndexRequest
|
||||
deleteIndexInputs []*handlers_ochrevector.DeleteIndexRequest
|
||||
ingestInputs []*handlers_ochrevector.IngestRequest
|
||||
queryInputs []*handlers_ochrevector.QueryRequest
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) CreateIndex(_ context.Context, in *handlers_ochrevector.CreateIndexRequest, _ string) (*handlers_ochrevector.CreateIndexResponse, error) {
|
||||
f.createIndexInputs = append(f.createIndexInputs, in)
|
||||
if f.createIndexErr != nil {
|
||||
return nil, f.createIndexErr
|
||||
}
|
||||
out := f.createIndex
|
||||
if out.Index.ID == "" {
|
||||
out.Index.ID = in.IndexID
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) DeleteIndex(_ context.Context, in *handlers_ochrevector.DeleteIndexRequest, _ string) (*handlers_ochrevector.DeleteIndexResponse, error) {
|
||||
f.deleteIndexInputs = append(f.deleteIndexInputs, in)
|
||||
if f.deleteIndexErr != nil {
|
||||
return nil, f.deleteIndexErr
|
||||
}
|
||||
return &handlers_ochrevector.DeleteIndexResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) ListIndexes(_ context.Context, _ *handlers_ochrevector.ListIndexesRequest, _ string) (*handlers_ochrevector.ListIndexesResponse, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return &handlers_ochrevector.ListIndexesResponse{Indexes: f.list}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) Ingest(_ context.Context, in *handlers_ochrevector.IngestRequest, _ string) (*handlers_ochrevector.IngestResponse, error) {
|
||||
f.ingestInputs = append(f.ingestInputs, in)
|
||||
if f.ingestErr != nil {
|
||||
return nil, f.ingestErr
|
||||
}
|
||||
return &f.ingest, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) DescribeJob(_ context.Context, _ *handlers_ochrevector.DescribeJobRequest, _ string) (*handlers_ochrevector.DescribeJobResponse, error) {
|
||||
if f.describeErr != nil {
|
||||
return nil, f.describeErr
|
||||
}
|
||||
return &handlers_ochrevector.DescribeJobResponse{Job: f.describeJob}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) Query(_ context.Context, in *handlers_ochrevector.QueryRequest, _ string) (*handlers_ochrevector.QueryResponse, error) {
|
||||
f.queryInputs = append(f.queryInputs, in)
|
||||
if f.queryErr != nil {
|
||||
return nil, f.queryErr
|
||||
}
|
||||
return &handlers_ochrevector.QueryResponse{Results: f.query}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) ListJobs(_ context.Context, _ *handlers_ochrevector.ListJobsRequest, _ string) (*handlers_ochrevector.ListJobsResponse, error) {
|
||||
if f.listJobsErr != nil {
|
||||
return nil, f.listJobsErr
|
||||
}
|
||||
return &handlers_ochrevector.ListJobsResponse{Jobs: f.listJobs}, nil
|
||||
}
|
||||
|
||||
func (f *fakeVectorService) StopJob(_ context.Context, req *handlers_ochrevector.StopJobRequest, _ string) (*handlers_ochrevector.StopJobResponse, error) {
|
||||
if f.stopJobErr != nil {
|
||||
return nil, f.stopJobErr
|
||||
}
|
||||
job := f.stopJob
|
||||
if job.ID == "" {
|
||||
job.ID = req.JobID
|
||||
}
|
||||
return &handlers_ochrevector.StopJobResponse{Job: job}, nil
|
||||
}
|
||||
|
||||
var _ handlers_ochrevector.VectorService = (*fakeVectorService)(nil)
|
||||
|
||||
const testIndexID = "idx-0123456789abcdef0"
|
||||
|
||||
func TestRunIndexCreate_MintsIDAndPrintsRecord(t *testing.T) {
|
||||
svc := &fakeVectorService{createIndex: handlers_ochrevector.CreateIndexResponse{
|
||||
Index: handlers_ochrevector.Record{Name: "kb1", State: handlers_ochrevector.StateReady, Dimension: 768, EmbeddingModel: "nomic-embed-text-v1.5"},
|
||||
}}
|
||||
|
||||
msg, err := runIndexCreate(context.Background(), svc, "kb1", 768, "nomic-embed-text-v1.5")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, msg, "READY")
|
||||
require.Contains(t, msg, "nomic-embed-text-v1.5")
|
||||
|
||||
require.Len(t, svc.createIndexInputs, 1)
|
||||
require.NotEmpty(t, svc.createIndexInputs[0].IndexID, "the CLI must mint an index ID, not leave it empty")
|
||||
require.Equal(t, 768, svc.createIndexInputs[0].Dimension)
|
||||
}
|
||||
|
||||
func TestRunIndexCreate_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{createIndexErr: errors.New("ValidationException")}
|
||||
_, err := runIndexCreate(context.Background(), svc, "kb1", 768, "m")
|
||||
require.ErrorContains(t, err, "ValidationException")
|
||||
}
|
||||
|
||||
func TestRunIndexDelete_ReportsDeletion(t *testing.T) {
|
||||
svc := &fakeVectorService{}
|
||||
msg, err := runIndexDelete(context.Background(), svc, testIndexID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, msg, testIndexID)
|
||||
require.Len(t, svc.deleteIndexInputs, 1)
|
||||
require.Equal(t, testIndexID, svc.deleteIndexInputs[0].IndexID)
|
||||
}
|
||||
|
||||
func TestRunIndexDelete_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{deleteIndexErr: errors.New("nats: timeout")}
|
||||
_, err := runIndexDelete(context.Background(), svc, testIndexID)
|
||||
require.ErrorContains(t, err, "timeout")
|
||||
}
|
||||
|
||||
func TestListIndexesOutput_NoIndexes(t *testing.T) {
|
||||
msg, err := listIndexesOutput(context.Background(), &fakeVectorService{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "No vector indexes.", msg)
|
||||
}
|
||||
|
||||
func TestListIndexesOutput_ListsIndexes(t *testing.T) {
|
||||
svc := &fakeVectorService{list: []handlers_ochrevector.Record{
|
||||
{ID: testIndexID, Name: "kb1", State: handlers_ochrevector.StateReady, Dimension: 768, EmbeddingModel: "nomic-embed-text-v1.5"},
|
||||
}}
|
||||
msg, err := listIndexesOutput(context.Background(), svc)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, msg, testIndexID)
|
||||
require.Contains(t, msg, "READY")
|
||||
}
|
||||
|
||||
func TestListIndexesOutput_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{listErr: errors.New("nats: no responders")}
|
||||
_, err := listIndexesOutput(context.Background(), svc)
|
||||
require.ErrorContains(t, err, "no responders")
|
||||
}
|
||||
|
||||
func TestRunIngest_NoModelFlagSentByCLI(t *testing.T) {
|
||||
svc := &fakeVectorService{ingest: handlers_ochrevector.IngestResponse{
|
||||
Job: handlers_ochrevector.JobRecord{ID: "job-abc", IndexID: testIndexID, State: handlers_ochrevector.JobStatePending},
|
||||
}}
|
||||
|
||||
msg, err := runIngest(context.Background(), svc, testIndexID, "docs", "kb/", 100, 10, map[string]string{"team": "eng"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, msg, "job-abc")
|
||||
require.Contains(t, msg, "PENDING")
|
||||
|
||||
require.Len(t, svc.ingestInputs, 1)
|
||||
in := svc.ingestInputs[0]
|
||||
require.Equal(t, testIndexID, in.IndexID)
|
||||
require.Equal(t, "docs", in.Source.Bucket)
|
||||
require.Equal(t, "kb/", in.Source.Prefix)
|
||||
require.Equal(t, 100, in.Source.ChunkSize)
|
||||
require.Equal(t, "eng", in.Source.Metadata["team"])
|
||||
// The CLI has no --model flag: the daemon stamps the index's pinned
|
||||
// model server-side, so the request's EmbeddingModel is left zero.
|
||||
require.Empty(t, in.Source.EmbeddingModel)
|
||||
}
|
||||
|
||||
func TestRunIngest_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{ingestErr: errors.New("ResourceNotFoundException")}
|
||||
_, err := runIngest(context.Background(), svc, "no-such-index", "docs", "", 0, 0, nil)
|
||||
require.ErrorContains(t, err, "ResourceNotFoundException")
|
||||
}
|
||||
|
||||
func TestRunJobDescribe_PrintsRecord(t *testing.T) {
|
||||
svc := &fakeVectorService{describeJob: handlers_ochrevector.JobRecord{
|
||||
ID: "job-abc", IndexID: testIndexID, State: handlers_ochrevector.JobStateReady, DocumentsDone: 3, DocumentsTotal: 3,
|
||||
}}
|
||||
out, err := runJobDescribe(context.Background(), svc, "job-abc")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, out, "job-abc")
|
||||
require.Contains(t, out, "3/3")
|
||||
}
|
||||
|
||||
func TestRunJobDescribe_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{describeErr: errors.New("ochrevector: job not found")}
|
||||
_, err := runJobDescribe(context.Background(), svc, "no-such-job")
|
||||
require.ErrorContains(t, err, "job not found")
|
||||
}
|
||||
|
||||
func TestRunQuery_TableTruncatesLongChunk(t *testing.T) {
|
||||
longChunk := strings.Repeat("x", cliChunkPreviewChars+50)
|
||||
svc := &fakeVectorService{query: []handlers_ochrevector.QueryResult{
|
||||
{Chunk: longChunk, SourceKey: "docs/a.txt", SourceOffset: 0, Score: 0.9},
|
||||
}}
|
||||
|
||||
out, err := runQuery(context.Background(), svc, testIndexID, "hello", 5, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, out, "docs/a.txt")
|
||||
require.NotContains(t, out, longChunk, "the table must not print the full untruncated chunk")
|
||||
|
||||
require.Len(t, svc.queryInputs, 1)
|
||||
require.Equal(t, testIndexID, svc.queryInputs[0].IndexID)
|
||||
require.Equal(t, "hello", svc.queryInputs[0].Text)
|
||||
require.Equal(t, 5, svc.queryInputs[0].K)
|
||||
}
|
||||
|
||||
func TestRunQuery_JSONFlagPrintsFullChunk(t *testing.T) {
|
||||
longChunk := strings.Repeat("x", cliChunkPreviewChars+50)
|
||||
svc := &fakeVectorService{query: []handlers_ochrevector.QueryResult{
|
||||
{Chunk: longChunk, SourceKey: "docs/a.txt", Score: 0.9},
|
||||
}}
|
||||
|
||||
out, err := runQuery(context.Background(), svc, testIndexID, "hello", 0, "", true)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, out, longChunk, "--json must print the full, untruncated chunk")
|
||||
}
|
||||
|
||||
func TestRunQuery_NoResults(t *testing.T) {
|
||||
out, err := runQuery(context.Background(), &fakeVectorService{}, testIndexID, "hello", 0, "", false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "No results.", out)
|
||||
}
|
||||
|
||||
func TestRunQuery_FilterFlagReachesRequest(t *testing.T) {
|
||||
svc := &fakeVectorService{}
|
||||
filterJSON := `{"Op":"equals","Key":"category","Value":"faq"}`
|
||||
|
||||
_, err := runQuery(context.Background(), svc, testIndexID, "hello", 0, filterJSON, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, svc.queryInputs, 1)
|
||||
require.NotNil(t, svc.queryInputs[0].Filter)
|
||||
require.Equal(t, handlers_ochrevector.FilterEquals, svc.queryInputs[0].Filter.Op)
|
||||
require.Equal(t, "category", svc.queryInputs[0].Filter.Key)
|
||||
}
|
||||
|
||||
func TestRunQuery_InvalidFilterJSONErrors(t *testing.T) {
|
||||
_, err := runQuery(context.Background(), &fakeVectorService{}, testIndexID, "hello", 0, "{not json", false)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunQuery_ErrorSurfaces(t *testing.T) {
|
||||
svc := &fakeVectorService{queryErr: errors.New("nats: timeout")}
|
||||
_, err := runQuery(context.Background(), svc, testIndexID, "hello", 0, "", false)
|
||||
require.ErrorContains(t, err, "timeout")
|
||||
}
|
||||
|
||||
// withVectorService swaps vectorServiceFn for one returning svc, so a Run
|
||||
// wrapper's real connect/validate/exit control flow runs without a daemon.
|
||||
func withVectorService(t *testing.T, svc handlers_ochrevector.VectorService, connErr error) {
|
||||
t.Helper()
|
||||
orig := vectorServiceFn
|
||||
t.Cleanup(func() { vectorServiceFn = orig })
|
||||
vectorServiceFn = func() (handlers_ochrevector.VectorService, func(), error) {
|
||||
if connErr != nil {
|
||||
return nil, nil, connErr
|
||||
}
|
||||
return svc, func() {}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOchreVectorIndexCreate_PrintsRecord(t *testing.T) {
|
||||
withVectorService(t, &fakeVectorService{createIndex: handlers_ochrevector.CreateIndexResponse{
|
||||
Index: handlers_ochrevector.Record{ID: testIndexID, State: handlers_ochrevector.StateReady},
|
||||
}}, nil)
|
||||
|
||||
cmd := *ochreVectorIndexCreateCmd
|
||||
require.NoError(t, cmd.Flags().Set("name", "kb1"))
|
||||
require.NoError(t, cmd.Flags().Set("dimension", "768"))
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorIndexCreate(&cmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, testIndexID)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorIndexCreate_ConnectFailureExits1(t *testing.T) {
|
||||
withVectorService(t, nil, errors.New("dial nats: connection refused"))
|
||||
|
||||
cmd := *ochreVectorIndexCreateCmd
|
||||
require.NoError(t, cmd.Flags().Set("name", "kb1"))
|
||||
require.NoError(t, cmd.Flags().Set("dimension", "768"))
|
||||
|
||||
code := withOchreExitCapture(t, func() { runOchreVectorIndexCreate(&cmd, nil) })
|
||||
require.Equal(t, 1, code)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorIndexDelete_ReportsDeletion(t *testing.T) {
|
||||
svc := &fakeVectorService{}
|
||||
withVectorService(t, svc, nil)
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorIndexDelete(ochreVectorIndexDeleteCmd, []string{testIndexID}) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, testIndexID)
|
||||
require.Len(t, svc.deleteIndexInputs, 1)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorIndexList_PrintsTable(t *testing.T) {
|
||||
withVectorService(t, &fakeVectorService{list: []handlers_ochrevector.Record{
|
||||
{ID: testIndexID, Name: "kb1", State: handlers_ochrevector.StateReady},
|
||||
}}, nil)
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorIndexList(ochreVectorIndexListCmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, testIndexID)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorIngest_PrintsJobID(t *testing.T) {
|
||||
withVectorService(t, &fakeVectorService{ingest: handlers_ochrevector.IngestResponse{
|
||||
Job: handlers_ochrevector.JobRecord{ID: "job-abc", IndexID: testIndexID, State: handlers_ochrevector.JobStatePending},
|
||||
}}, nil)
|
||||
|
||||
cmd := *ochreVectorIngestCmd
|
||||
require.NoError(t, cmd.Flags().Set("index", testIndexID))
|
||||
require.NoError(t, cmd.Flags().Set("bucket", "docs"))
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorIngest(&cmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, "job-abc")
|
||||
}
|
||||
|
||||
func TestRunOchreVectorJobDescribe_PrintsRecord(t *testing.T) {
|
||||
withVectorService(t, &fakeVectorService{describeJob: handlers_ochrevector.JobRecord{
|
||||
ID: "job-abc", IndexID: testIndexID, State: handlers_ochrevector.JobStateReady,
|
||||
}}, nil)
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorJobDescribe(ochreVectorJobDescribeCmd, []string{"job-abc"}) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, "job-abc")
|
||||
}
|
||||
|
||||
func TestRunOchreVectorQuery_PrintsTable(t *testing.T) {
|
||||
withVectorService(t, &fakeVectorService{query: []handlers_ochrevector.QueryResult{
|
||||
{Chunk: "hello world", SourceKey: "docs/a.txt", Score: 0.9},
|
||||
}}, nil)
|
||||
|
||||
cmd := *ochreVectorQueryCmd
|
||||
require.NoError(t, cmd.Flags().Set("index", testIndexID))
|
||||
require.NoError(t, cmd.Flags().Set("text", "hello"))
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorQuery(&cmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, "docs/a.txt")
|
||||
}
|
||||
|
||||
func TestRunOchreVectorQuery_ConnectFailureExits1(t *testing.T) {
|
||||
withVectorService(t, nil, errors.New("dial nats: connection refused"))
|
||||
|
||||
cmd := *ochreVectorQueryCmd
|
||||
require.NoError(t, cmd.Flags().Set("index", testIndexID))
|
||||
require.NoError(t, cmd.Flags().Set("text", "hello"))
|
||||
|
||||
code := withOchreExitCapture(t, func() { runOchreVectorQuery(&cmd, nil) })
|
||||
require.Equal(t, 1, code)
|
||||
}
|
||||
|
||||
const testBackupAccountID = "123456789012"
|
||||
|
||||
func TestRunBackupAccount_PrintsObjectKeyAndSize(t *testing.T) {
|
||||
backup := func(_ context.Context, accountID string) (*handlers_ochrevector.BackupAccountResponse, error) {
|
||||
require.Equal(t, testBackupAccountID, accountID)
|
||||
return &handlers_ochrevector.BackupAccountResponse{ObjectKey: testBackupAccountID + "/backup.sql.gz", SizeBytes: 4096}, nil
|
||||
}
|
||||
|
||||
msg, err := runBackupAccount(context.Background(), testBackupAccountID, backup)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, msg, testBackupAccountID+"/backup.sql.gz")
|
||||
require.Contains(t, msg, "4096")
|
||||
}
|
||||
|
||||
func TestRunBackupAccount_ErrorSurfaces(t *testing.T) {
|
||||
backup := func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
|
||||
return nil, errors.New("ochrevector: appliance not available")
|
||||
}
|
||||
|
||||
_, err := runBackupAccount(context.Background(), testBackupAccountID, backup)
|
||||
require.ErrorContains(t, err, "not available")
|
||||
}
|
||||
|
||||
func TestRunRestoreAccount_ReportsSuccess(t *testing.T) {
|
||||
var gotAccount, gotKey string
|
||||
restore := func(_ context.Context, accountID, objectKey string) error {
|
||||
gotAccount, gotKey = accountID, objectKey
|
||||
return nil
|
||||
}
|
||||
|
||||
msg, err := runRestoreAccount(context.Background(), testBackupAccountID, testBackupAccountID+"/backup.sql.gz", restore)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testBackupAccountID, gotAccount)
|
||||
require.Equal(t, testBackupAccountID+"/backup.sql.gz", gotKey)
|
||||
require.Contains(t, msg, testBackupAccountID)
|
||||
}
|
||||
|
||||
func TestRunRestoreAccount_ErrorSurfaces(t *testing.T) {
|
||||
restore := func(context.Context, string, string) error {
|
||||
return errors.New("ochrevector: object key does not belong to account")
|
||||
}
|
||||
|
||||
_, err := runRestoreAccount(context.Background(), testBackupAccountID, "wrong/backup.sql.gz", restore)
|
||||
require.ErrorContains(t, err, "does not belong to account")
|
||||
}
|
||||
|
||||
// withBackupAccount swaps backupAccountFn for one that never dials NATS, so
|
||||
// the Run wrapper's flag/exit control flow is exercised without a live
|
||||
// daemon, mirroring withApplianceTeardown.
|
||||
func withBackupAccount(t *testing.T, fn func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error)) {
|
||||
t.Helper()
|
||||
orig := backupAccountFn
|
||||
t.Cleanup(func() { backupAccountFn = orig })
|
||||
backupAccountFn = fn
|
||||
}
|
||||
|
||||
// withRestoreAccount mirrors withBackupAccount for restoreAccountFn.
|
||||
func withRestoreAccount(t *testing.T, fn func(context.Context, string, string) error) {
|
||||
t.Helper()
|
||||
orig := restoreAccountFn
|
||||
t.Cleanup(func() { restoreAccountFn = orig })
|
||||
restoreAccountFn = fn
|
||||
}
|
||||
|
||||
func TestRunOchreVectorBackup_PrintsSuccess(t *testing.T) {
|
||||
withBackupAccount(t, func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
|
||||
return &handlers_ochrevector.BackupAccountResponse{ObjectKey: testBackupAccountID + "/backup.sql.gz", SizeBytes: 1024}, nil
|
||||
})
|
||||
|
||||
cmd := *ochreVectorBackupCmd
|
||||
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorBackup(&cmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, testBackupAccountID+"/backup.sql.gz")
|
||||
}
|
||||
|
||||
func TestRunOchreVectorBackup_ErrorExits1(t *testing.T) {
|
||||
withBackupAccount(t, func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
|
||||
return nil, errors.New("ochrevector: appliance not available")
|
||||
})
|
||||
|
||||
cmd := *ochreVectorBackupCmd
|
||||
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
|
||||
|
||||
code := withOchreExitCapture(t, func() { runOchreVectorBackup(&cmd, nil) })
|
||||
require.Equal(t, 1, code)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorRestore_PrintsSuccess(t *testing.T) {
|
||||
withRestoreAccount(t, func(context.Context, string, string) error { return nil })
|
||||
|
||||
cmd := *ochreVectorRestoreCmd
|
||||
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
|
||||
require.NoError(t, cmd.Flags().Set("from", testBackupAccountID+"/backup.sql.gz"))
|
||||
|
||||
var out string
|
||||
code := withOchreExitCapture(t, func() {
|
||||
out = captureStdout(t, func() { runOchreVectorRestore(&cmd, nil) })
|
||||
})
|
||||
require.Equal(t, -1, code)
|
||||
require.Contains(t, out, testBackupAccountID)
|
||||
}
|
||||
|
||||
func TestRunOchreVectorRestore_ErrorExits1(t *testing.T) {
|
||||
withRestoreAccount(t, func(context.Context, string, string) error {
|
||||
return errors.New("ochrevector: restore requires an object key")
|
||||
})
|
||||
|
||||
cmd := *ochreVectorRestoreCmd
|
||||
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
|
||||
require.NoError(t, cmd.Flags().Set("from", testBackupAccountID+"/backup.sql.gz"))
|
||||
|
||||
code := withOchreExitCapture(t, func() { runOchreVectorRestore(&cmd, nil) })
|
||||
require.Equal(t, 1, code)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,637 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/daemon"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
"github.com/mulgadc/spinifex/spinifex/otelsetup"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// systemIsSystemRunning is overridable in tests so hostIsStopping does not
|
||||
// need a real systemd. systemctl exits non-zero for every state other than
|
||||
// "running", so callers must inspect the output, not just the error.
|
||||
var systemIsSystemRunning = func() (string, error) {
|
||||
out, err := exec.Command("systemctl", "is-system-running").Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// knownSystemStates are the documented systemctl is-system-running values.
|
||||
var knownSystemStates = map[string]bool{
|
||||
"running": true, "degraded": true, "maintenance": true,
|
||||
"stopping": true, "initializing": true, "starting": true, "offline": true,
|
||||
}
|
||||
|
||||
// hostIsStopping reports whether systemd is genuinely unwinding into
|
||||
// shutdown.target (reboot/poweroff/halt/kexec), as opposed to a plain
|
||||
// `systemctl restart/stop spinifex.target` where the host stays up.
|
||||
func hostIsStopping() (bool, error) {
|
||||
out, err := systemIsSystemRunning()
|
||||
|
||||
// systemctl did not run at all, so the state is genuinely unknown. Fail
|
||||
// toward draining: a skipped drain on a real shutdown hard-kills guests,
|
||||
// while a spurious drain only costs a graceful stop/relaunch cycle.
|
||||
if out == "" {
|
||||
return true, fmt.Errorf("systemctl is-system-running produced no output: %w", err)
|
||||
}
|
||||
|
||||
// Unrecognized output is untrusted; an unknown state must never be
|
||||
// mistaken for shutdown.
|
||||
if !knownSystemStates[out] {
|
||||
return false, fmt.Errorf("systemctl is-system-running returned unrecognized state %q", out)
|
||||
}
|
||||
|
||||
return out == "stopping", nil
|
||||
}
|
||||
|
||||
// systemctlListJobs is overridable in tests so stackIsRestarting does not
|
||||
// need a real systemd. Output is one line per pending job in the form
|
||||
// "JOB UNIT TYPE STATE" (systemctl list-jobs --no-legend --no-pager).
|
||||
var systemctlListJobs = func() (string, error) {
|
||||
out, err := exec.Command("systemctl", "list-jobs", "--no-legend", "--no-pager").Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// stackIsRestarting reports whether a pending systemd job shows the spinifex
|
||||
// stack is already coming back: a queued START job for spinifex.target
|
||||
// (its stop leg resolves instantly, so only the start job stays pending
|
||||
// during `systemctl restart`), or a queued RESTART job for
|
||||
// spinifex-shutdown.service itself (PartOf propagates target-to-member
|
||||
// only, so a direct unit restart looks different). No pending jobs is a
|
||||
// clean "not restarting"; output with no recognizable job line is an error.
|
||||
func stackIsRestarting() (bool, error) {
|
||||
out, err := systemctlListJobs()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("systemctl list-jobs: %w", err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(out)
|
||||
if trimmed == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sawParseableLine := false
|
||||
for line := range strings.SplitSeq(trimmed, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
sawParseableLine = true
|
||||
unit, jobType := fields[1], fields[2]
|
||||
if unit == "spinifex.target" && jobType == "start" {
|
||||
return true, nil
|
||||
}
|
||||
if unit == "spinifex-shutdown.service" && jobType == "restart" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if !sawParseableLine {
|
||||
return false, fmt.Errorf("systemctl list-jobs: no parseable job lines in output %q", trimmed)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// shouldDrainOnStop decides whether a spinifex.target stop should drain
|
||||
// local guests. A real host shutdown always drains. Otherwise a plain
|
||||
// target stop drains too, since nothing then guarantees the stack comes
|
||||
// back; only a systemd job proving a restart is already queued skips it.
|
||||
// Any step systemctl cannot answer fails toward draining: a spurious drain
|
||||
// only costs a graceful stop and relaunch, a skipped one costs a guest's
|
||||
// data path.
|
||||
func shouldDrainOnStop() (drain bool, reason string) {
|
||||
stopping, err := hostIsStopping()
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("could not determine host shutdown state, draining: %v", err)
|
||||
}
|
||||
if stopping {
|
||||
return true, "host is shutting down (reboot/poweroff)"
|
||||
}
|
||||
|
||||
restarting, err := stackIsRestarting()
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("could not determine whether the spinifex stack is restarting, draining: %v", err)
|
||||
}
|
||||
if restarting {
|
||||
return false, "a restart of the spinifex stack is already queued"
|
||||
}
|
||||
|
||||
return true, "plain target stop with no restart queued"
|
||||
}
|
||||
|
||||
// runClusterShutdown orchestrates a phased, coordinated shutdown of the cluster.
|
||||
func runClusterShutdown(cmd *cobra.Command, args []string) {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
timeout, _ := cmd.Flags().GetDuration("timeout")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
cfg, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
nodeCount := len(cfg.Nodes)
|
||||
phases := []string{"gate", "drain", "storage", "persist", "infra"}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("Cluster Shutdown Plan (dry-run)")
|
||||
fmt.Printf(" Nodes: %d\n", nodeCount)
|
||||
for name, nodeCfg := range cfg.Nodes {
|
||||
fmt.Printf(" - %s (services: %s)\n", name, strings.Join(nodeCfg.GetServices(), ", "))
|
||||
}
|
||||
fmt.Printf(" Phases: %s\n", strings.Join(phases, " -> "))
|
||||
fmt.Printf(" Timeout per phase: %s\n", timeout)
|
||||
fmt.Printf(" Force: %v\n", force)
|
||||
fmt.Println("\nPhase details:")
|
||||
fmt.Println(" 1. GATE - Stop API gateway and UI, reject new work")
|
||||
fmt.Println(" 2. DRAIN - Gracefully stop all VMs, persist state")
|
||||
fmt.Println(" 3. STORAGE - Stop viperblock, cleanup nbdkit")
|
||||
fmt.Println(" 4. PERSIST - Stop predastore")
|
||||
fmt.Println(" 5. INFRA - Stop NATS, exit daemons")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Starting coordinated cluster shutdown (%d nodes)\n", nodeCount)
|
||||
fmt.Printf("Phases: %s\n", strings.Join(phases, " -> "))
|
||||
fmt.Printf("Timeout per phase: %s\n\n", timeout)
|
||||
|
||||
// Write cluster shutdown marker to KV
|
||||
jsm, err := daemon.NewJetStreamManager(nc)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to create JetStream manager: %v\n", err)
|
||||
} else {
|
||||
if err := jsm.InitClusterStateBucket(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to init cluster state bucket: %v\n", err)
|
||||
} else {
|
||||
state := &daemon.ClusterShutdownState{
|
||||
Initiator: cfg.Node,
|
||||
Phase: "starting",
|
||||
Started: time.Now().UTC().Format(time.RFC3339),
|
||||
Timeout: timeout.String(),
|
||||
Force: force,
|
||||
NodesTotal: nodeCount,
|
||||
NodesAcked: make(map[string]string),
|
||||
}
|
||||
if err := jsm.WriteClusterShutdown(state); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to write shutdown state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Execute phases sequentially (except INFRA which is fire-and-forget)
|
||||
for _, phase := range phases {
|
||||
topic := "spinifex.cluster.shutdown." + phase
|
||||
req := daemon.ShutdownRequest{
|
||||
Phase: phase,
|
||||
Force: force,
|
||||
Timeout: int(timeout.Seconds()),
|
||||
}
|
||||
|
||||
reqData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marshaling request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if phase == "infra" {
|
||||
// INFRA is fire-and-forget — NATS is going down, no ACKs possible
|
||||
fmt.Printf("[INFRA] Sending final shutdown to all nodes...\n")
|
||||
if err := nc.Publish(topic, reqData); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error publishing infra shutdown: %v\n", err)
|
||||
}
|
||||
nc.Flush()
|
||||
// Wait briefly for messages to propagate
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Printf("[INFRA] Complete\n")
|
||||
break
|
||||
}
|
||||
|
||||
// For DRAIN phase, subscribe to progress updates
|
||||
var progressSub *nats.Subscription
|
||||
if phase == "drain" {
|
||||
progressSub, err = nc.Subscribe("spinifex.cluster.shutdown.progress", func(msg *nats.Msg) {
|
||||
var progress daemon.ShutdownProgress
|
||||
if err := json.Unmarshal(msg.Data, &progress); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Printf(" [%s] %s: %d/%d VMs remaining\n", strings.ToUpper(phase), progress.Node, progress.Remaining, progress.Total)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to subscribe to progress: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect ACKs from all nodes
|
||||
phaseStart := time.Now()
|
||||
fmt.Printf("[%s] Sending to %d node(s)...\n", strings.ToUpper(phase), nodeCount)
|
||||
|
||||
acks, err := collectShutdownACKs(nc, topic, reqData, nodeCount, "", timeout)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", strings.ToUpper(phase), err)
|
||||
if !force {
|
||||
fmt.Fprintf(os.Stderr, "Aborting shutdown. Use --force to continue despite errors.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from progress
|
||||
if progressSub != nil {
|
||||
if err := progressSub.Unsubscribe(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to unsubscribe from progress: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Print results
|
||||
for _, ack := range acks {
|
||||
if ack.Error != "" {
|
||||
fmt.Printf(" [%s] %s: ERROR - %s\n", strings.ToUpper(phase), ack.Node, ack.Error)
|
||||
} else if len(ack.Stopped) > 0 {
|
||||
fmt.Printf(" [%s] %s: stopped %s\n", strings.ToUpper(phase), ack.Node, strings.Join(ack.Stopped, ", "))
|
||||
} else {
|
||||
fmt.Printf(" [%s] %s: OK\n", strings.ToUpper(phase), ack.Node)
|
||||
}
|
||||
}
|
||||
|
||||
ackedCount, abort := shutdownPhaseOutcome(phase, acks, nodeCount, force)
|
||||
if abort != "" {
|
||||
fmt.Fprintln(os.Stderr, abort)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] Complete (%d/%d nodes, %s)\n\n", strings.ToUpper(phase), ackedCount, nodeCount, time.Since(phaseStart).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
fmt.Printf("Cluster shutdown complete (%s)\n", time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// runNodeDrainLocal runs the GATE and DRAIN shutdown phases against the local
|
||||
// node only, leaving STORAGE/PERSIST/INFRA to systemd's ordered unit teardown.
|
||||
// It is the ExecStop of spinifex-shutdown.service: guests are powered down and
|
||||
// their volumes unmounted while every service is still up, so the subsequent
|
||||
// service stops never tear storage out from under a running guest.
|
||||
func runNodeDrainLocal(cmd *cobra.Command, args []string) {
|
||||
local, _ := cmd.Flags().GetBool("local")
|
||||
timeout, _ := cmd.Flags().GetDuration("timeout")
|
||||
unlessRestarting, _ := cmd.Flags().GetBool("unless-restarting")
|
||||
// --only-if-host-stopping is a deprecated alias for --unless-restarting;
|
||||
// OR it in so a mixed-version node/unit-file pairing during a partial
|
||||
// upgrade still gets a gate rather than silently always draining.
|
||||
deprecatedOnlyIfHostStopping, _ := cmd.Flags().GetBool("only-if-host-stopping")
|
||||
unlessRestarting = unlessRestarting || deprecatedOnlyIfHostStopping
|
||||
if !local {
|
||||
fmt.Fprintln(os.Stderr, "Error: node drain currently supports only --local")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Unset (an operator running this by hand), the command drains
|
||||
// unconditionally. Set, it drains on a real host shutdown and on a plain
|
||||
// target stop, and skips only when a restart of the stack is already
|
||||
// queued.
|
||||
if unlessRestarting {
|
||||
drain, reason := shouldDrainOnStop()
|
||||
if !drain {
|
||||
fmt.Printf("Skipping guest drain: %s. Guests are left running for the stack to reattach to.\n", reason)
|
||||
warnIfGuestsLeftRunning()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
node := cfg.Node
|
||||
fmt.Printf("Draining local node %q (gate -> drain), timeout %s/phase\n", node, timeout)
|
||||
|
||||
for _, phase := range []string{"gate", "drain"} {
|
||||
topic := "spinifex.cluster.shutdown." + phase
|
||||
req := localDrainRequest(phase, node, timeout)
|
||||
reqData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marshaling request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ack, err := collectLocalShutdownACK(nc, topic, reqData, node, timeout)
|
||||
if err != nil {
|
||||
// Non-zero marks spinifex-shutdown.service failed (visible via
|
||||
// `systemctl --failed`) without blocking the target's teardown;
|
||||
// exiting 0 would hide guests left running as storage vanishes.
|
||||
slog.Error("drain phase failed: no ACK from local daemon before timeout; guests may be left running while storage is about to be torn down",
|
||||
"phase", phase, "node", node, "timeout_ms", otelsetup.Millis(timeout), "error", err)
|
||||
reportGuestsLeftRunning(true, fmt.Sprintf("[%s] drain failed: guests left running with storage about to be torn down", strings.ToUpper(phase)))
|
||||
os.Exit(1)
|
||||
}
|
||||
if ack.Error != "" {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %s: ERROR - %s\n", strings.ToUpper(phase), ack.Node, ack.Error)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(ack.Stopped) > 0 {
|
||||
fmt.Printf("[%s] %s: stopped %s\n", strings.ToUpper(phase), ack.Node, strings.Join(ack.Stopped, ", "))
|
||||
} else {
|
||||
fmt.Printf("[%s] %s: OK\n", strings.ToUpper(phase), ack.Node)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Local node %q drained; systemd may now stop storage services\n", node)
|
||||
}
|
||||
|
||||
// guestEnumerationTimeout bounds how long a skipped drain waits on the
|
||||
// spinifex.node.vms fan-out before giving up. It is intentionally short and
|
||||
// independent of the drain --timeout: this only runs when the drain itself
|
||||
// is being skipped, so it must never itself stall the stop. A var (like
|
||||
// systemIsSystemRunning) so tests do not have to wait out a real timeout.
|
||||
var guestEnumerationTimeout = 3 * time.Second
|
||||
|
||||
// vmStatusRunning mirrors vm.StateRunning's wire value. Not imported directly
|
||||
// to avoid pulling the vm package into the CLI: VMInfo.Status crosses NATS as
|
||||
// a plain string.
|
||||
const vmStatusRunning = "running"
|
||||
|
||||
// reportGuestsLeftRunning names, at the given severity, every guest still
|
||||
// running on this node, reusing the spinifex.node.vms fan-out `spx get vms`
|
||||
// already relies on. Enumeration failures always warn, never escalate.
|
||||
func reportGuestsLeftRunning(severe bool, msg string) {
|
||||
cfg, nc, err := loadConfigAndConnectFn()
|
||||
if err != nil {
|
||||
slog.Warn("could not connect to check for guests left running", "error", err)
|
||||
return
|
||||
}
|
||||
defer nc.Close()
|
||||
node := cfg.Node
|
||||
|
||||
responses, err := collectResponses(nc, "spinifex.node.vms", guestEnumerationTimeout)
|
||||
if err != nil {
|
||||
slog.Warn("could not enumerate local guests", "node", node, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var running []string
|
||||
for _, data := range responses {
|
||||
var resp types.NodeVMsResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Node != node {
|
||||
continue
|
||||
}
|
||||
for _, v := range resp.VMs {
|
||||
if v.Status == vmStatusRunning {
|
||||
running = append(running, v.InstanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(running) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(running)
|
||||
if severe {
|
||||
slog.Error(msg, "node", node, "instances", running)
|
||||
} else {
|
||||
slog.Warn(msg, "node", node, "instances", running)
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfGuestsLeftRunning reports, at WARN level, every guest still running
|
||||
// on this node when the drain gate has just decided to skip a stop because a
|
||||
// restart is already queued.
|
||||
func warnIfGuestsLeftRunning() {
|
||||
reportGuestsLeftRunning(false, "skipped drain leaves guests running unsupervised; they will lose their data path if the spinifex stack does not come back")
|
||||
}
|
||||
|
||||
// localShutdownACKRetryBaseDelay/MaxDelay set the backoff between GATE/DRAIN
|
||||
// ACK attempts: NATS bounces a request instantly when nothing is subscribed
|
||||
// yet, so a single miss right after a daemon restart is not a real failure.
|
||||
var (
|
||||
localShutdownACKRetryBaseDelay = 250 * time.Millisecond
|
||||
localShutdownACKRetryMaxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// localDrainRequest builds a phase request scoped to one node. Target is what
|
||||
// keeps it local: the phase subjects are fan-out, so an untargeted request
|
||||
// gates and drains every node in the cluster off one node's target stop.
|
||||
func localDrainRequest(phase, node string, timeout time.Duration) daemon.ShutdownRequest {
|
||||
return daemon.ShutdownRequest{
|
||||
Phase: phase,
|
||||
Timeout: int(timeout.Seconds()),
|
||||
Target: node,
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownPhaseOutcome decides whether a phase may advance. A node that
|
||||
// answered with an error has not completed the phase, and counting it as done
|
||||
// would tear storage out from under guests that never stopped. Returns the
|
||||
// completed count and, when the run must stop, the message to print.
|
||||
func shutdownPhaseOutcome(phase string, acks []daemon.ShutdownACK, nodeCount int, force bool) (completed int, abort string) {
|
||||
completed, failed := summariseShutdownACKs(acks)
|
||||
if force {
|
||||
return completed, ""
|
||||
}
|
||||
|
||||
if len(failed) > 0 {
|
||||
return completed, fmt.Sprintf("[%s] %d node(s) failed the phase: %s. Use --force to continue.",
|
||||
strings.ToUpper(phase), len(failed), strings.Join(failed, ", "))
|
||||
}
|
||||
if completed < nodeCount {
|
||||
return completed, fmt.Sprintf("[%s] Only %d/%d nodes completed. Use --force to continue.",
|
||||
strings.ToUpper(phase), completed, nodeCount)
|
||||
}
|
||||
return completed, ""
|
||||
}
|
||||
|
||||
// summariseShutdownACKs splits a phase's ACKs into the count that completed
|
||||
// cleanly and the names of the nodes that answered with an error.
|
||||
func summariseShutdownACKs(acks []daemon.ShutdownACK) (completed int, failed []string) {
|
||||
for _, ack := range acks {
|
||||
if ack.Error != "" {
|
||||
failed = append(failed, ack.Node)
|
||||
continue
|
||||
}
|
||||
completed++
|
||||
}
|
||||
return completed, failed
|
||||
}
|
||||
|
||||
// collectShutdownACKsFn indirects collectShutdownACKs so tests can simulate a
|
||||
// slow-to-resubscribe daemon without a real NATS round trip.
|
||||
var collectShutdownACKsFn = collectShutdownACKs
|
||||
|
||||
// collectLocalShutdownACK publishes a shutdown-phase request and returns the
|
||||
// ACK from the local node, retrying with backoff bounded by timeout as a
|
||||
// whole. Returns an error once the budget is exhausted with no ACK.
|
||||
func collectLocalShutdownACK(nc *nats.Conn, topic string, reqData []byte, node string, timeout time.Duration) (daemon.ShutdownACK, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
delay := localShutdownACKRetryBaseDelay
|
||||
attempts := 0
|
||||
loggedRetry := false
|
||||
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
attempts++
|
||||
|
||||
acks, err := collectShutdownACKsFn(nc, topic, reqData, 1, node, remaining)
|
||||
if err != nil {
|
||||
return daemon.ShutdownACK{}, fmt.Errorf("collecting ACK from local node %q: %w", node, err)
|
||||
}
|
||||
if len(acks) > 0 {
|
||||
return acks[0], nil
|
||||
}
|
||||
|
||||
remaining = time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
// Log once, not per attempt: this runs in ExecStop, so its output
|
||||
// lands in the journal on every single stop, and most stops recover
|
||||
// within the first attempt or two.
|
||||
if !loggedRetry {
|
||||
slog.Warn("no ACK yet for local node, retrying (daemon may still be re-subscribing)",
|
||||
"node", node, "topic", topic)
|
||||
loggedRetry = true
|
||||
}
|
||||
|
||||
sleep := min(delay, remaining)
|
||||
time.Sleep(sleep)
|
||||
delay *= 2
|
||||
if delay > localShutdownACKRetryMaxDelay {
|
||||
delay = localShutdownACKRetryMaxDelay
|
||||
}
|
||||
}
|
||||
|
||||
if attempts == 0 {
|
||||
return daemon.ShutdownACK{}, fmt.Errorf("no time budget to wait for local node %q ACK (timeout=%s)", node, timeout)
|
||||
}
|
||||
return daemon.ShutdownACK{}, fmt.Errorf("no ACK from local node %q after %d attempt(s) within %s", node, attempts, timeout)
|
||||
}
|
||||
|
||||
// runClusterDrainDHCP asks every vpcd to DHCPRELEASE all external-pool leases
|
||||
// it currently holds, returning them to the upstream DHCP server. Intended for
|
||||
// the teardown path: an env reset otherwise strands held leases until TTL
|
||||
// because vpcd's Stop() preserves them for adopt-on-reboot. Best-effort — if
|
||||
// the cluster is already partly down it warns and exits 0 so teardown proceeds.
|
||||
func runClusterDrainDHCP(cmd *cobra.Command, args []string) {
|
||||
timeout, _ := cmd.Flags().GetDuration("timeout")
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
_, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp could not connect (cluster may be down): %v\n", err)
|
||||
return
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
total, responders := drainDHCPLeases(nc, timeout)
|
||||
fmt.Printf("DHCP drain complete: released %d lease(s) from %d vpcd responder(s)\n", total, responders)
|
||||
}
|
||||
|
||||
// drainDHCPLeases publishes a drain request to vpc.dhcp.drain and collects
|
||||
// replies until timeout — one responder per AZ (vpcd uses a per-AZ queue
|
||||
// group). Returns the summed released count and the number of responders.
|
||||
func drainDHCPLeases(nc *nats.Conn, timeout time.Duration) (released, responders int) {
|
||||
reqData, err := json.Marshal(struct{}{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
inbox := nats.NewInbox()
|
||||
sub, err := nc.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp subscribe failed: %v\n", err)
|
||||
return 0, 0
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
if err := nc.PublishRequest(dhcp.TopicDrain, inbox, reqData); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp publish failed: %v\n", err)
|
||||
return 0, 0
|
||||
}
|
||||
nc.Flush()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
msg, err := sub.NextMsg(remaining)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var reply struct {
|
||||
Released int `json:"released"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &reply); err != nil {
|
||||
continue
|
||||
}
|
||||
responders++
|
||||
if reply.Error != "" {
|
||||
fmt.Fprintf(os.Stderr, " vpcd drain error: %s\n", reply.Error)
|
||||
continue
|
||||
}
|
||||
released += reply.Released
|
||||
}
|
||||
return released, responders
|
||||
}
|
||||
|
||||
// collectShutdownACKs publishes a shutdown request and collects ACKs from nodes.
|
||||
// When nodeFilter is non-empty, only ACKs from that node count toward nodeCount
|
||||
// and all others are ignored (used by the single-node local-drain path).
|
||||
func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount int, nodeFilter string, timeout time.Duration) ([]daemon.ShutdownACK, error) {
|
||||
inbox := nats.NewInbox()
|
||||
sub, err := nc.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to subscribe to inbox: %w", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
if err := nc.PublishRequest(topic, inbox, reqData); err != nil {
|
||||
return nil, fmt.Errorf("failed to publish request: %w", err)
|
||||
}
|
||||
nc.Flush()
|
||||
|
||||
var acks []daemon.ShutdownACK
|
||||
deadline := time.Now().Add(timeout)
|
||||
for len(acks) < nodeCount {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
msg, err := sub.NextMsg(remaining)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var ack daemon.ShutdownACK
|
||||
if err := json.Unmarshal(msg.Data, &ack); err != nil {
|
||||
continue
|
||||
}
|
||||
if nodeFilter != "" && ack.Node != nodeFilter {
|
||||
continue
|
||||
}
|
||||
acks = append(acks, ack)
|
||||
}
|
||||
return acks, nil
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/daemon"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeDrainResponder subscribes a stand-in vpcd to vpc.dhcp.drain that
|
||||
// replies with the given released count (or error), mirroring
|
||||
// Manager.handleDrainMsg without a real lease store.
|
||||
func fakeDrainResponder(t *testing.T, nc *nats.Conn, released int, errMsg string) {
|
||||
t.Helper()
|
||||
sub, err := nc.Subscribe(dhcp.TopicDrain, func(msg *nats.Msg) {
|
||||
body, _ := json.Marshal(struct {
|
||||
Released int `json:"released"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}{Released: released, Error: errMsg})
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesSumsReleased(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
fakeDrainResponder(t, nc, 7, "")
|
||||
|
||||
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
|
||||
assert.Equal(t, 7, released)
|
||||
assert.Equal(t, 1, responders)
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesReportsResponderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
fakeDrainResponder(t, nc, 0, "list leases for drain: boom")
|
||||
|
||||
// An erroring responder still counts as a responder but contributes
|
||||
// zero released leases.
|
||||
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
|
||||
assert.Equal(t, 0, released)
|
||||
assert.Equal(t, 1, responders)
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesNoResponders(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
// No vpcd subscribed: the collection window elapses with zero replies.
|
||||
released, responders := drainDHCPLeases(nc, 200*time.Millisecond)
|
||||
assert.Equal(t, 0, released)
|
||||
assert.Equal(t, 0, responders)
|
||||
}
|
||||
|
||||
// TestHostIsStopping covers every systemctl is-system-running outcome the
|
||||
// shutdown-drain gate must distinguish: a real shutdown/reboot ("stopping"),
|
||||
// ordinary steady states that must never trigger a drain, unrecognized
|
||||
// output, and a fully unreachable systemctl.
|
||||
func TestHostIsStopping(t *testing.T) {
|
||||
orig := systemIsSystemRunning
|
||||
t.Cleanup(func() { systemIsSystemRunning = orig })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
out string
|
||||
err error
|
||||
wantStopping bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"stopping", "stopping", errors.New("exit status 1"), true, false},
|
||||
{"running", "running", nil, false, false},
|
||||
{"degraded", "degraded", errors.New("exit status 1"), false, false},
|
||||
{"maintenance", "maintenance", errors.New("exit status 1"), false, false},
|
||||
{"unknown output", "banana", nil, false, true},
|
||||
{"systemctl unreachable", "", errors.New("exec: \"systemctl\": executable file not found in $PATH"), true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
systemIsSystemRunning = func() (string, error) { return tc.out, tc.err }
|
||||
stopping, err := hostIsStopping()
|
||||
assert.Equal(t, tc.wantStopping, stopping)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStackIsRestarting covers every systemctl list-jobs shape the
|
||||
// stop-vs-restart gate must distinguish: a queued target restart, a direct
|
||||
// restart of the shutdown unit itself (PartOf propagates target-to-member
|
||||
// only, so that shows up differently), a plain stop with only stop jobs, no
|
||||
// pending jobs at all, an unreachable systemctl, and output with no
|
||||
// parseable job line.
|
||||
func TestStackIsRestarting(t *testing.T) {
|
||||
orig := systemctlListJobs
|
||||
t.Cleanup(func() { systemctlListJobs = orig })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
out string
|
||||
err error
|
||||
wantRestarting bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "target restart queued",
|
||||
out: "1655 spinifex-shutdown.service restart running\n" +
|
||||
"1654 spinifex-other.service restart waiting\n" +
|
||||
"1636 spinifex.target start waiting\n",
|
||||
wantRestarting: true,
|
||||
},
|
||||
{
|
||||
name: "direct restart of shutdown unit",
|
||||
out: "1617 spinifex-shutdown.service restart running\n",
|
||||
wantRestarting: true,
|
||||
},
|
||||
{
|
||||
name: "plain stop, only stop jobs",
|
||||
out: "1617 spinifex-shutdown.service stop running\n" +
|
||||
"1616 spinifex-other.service stop waiting\n",
|
||||
wantRestarting: false,
|
||||
},
|
||||
{
|
||||
name: "no pending jobs",
|
||||
out: "",
|
||||
wantRestarting: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace-only output",
|
||||
out: " \n\t\n",
|
||||
wantRestarting: false,
|
||||
},
|
||||
{
|
||||
name: "systemctl unreachable",
|
||||
out: "",
|
||||
err: errors.New("exec: \"systemctl\": executable file not found in $PATH"),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unparseable output",
|
||||
out: "totally broken\n",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
systemctlListJobs = func() (string, error) { return tc.out, tc.err }
|
||||
restarting, err := stackIsRestarting()
|
||||
assert.Equal(t, tc.wantRestarting, restarting)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldDrainOnStop covers the full stop-vs-restart decision: a real
|
||||
// shutdown short-circuits the job-list check entirely, a plain stop and both
|
||||
// restart shapes route correctly, and every "cannot determine" case fails
|
||||
// toward draining.
|
||||
func TestShouldDrainOnStop(t *testing.T) {
|
||||
origState := systemIsSystemRunning
|
||||
origJobs := systemctlListJobs
|
||||
t.Cleanup(func() {
|
||||
systemIsSystemRunning = origState
|
||||
systemctlListJobs = origJobs
|
||||
})
|
||||
|
||||
t.Run("real shutdown short-circuits list-jobs", func(t *testing.T) {
|
||||
systemIsSystemRunning = func() (string, error) { return "stopping", errors.New("exit status 1") }
|
||||
systemctlListJobs = func() (string, error) {
|
||||
t.Fatal("stackIsRestarting must not run once hostIsStopping is true")
|
||||
return "", nil
|
||||
}
|
||||
drain, reason := shouldDrainOnStop()
|
||||
assert.True(t, drain)
|
||||
assert.NotEmpty(t, reason)
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
state string
|
||||
stateErr error
|
||||
jobs string
|
||||
jobsErr error
|
||||
wantDrain bool
|
||||
}{
|
||||
{
|
||||
name: "plain stop, no restart queued",
|
||||
state: "running",
|
||||
jobs: "1617 spinifex-shutdown.service stop running\n",
|
||||
wantDrain: true,
|
||||
},
|
||||
{
|
||||
name: "target restart queued",
|
||||
state: "running",
|
||||
jobs: "1636 spinifex.target start waiting\n",
|
||||
wantDrain: false,
|
||||
},
|
||||
{
|
||||
name: "direct restart of shutdown unit",
|
||||
state: "running",
|
||||
jobs: "1617 spinifex-shutdown.service restart running\n",
|
||||
wantDrain: false,
|
||||
},
|
||||
{
|
||||
name: "unknown host state",
|
||||
state: "banana",
|
||||
wantDrain: true,
|
||||
},
|
||||
{
|
||||
name: "is-system-running unreachable",
|
||||
state: "",
|
||||
stateErr: errors.New("exec: \"systemctl\": executable file not found in $PATH"),
|
||||
wantDrain: true,
|
||||
},
|
||||
{
|
||||
name: "list-jobs unreachable",
|
||||
state: "running",
|
||||
jobsErr: errors.New("exec: \"systemctl\": executable file not found in $PATH"),
|
||||
wantDrain: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
systemIsSystemRunning = func() (string, error) { return tc.state, tc.stateErr }
|
||||
systemctlListJobs = func() (string, error) { return tc.jobs, tc.jobsErr }
|
||||
drain, reason := shouldDrainOnStop()
|
||||
assert.Equal(t, tc.wantDrain, drain)
|
||||
assert.NotEmpty(t, reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeDrainCmdDeprecatedFlagAlias asserts --only-if-host-stopping still
|
||||
// exists and works (a node running the new unit file against an old spx
|
||||
// binary, or the reverse during a partial upgrade, must not break) but is
|
||||
// marked deprecated in favour of --unless-restarting.
|
||||
func TestNodeDrainCmdDeprecatedFlagAlias(t *testing.T) {
|
||||
unlessRestarting := nodeDrainCmd.Flags().Lookup("unless-restarting")
|
||||
require.NotNil(t, unlessRestarting)
|
||||
assert.Empty(t, unlessRestarting.Deprecated)
|
||||
|
||||
legacy := nodeDrainCmd.Flags().Lookup("only-if-host-stopping")
|
||||
require.NotNil(t, legacy)
|
||||
assert.NotEmpty(t, legacy.Deprecated)
|
||||
}
|
||||
|
||||
// captureSlog swaps the default slog logger for a text handler writing into
|
||||
// a buffer for the duration of fn, then restores it.
|
||||
func captureSlog(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
orig := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(orig) })
|
||||
|
||||
fn()
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TestWarnIfGuestsLeftRunningNamesRunningGuests asserts the skip branch
|
||||
// enumerates via the same spinifex.node.vms fan-out spx get vms uses, warns
|
||||
// at WARN level, and names only the running instance, not the stopped one.
|
||||
func TestWarnIfGuestsLeftRunningNamesRunningGuests(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
stubConnect(t, &config.ClusterConfig{Node: "node1"}, nc, nil)
|
||||
|
||||
sub, err := nc.Subscribe("spinifex.node.vms", func(msg *nats.Msg) {
|
||||
resp := types.NodeVMsResponse{
|
||||
Node: "node1",
|
||||
VMs: []types.VMInfo{
|
||||
{InstanceID: "i-running", Status: vmStatusRunning},
|
||||
{InstanceID: "i-stopped", Status: "stopped"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
out := captureSlog(t, warnIfGuestsLeftRunning)
|
||||
|
||||
assert.Contains(t, out, "i-running")
|
||||
assert.NotContains(t, out, "i-stopped")
|
||||
assert.Contains(t, out, "WARN")
|
||||
}
|
||||
|
||||
// TestWarnIfGuestsLeftRunningNoGuestsIsSilent asserts a node with no
|
||||
// running guests produces no warning.
|
||||
func TestWarnIfGuestsLeftRunningNoGuestsIsSilent(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
stubConnect(t, &config.ClusterConfig{Node: "node1"}, nc, nil)
|
||||
|
||||
sub, err := nc.Subscribe("spinifex.node.vms", func(msg *nats.Msg) {
|
||||
resp := types.NodeVMsResponse{
|
||||
Node: "node1",
|
||||
VMs: []types.VMInfo{{InstanceID: "i-stopped", Status: "stopped"}},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
out := captureSlog(t, warnIfGuestsLeftRunning)
|
||||
assert.Empty(t, out)
|
||||
}
|
||||
|
||||
// TestWarnIfGuestsLeftRunningConnectFailureWarnsNotFails asserts that a
|
||||
// failure to even connect for enumeration is itself warned about rather
|
||||
// than treated as fatal — a skipped drain must never block the stop.
|
||||
func TestWarnIfGuestsLeftRunningConnectFailureWarnsNotFails(t *testing.T) {
|
||||
stubConnect(t, nil, nil, errors.New("connect refused"))
|
||||
|
||||
out := captureSlog(t, warnIfGuestsLeftRunning)
|
||||
assert.Contains(t, out, "could not connect")
|
||||
}
|
||||
|
||||
// stubCollectShutdownACKsFn swaps collectShutdownACKsFn for fn and restores
|
||||
// the original on cleanup, so collectLocalShutdownACK's retry loop can be
|
||||
// driven deterministically without a real NATS round trip.
|
||||
func stubCollectShutdownACKsFn(t *testing.T, fn func(nc *nats.Conn, topic string, reqData []byte, nodeCount int, nodeFilter string, timeout time.Duration) ([]daemon.ShutdownACK, error)) {
|
||||
t.Helper()
|
||||
orig := collectShutdownACKsFn
|
||||
t.Cleanup(func() { collectShutdownACKsFn = orig })
|
||||
collectShutdownACKsFn = fn
|
||||
}
|
||||
|
||||
// shrinkLocalShutdownACKBackoff overrides the GATE/DRAIN retry backoff to
|
||||
// millisecond-scale values for the duration of the test so retry tests do
|
||||
// not pay for the real 250ms-2s production backoff.
|
||||
func shrinkLocalShutdownACKBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
origBase, origCap := localShutdownACKRetryBaseDelay, localShutdownACKRetryMaxDelay
|
||||
localShutdownACKRetryBaseDelay = time.Millisecond
|
||||
localShutdownACKRetryMaxDelay = 5 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
localShutdownACKRetryBaseDelay = origBase
|
||||
localShutdownACKRetryMaxDelay = origCap
|
||||
})
|
||||
}
|
||||
|
||||
// TestLocalDrainRequestIsNodeScoped pins the field that keeps a local drain
|
||||
// local. The phase subjects are fan-out, so an empty Target here drains the
|
||||
// guests on every node in the cluster, not just the one being stopped.
|
||||
func TestLocalDrainRequestIsNodeScoped(t *testing.T) {
|
||||
req := localDrainRequest("drain", "node2", 90*time.Second)
|
||||
|
||||
assert.Equal(t, "node2", req.Target, "a local drain must name the node it applies to")
|
||||
assert.Equal(t, "drain", req.Phase)
|
||||
assert.Equal(t, 90, req.Timeout)
|
||||
}
|
||||
|
||||
// TestSummariseShutdownACKs covers phase accounting by outcome: a node that
|
||||
// answered with an error has not completed the phase, and counting it as done
|
||||
// advances the shutdown into STORAGE with guests still running.
|
||||
func TestSummariseShutdownACKs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acks []daemon.ShutdownACK
|
||||
completed int
|
||||
failed []string
|
||||
}{
|
||||
{
|
||||
name: "all clean",
|
||||
acks: []daemon.ShutdownACK{{Node: "node1"}, {Node: "node2"}},
|
||||
completed: 2,
|
||||
},
|
||||
{
|
||||
name: "one errored is not counted as completed",
|
||||
acks: []daemon.ShutdownACK{
|
||||
{Node: "node1"},
|
||||
{Node: "node2", Error: "failed to stop VMs"},
|
||||
{Node: "node3"},
|
||||
},
|
||||
completed: 2,
|
||||
failed: []string{"node2"},
|
||||
},
|
||||
{
|
||||
name: "all errored",
|
||||
acks: []daemon.ShutdownACK{{Node: "node1", Error: "boom"}, {Node: "node2", Error: "boom"}},
|
||||
completed: 0,
|
||||
failed: []string{"node1", "node2"},
|
||||
},
|
||||
{
|
||||
name: "no acks",
|
||||
acks: nil,
|
||||
completed: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
completed, failed := summariseShutdownACKs(tt.acks)
|
||||
assert.Equal(t, tt.completed, completed)
|
||||
assert.Equal(t, tt.failed, failed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShutdownPhaseOutcome covers the gate between phases. Advancing past a
|
||||
// node that failed to drain is what tears storage out from under a guest that
|
||||
// never stopped, so only --force may do it.
|
||||
func TestShutdownPhaseOutcome(t *testing.T) {
|
||||
clean := []daemon.ShutdownACK{{Node: "node1"}, {Node: "node2"}, {Node: "node3"}}
|
||||
mixed := []daemon.ShutdownACK{{Node: "node1"}, {Node: "node2", Error: "failed to stop VMs"}, {Node: "node3"}}
|
||||
|
||||
t.Run("every node clean advances", func(t *testing.T) {
|
||||
completed, abort := shutdownPhaseOutcome("drain", clean, 3, false)
|
||||
assert.Equal(t, 3, completed)
|
||||
assert.Empty(t, abort)
|
||||
})
|
||||
|
||||
t.Run("an errored node aborts and is named", func(t *testing.T) {
|
||||
completed, abort := shutdownPhaseOutcome("drain", mixed, 3, false)
|
||||
assert.Equal(t, 2, completed)
|
||||
assert.Contains(t, abort, "node2")
|
||||
assert.Contains(t, abort, "failed the phase")
|
||||
assert.Contains(t, abort, "--force")
|
||||
})
|
||||
|
||||
t.Run("a silent node aborts on the count", func(t *testing.T) {
|
||||
completed, abort := shutdownPhaseOutcome("storage", clean, 5, false)
|
||||
assert.Equal(t, 3, completed)
|
||||
assert.Contains(t, abort, "3/5")
|
||||
})
|
||||
|
||||
t.Run("force advances past both", func(t *testing.T) {
|
||||
completed, abort := shutdownPhaseOutcome("drain", mixed, 5, true)
|
||||
assert.Equal(t, 2, completed)
|
||||
assert.Empty(t, abort, "--force is the documented way to continue despite errors")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCollectLocalShutdownACKRetriesUntilACK covers the actual bug: the
|
||||
// daemon may not have re-subscribed right after a restart, so the first
|
||||
// attempt(s) get no ACK. The ACK on the third attempt must still succeed.
|
||||
func TestCollectLocalShutdownACKRetriesUntilACK(t *testing.T) {
|
||||
shrinkLocalShutdownACKBackoff(t)
|
||||
|
||||
var calls int
|
||||
stubCollectShutdownACKsFn(t, func(nc *nats.Conn, topic string, reqData []byte, nodeCount int, nodeFilter string, timeout time.Duration) ([]daemon.ShutdownACK, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return nil, nil // daemon not re-subscribed yet: no ACK, no error
|
||||
}
|
||||
return []daemon.ShutdownACK{{Node: nodeFilter, Phase: "gate"}}, nil
|
||||
})
|
||||
|
||||
ack, err := collectLocalShutdownACK(nil, "spinifex.cluster.shutdown.gate", []byte(`{}`), "node1", time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "node1", ack.Node)
|
||||
assert.Equal(t, 3, calls)
|
||||
}
|
||||
|
||||
// TestCollectLocalShutdownACKExhaustedReturnsWithinBudget covers a daemon
|
||||
// that never re-subscribes: the call must return promptly once the budget
|
||||
// elapses, not hang, with a non-nil error so the failure path fires.
|
||||
func TestCollectLocalShutdownACKExhaustedReturnsWithinBudget(t *testing.T) {
|
||||
shrinkLocalShutdownACKBackoff(t)
|
||||
|
||||
var calls int
|
||||
stubCollectShutdownACKsFn(t, func(nc *nats.Conn, topic string, reqData []byte, nodeCount int, nodeFilter string, timeout time.Duration) ([]daemon.ShutdownACK, error) {
|
||||
calls++
|
||||
return nil, nil // daemon never comes back within the budget
|
||||
})
|
||||
|
||||
budget := 60 * time.Millisecond
|
||||
start := time.Now()
|
||||
ack, err := collectLocalShutdownACK(nil, "spinifex.cluster.shutdown.gate", []byte(`{}`), "node1", budget)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, daemon.ShutdownACK{}, ack)
|
||||
assert.Contains(t, err.Error(), "node1")
|
||||
assert.Greater(t, calls, 1, "must have retried, not failed on the first miss")
|
||||
// The retry loop must not run appreciably past the configured budget.
|
||||
assert.Less(t, elapsed, budget+150*time.Millisecond)
|
||||
}
|
||||
|
||||
// TestReportGuestsLeftRunningSevereLogsErrorAndNamesGuests covers the
|
||||
// "drain failed" path sharing warnIfGuestsLeftRunning's enumeration: severe
|
||||
// must log at ERROR, not WARN, and still name only the running instance.
|
||||
func TestReportGuestsLeftRunningSevereLogsErrorAndNamesGuests(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
stubConnect(t, &config.ClusterConfig{Node: "node1"}, nc, nil)
|
||||
|
||||
sub, err := nc.Subscribe("spinifex.node.vms", func(msg *nats.Msg) {
|
||||
resp := types.NodeVMsResponse{
|
||||
Node: "node1",
|
||||
VMs: []types.VMInfo{
|
||||
{InstanceID: "i-abandoned", Status: vmStatusRunning},
|
||||
{InstanceID: "i-stopped", Status: "stopped"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
out := captureSlog(t, func() {
|
||||
reportGuestsLeftRunning(true, "drain failed: guests left running with storage about to be torn down")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "ERROR")
|
||||
assert.Contains(t, out, "i-abandoned")
|
||||
assert.NotContains(t, out, "i-stopped")
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//test:in-package — the file exists to expose unexported identifiers to the
|
||||
// external cmd_test package, which it can only do from inside cmd.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// Test hooks for the external cmd_test package.
|
||||
|
||||
// OpenFormationPort exposes openFormationPort for testing.
|
||||
var OpenFormationPort = openFormationPort
|
||||
|
||||
// SetFirewallApplyHelper points the firewall helper at a stub and returns the
|
||||
// function that restores it.
|
||||
func SetFirewallApplyHelper(path string) func() {
|
||||
orig := firewallApplyHelper
|
||||
firewallApplyHelper = path
|
||||
return func() { firewallApplyHelper = orig }
|
||||
}
|
||||
|
||||
// InitCLILogger exposes initCLILogger for testing.
|
||||
var InitCLILogger = initCLILogger
|
||||
|
||||
// OutputStylingEnabled, IsTerminal and SetOutputStyling expose the spx colour guard.
|
||||
var (
|
||||
OutputStylingEnabled = outputStylingEnabled
|
||||
IsTerminal = isTerminal
|
||||
SetOutputStyling = setOutputStyling
|
||||
)
|
||||
|
||||
// CLILogLevel exposes the CLI logger's level var for testing.
|
||||
var CLILogLevel = cliLogLevel
|
||||
|
||||
// SetVerboseFlag sets the root --verbose flag and returns the function that
|
||||
// restores it.
|
||||
func SetVerboseFlag(v bool) func() {
|
||||
flag := rootCmd.PersistentFlags().Lookup("verbose")
|
||||
orig := flag.Value.String()
|
||||
_ = flag.Value.Set(strconv.FormatBool(v))
|
||||
return func() { _ = flag.Value.Set(orig) }
|
||||
}
|
||||
|
||||
// TrustsWholeAccount exposes trustsWholeAccount for testing.
|
||||
var TrustsWholeAccount = trustsWholeAccount
|
||||
|
||||
// LocalGatewayEndpoint exposes localGatewayEndpoint for testing.
|
||||
var LocalGatewayEndpoint = localGatewayEndpoint
|
||||
|
||||
// NewClientToken exposes newClientToken for testing.
|
||||
var NewClientToken = newClientToken
|
||||
|
||||
// AdminHTTPClient exposes adminHTTPClient for testing.
|
||||
var AdminHTTPClient = adminHTTPClient
|
||||
|
||||
// CreateAccountRemote exposes createAccountRemote for testing.
|
||||
var CreateAccountRemote = createAccountRemote
|
||||
|
||||
// AdminTarget builds the unexported target struct for testing.
|
||||
func AdminTarget(endpoint, region, caBundle string) adminTarget {
|
||||
return adminTarget{endpoint: endpoint, region: region, caBundle: caBundle}
|
||||
}
|
||||
|
||||
// DecodeAdminError exposes decodeAdminError for testing.
|
||||
var DecodeAdminError = decodeAdminError
|
||||
|
||||
// RetryableAdminError reports whether the CLI would suggest retrying err.
|
||||
func RetryableAdminError(err error) bool {
|
||||
var adminErr *adminError
|
||||
return errors.As(err, &adminErr) && retryableAdminErrors[adminErr.Code]
|
||||
}
|
||||
|
||||
// ConsoleRegion exposes consoleRegion for testing.
|
||||
var ConsoleRegion = consoleRegion
|
||||
|
||||
// DeleteAccountRemote exposes deleteAccountRemote for testing.
|
||||
var DeleteAccountRemote = deleteAccountRemote
|
||||
|
||||
// DescribeAccountDeletionRemote exposes describeAccountDeletionRemote for testing.
|
||||
var DescribeAccountDeletionRemote = describeAccountDeletionRemote
|
||||
|
||||
// FollowAccountDeletion exposes followAccountDeletion for testing.
|
||||
var FollowAccountDeletion = followAccountDeletion
|
||||
|
||||
// SetAccountDeletePollInterval shortens the follow loop's poll and returns the
|
||||
// function that restores it, so a test does not wait an operator's interval.
|
||||
func SetAccountDeletePollInterval(d time.Duration) func() {
|
||||
orig := accountDeletePollInterval
|
||||
accountDeletePollInterval = d
|
||||
return func() { accountDeletePollInterval = orig }
|
||||
}
|
||||
|
||||
// PrintTeardownPlan exposes printTeardownPlan for testing.
|
||||
var PrintTeardownPlan = printTeardownPlan
|
||||
|
||||
// PrintTeardownResult exposes printTeardownResult for testing.
|
||||
var PrintTeardownResult = printTeardownResult
|
||||
|
||||
// PromptAccountName exposes promptAccountName for testing.
|
||||
var PromptAccountName = promptAccountName
|
||||
|
||||
// ResolvePrincipalGrants exposes resolvePrincipalGrants for testing.
|
||||
var ResolvePrincipalGrants = resolvePrincipalGrants
|
||||
|
||||
// PrincipalPolicyDocument exposes principalPolicyDocument for testing.
|
||||
var PrincipalPolicyDocument = principalPolicyDocument
|
||||
|
||||
// PrincipalPolicyActions exposes principalPolicyActions for testing.
|
||||
var PrincipalPolicyActions = principalPolicyActions
|
||||
|
||||
// ValidatePrincipalName exposes validatePrincipalName for testing.
|
||||
var ValidatePrincipalName = validatePrincipalName
|
||||
|
||||
// AdminPrincipalPolicyName exposes the inline policy name for testing.
|
||||
const AdminPrincipalPolicyName = adminPrincipalPolicyName
|
||||
|
||||
// PrincipalRowSpec mirrors the unexported listing row so the external test
|
||||
// package can build one.
|
||||
type PrincipalRowSpec struct {
|
||||
UserName string
|
||||
Grants []string
|
||||
Keys int
|
||||
}
|
||||
|
||||
// DropStalePrincipalPolicies exposes dropStalePrincipalPolicies for testing.
|
||||
var DropStalePrincipalPolicies = dropStalePrincipalPolicies
|
||||
|
||||
// RevokePrincipalKeys exposes revokePrincipalKeys for testing.
|
||||
var RevokePrincipalKeys = revokePrincipalKeys
|
||||
|
||||
// DescribePrincipal exposes describePrincipal for testing.
|
||||
func DescribePrincipal(svc handlers_iam.IAMService, accountID, userName string) (PrincipalRowSpec, error) {
|
||||
row, err := describePrincipal(svc, accountID, userName)
|
||||
return PrincipalRowSpec(row), err
|
||||
}
|
||||
|
||||
// PrintPrincipalTable exposes printPrincipalTable for testing.
|
||||
func PrintPrincipalTable(accountID string, specs []PrincipalRowSpec) {
|
||||
rows := make([]principalRow, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
rows = append(rows, principalRow(spec))
|
||||
}
|
||||
printPrincipalTable(accountID, rows)
|
||||
}
|
||||
|
||||
// PrintAccountTable exposes printAccountTable for testing.
|
||||
var PrintAccountTable = printAccountTable
|
||||
|
||||
// AccountSummaries exposes accountSummaries for testing.
|
||||
var AccountSummaries = accountSummaries
|
||||
|
||||
// CompareKVDigests runs "kv compare" over digest JSON documents and returns
|
||||
// whether any stream diverged, plus the printed report.
|
||||
func CompareKVDigests(docs ...string) (bool, string, error) {
|
||||
var nodes []nodeDigest
|
||||
for i, doc := range docs {
|
||||
got, err := loadNodeDigests(strings.NewReader(doc), "doc"+strconv.Itoa(i))
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
nodes = append(nodes, got...)
|
||||
}
|
||||
res, err := compareNodeDigests(nodes)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeCompareResult(&buf, nodes, res)
|
||||
return res.divergent(), buf.String(), nil
|
||||
}
|
||||
|
||||
// AdminInitFlagDefault returns the registered default of an admin init flag.
|
||||
func AdminInitFlagDefault(name string) string {
|
||||
return adminInitCmd.Flags().Lookup(name).DefValue
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConsoleRegion_ReadsTheServingNode(t *testing.T) {
|
||||
cfg := &config.ClusterConfig{
|
||||
Node: "node2",
|
||||
Nodes: map[string]config.Config{
|
||||
"node1": {Region: "us-east-1"},
|
||||
"node2": {Region: "us-west-1"},
|
||||
},
|
||||
}
|
||||
|
||||
region, err := cmd.ConsoleRegion(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "us-west-1", region)
|
||||
}
|
||||
|
||||
// Serving a default would sign with a region awsgw does not verify against.
|
||||
func TestConsoleRegion_FailsWhenNodeHasNoRegion(t *testing.T) {
|
||||
cfg := &config.ClusterConfig{
|
||||
Node: "node1",
|
||||
Nodes: map[string]config.Config{"node1": {}},
|
||||
}
|
||||
|
||||
_, err := cmd.ConsoleRegion(cfg)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "node1")
|
||||
}
|
||||
|
||||
func TestConsoleRegion_FailsWhenNodeIsAbsent(t *testing.T) {
|
||||
_, err := cmd.ConsoleRegion(&config.ClusterConfig{Node: "ghost"})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/northstar"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubNorthstarService struct {
|
||||
starts int
|
||||
startErr error
|
||||
}
|
||||
|
||||
func (s *stubNorthstarService) Start() (int, error) {
|
||||
s.starts++
|
||||
return 0, s.startErr
|
||||
}
|
||||
|
||||
func TestRunNorthstarStartActivation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nodePath string
|
||||
override string
|
||||
wantPath string
|
||||
wantConfigured bool
|
||||
}{
|
||||
{
|
||||
name: "unconfigured node is skipped",
|
||||
},
|
||||
{
|
||||
name: "node config starts service",
|
||||
nodePath: "/etc/spinifex/northstar/northstar.toml",
|
||||
wantPath: "/etc/spinifex/northstar/northstar.toml",
|
||||
wantConfigured: true,
|
||||
},
|
||||
{
|
||||
name: "override starts service",
|
||||
override: "/run/spinifex/northstar.toml",
|
||||
wantPath: "/run/spinifex/northstar.toml",
|
||||
wantConfigured: true,
|
||||
},
|
||||
{
|
||||
name: "override takes precedence",
|
||||
nodePath: "/etc/spinifex/northstar/northstar.toml",
|
||||
override: "/run/spinifex/northstar.toml",
|
||||
wantPath: "/run/spinifex/northstar.toml",
|
||||
wantConfigured: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clusterConfig := &config.ClusterConfig{
|
||||
Node: "node1",
|
||||
Nodes: map[string]config.Config{
|
||||
"node1": {
|
||||
BaseDir: "/var/lib/spinifex/northstar",
|
||||
Northstar: config.NorthstarConfig{ConfigPath: tt.nodePath},
|
||||
},
|
||||
},
|
||||
}
|
||||
stub := &stubNorthstarService{}
|
||||
bootstrapCalls := 0
|
||||
factoryCalls := 0
|
||||
var startedConfig *northstar.Config
|
||||
deps := northstarStartDependencies{
|
||||
loadConfig: func(string) (*config.ClusterConfig, error) {
|
||||
return clusterConfig, nil
|
||||
},
|
||||
bootstrapBaseZone: func(path string, cfg *config.ClusterConfig) error {
|
||||
bootstrapCalls++
|
||||
assert.Equal(t, tt.wantPath, path)
|
||||
assert.Same(t, clusterConfig, cfg)
|
||||
return nil
|
||||
},
|
||||
newService: func(cfg *northstar.Config) (northstarStarter, error) {
|
||||
factoryCalls++
|
||||
startedConfig = cfg
|
||||
return stub, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runNorthstarStart(northstarStartOptions{
|
||||
configFile: "/etc/spinifex/spinifex.toml",
|
||||
configOverride: tt.override,
|
||||
}, deps)
|
||||
require.NoError(t, err)
|
||||
|
||||
if !tt.wantConfigured {
|
||||
assert.Zero(t, bootstrapCalls)
|
||||
assert.Zero(t, factoryCalls)
|
||||
assert.Zero(t, stub.starts)
|
||||
assert.Nil(t, startedConfig)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, 1, bootstrapCalls)
|
||||
assert.Equal(t, 1, factoryCalls)
|
||||
assert.Equal(t, 1, stub.starts)
|
||||
require.NotNil(t, startedConfig)
|
||||
assert.Equal(t, tt.wantPath, startedConfig.ConfigPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiredClusterConfigRejectsMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "empty path"},
|
||||
{name: "missing path", path: t.TempDir() + "/missing.toml"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := loadRequiredClusterConfig(tt.path)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNorthstarStartReturnsLoadFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loadErr := errors.New("cluster config unavailable")
|
||||
deps := northstarStartDependencies{
|
||||
loadConfig: func(string) (*config.ClusterConfig, error) {
|
||||
return nil, loadErr
|
||||
},
|
||||
}
|
||||
|
||||
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
|
||||
require.ErrorIs(t, err, loadErr)
|
||||
}
|
||||
|
||||
func TestRunNorthstarStartContinuesAfterBootstrapFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bootstrapErr := errors.New("predastore unavailable")
|
||||
stub := &stubNorthstarService{}
|
||||
deps := northstarStartDependencies{
|
||||
loadConfig: func(string) (*config.ClusterConfig, error) {
|
||||
return &config.ClusterConfig{
|
||||
Node: "node1",
|
||||
Nodes: map[string]config.Config{
|
||||
"node1": {Northstar: config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar/northstar.toml"}},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
bootstrapBaseZone: func(string, *config.ClusterConfig) error { return bootstrapErr },
|
||||
newService: func(*northstar.Config) (northstarStarter, error) {
|
||||
return stub, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, stub.starts)
|
||||
}
|
||||
|
||||
func TestRunNorthstarStartRejectsMissingNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := northstarStartDependencies{
|
||||
loadConfig: func(string) (*config.ClusterConfig, error) {
|
||||
return &config.ClusterConfig{Node: "missing", Nodes: map[string]config.Config{}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
|
||||
require.EqualError(t, err, `node "missing" not found in cluster config`)
|
||||
}
|
||||
|
||||
func TestRunNorthstarStartReturnsConfiguredFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startErr := errors.New("configured startup failed")
|
||||
stub := &stubNorthstarService{startErr: startErr}
|
||||
deps := northstarStartDependencies{
|
||||
loadConfig: func(string) (*config.ClusterConfig, error) {
|
||||
return &config.ClusterConfig{
|
||||
Node: "node1",
|
||||
Nodes: map[string]config.Config{
|
||||
"node1": {Northstar: config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar/northstar.toml"}},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
bootstrapBaseZone: func(string, *config.ClusterConfig) error { return nil },
|
||||
newService: func(*northstar.Config) (northstarStarter, error) {
|
||||
return stub, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
|
||||
require.ErrorIs(t, err, startErr)
|
||||
assert.Equal(t, 1, stub.starts)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInitTelemetryNoEndpoint(t *testing.T) {
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
debug bool
|
||||
}{
|
||||
{"info level", false},
|
||||
{"debug level", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
flush := initTelemetry("test-svc", tt.debug)
|
||||
if flush == nil {
|
||||
t.Fatal("initTelemetry returned nil flush func")
|
||||
}
|
||||
flush()
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user