mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 20:57:02 +00:00
Compare commits
377 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5342f78abe | |||
| ce0c75c788 | |||
| af19f3f5c1 | |||
| 976df15d7f | |||
| 34d88507a6 | |||
| 96404d2d05 | |||
| 09646388ed | |||
| c419fb3490 | |||
| 0368e861a9 | |||
| 87cfc96335 | |||
| 254d898a3f | |||
| 6d0eddf0a4 | |||
| 3fdaa54d99 | |||
| 947ca45245 | |||
| 263c581678 | |||
| a130f6b8ee | |||
| a8233794ab | |||
| 7bf6c10d79 | |||
| eb53eded04 | |||
| e40e699e5f | |||
| c3f302423e | |||
| 0d7b4266d3 | |||
| c6fdc239ec | |||
| c617914a1a | |||
| 8db9771fbf | |||
| f821e3eed8 | |||
| e62f921d2c | |||
| 8fe3ea55d2 | |||
| daff8094b8 | |||
| e9e11d531e | |||
| 830f704899 | |||
| 2a196f9ba9 | |||
| c04222b27a | |||
| 6fa09d336c | |||
| 38fbd14993 | |||
| cf9e8ce0d0 | |||
| fd88e8ceb0 | |||
| 46f4489631 | |||
| 3aa82953e4 | |||
| 1050485944 | |||
| 77ac0f0f78 | |||
| 7a45e7a3e7 | |||
| 08b98016bd | |||
| 7e70222f17 | |||
| f17e93aedf | |||
| 53697bc762 | |||
| 29f9ad07f1 | |||
| c2dfba8a60 | |||
| 6b01f39e78 | |||
| abf734ef18 | |||
| 8bf162fb73 | |||
| 2bacd5ba22 | |||
| 382699c563 | |||
| 66b37e7b5d | |||
| d4415515ec | |||
| c3c8e28f86 | |||
| 5292bd22c9 | |||
| 903c92e260 | |||
| 919d5bfa67 | |||
| 8cd9cff75e | |||
| 8dacfc157c | |||
| d5849c6b3a | |||
| 0fc4d449bf | |||
| 7e504c1dd2 | |||
| 2da6ea452e | |||
| 25b3efdec4 | |||
| 642cb48898 | |||
| a23e1ccf59 | |||
| ee13be9501 | |||
| 7eedc555d7 | |||
| 84c8c754dd | |||
| 5582054bc5 | |||
| ce359fcefb | |||
| 4e822fa347 | |||
| 3a55e2ee19 | |||
| 09b1a29a45 | |||
| 6cfb01b47d | |||
| e274814b46 | |||
| 18794e0173 | |||
| 692d929b4a | |||
| 3e1abc027e | |||
| d899630542 | |||
| 15a05d6c2c | |||
| 097e19109a | |||
| 675341b561 | |||
| 52de5e517f | |||
| d804f5ac30 | |||
| d9c3f3ca6f | |||
| 5548a7039f | |||
| ca1dd9aa90 | |||
| 15d11aff08 | |||
| 4b55247819 | |||
| 292803bb1c | |||
| f1f194b16a | |||
| 955486421b | |||
| 2b877b01a0 | |||
| 68ee6f620c | |||
| 2dec7b0de7 | |||
| b36d70b222 | |||
| 37bb781c4c | |||
| 544b0c6a01 | |||
| 194deebf7b | |||
| 1cb868e682 | |||
| c3b1aec23c | |||
| 11fab67247 | |||
| 17dc29251d | |||
| 56384f9046 | |||
| 01276bf29d | |||
| a147edca53 | |||
| 9e77181f21 | |||
| 8b6840adb0 | |||
| bc1d3fea51 | |||
| 6c50349bd8 | |||
| fdaf589ca3 | |||
| 4b0a4dd6b3 | |||
| 229b0dc9c3 | |||
| b81ae4eb4e | |||
| d70a5a43f7 | |||
| 5f66bd2a36 | |||
| 88c7397f45 | |||
| 2603620fb7 | |||
| e6622c51d2 | |||
| 6c48ab6b78 | |||
| f1cd7c5fe8 | |||
| 86a2de3ffd | |||
| 4ad0b7e8fc | |||
| 89364c1005 | |||
| 7047f6b368 | |||
| d3c32d099b | |||
| 70ac74a21c | |||
| ba2d59b298 | |||
| cd92661768 | |||
| 7303059fc8 | |||
| 931af0ea59 | |||
| 534c5b49dd | |||
| d4c68007ef | |||
| b9a5eaf196 | |||
| cf76c609cf | |||
| 0485c77e6a | |||
| 7ee808d2d9 | |||
| 8b106e7966 | |||
| 4b315469fc | |||
| 8c94a4e613 | |||
| 67992c8f58 | |||
| 817e19bf2b | |||
| f506872105 | |||
| f6d8060f25 | |||
| eaa5c971ed | |||
| 2ee60b20f5 | |||
| c38bf99e4f | |||
| 21bb9c9545 | |||
| ebcafa01f9 | |||
| eb68ec460d | |||
| e630703d75 | |||
| d500e3802f | |||
| 5032e24a74 | |||
| 3e3bc5dd0a | |||
| 84293aecd7 | |||
| 05f390e618 | |||
| 606770ed7f | |||
| 918150758e | |||
| db8df67f23 | |||
| 55e858788b | |||
| a2238a0f83 | |||
| 59decbc25c | |||
| 50753e9831 | |||
| 3c82c69ad7 | |||
| 6db23689bc | |||
| ac069d8f37 | |||
| ddc01379c8 | |||
| fba330b86e | |||
| c42160d898 | |||
| d064924e69 | |||
| 1dde62d1b4 | |||
| 8929ee47b6 | |||
| 6e46f2e078 | |||
| fecf0b28a3 | |||
| 7ca1cc91ea | |||
| 4501dbf69c | |||
| e855790cac | |||
| 5d60184e89 | |||
| 5cd11a9842 | |||
| 20b5deb5c3 | |||
| 6a66d86a5e | |||
| d8ab4017ad | |||
| 3e7ac0d24f | |||
| 7003168bc5 | |||
| 1e27b03946 | |||
| 40964815b7 | |||
| eef56b1688 | |||
| 2b6131e217 | |||
| cf150073dd | |||
| 93f101c0f6 | |||
| b9f5dac38e | |||
| 2c467988d3 | |||
| f329ec3f32 | |||
| 454defb005 | |||
| a8d218accb | |||
| 001aa2d42a | |||
| 8a42943ac7 | |||
| 930040875d | |||
| b6870949d4 | |||
| 8ca3180702 | |||
| 81c3f1402b | |||
| 06b1eefd90 | |||
| 5a7d016d87 | |||
| 99278d5ffc | |||
| 2663bf563b | |||
| 09de514ef1 | |||
| b4ca2720f6 | |||
| 4d86296ebd | |||
| 325e6495f5 | |||
| cabe0e99b2 | |||
| b292ffd959 | |||
| b5806ce630 | |||
| f9d759308a | |||
| 02e0dd0dc6 | |||
| 5ac15bcecb | |||
| 7453385631 | |||
| bf5a51d8ea | |||
| 931d86d5ce | |||
| d05cfaa332 | |||
| ad6a158f82 | |||
| dd53633d15 | |||
| 796fb52d42 | |||
| eab631b420 | |||
| 1381e5ae36 | |||
| 53f13f2137 | |||
| 3cd8e3c83b | |||
| 366e2c2e46 | |||
| 18d48808b7 | |||
| e712c6a7f8 | |||
| 14ce3e228f | |||
| fd6c12f140 | |||
| 3f6f7a5e53 | |||
| 403d7fe6d4 | |||
| e6794d87dd | |||
| 2764fcbfa3 | |||
| d8985c2873 | |||
| 347ae8d9c8 | |||
| 9896834b2b | |||
| 5772cb3da2 | |||
| 83615f1f47 | |||
| 7547089b23 | |||
| cf531cd733 | |||
| 71f5d6e973 | |||
| 56471479fd | |||
| d2132157cc | |||
| 7e29f26405 | |||
| 1d9e788500 | |||
| 79cf93996f | |||
| 87a4bb0562 | |||
| 693109950b | |||
| 186237b929 | |||
| 9f83f899a4 | |||
| c229a11714 | |||
| b5bc8cd7d6 | |||
| 30c7541a2f | |||
| 3360382f51 | |||
| db458373bc | |||
| e2ac3dbed2 | |||
| 239b19f8f3 | |||
| be7f0b1d0c | |||
| 832b2e5f83 | |||
| d3cd6d5208 | |||
| c8baaea71c | |||
| 47c6b2fcfd | |||
| cfc59f5004 | |||
| 2b945ad886 | |||
| 13e99fe2b6 | |||
| 6ac02a8865 | |||
| d0786c5d74 | |||
| aa4b0b1bb6 | |||
| b4ab1ae5c6 | |||
| 5ebbdcb533 | |||
| 3b014b6633 | |||
| 54f96ad787 | |||
| 0c48268edf | |||
| 40e7b14adb | |||
| 95dece05de | |||
| 4c9f3d681b | |||
| 4a4fd1f71b | |||
| 68945bbb1d | |||
| 47d028eda4 | |||
| 72c20bf783 | |||
| 7c6c3912d2 | |||
| 7666ed2087 | |||
| 46ba9c69db | |||
| 2542ce98e5 | |||
| c1351ba784 | |||
| deabaa3009 | |||
| 1b274dcf38 | |||
| b22b723a44 | |||
| 44568f85f5 | |||
| ee4610a750 | |||
| 12e4005eef | |||
| a2f6005d29 | |||
| 54bdee40f4 | |||
| 7a5159366d | |||
| 1cb0563fba | |||
| a6b209b7b3 | |||
| 2bec8d6a90 | |||
| c558872e8b | |||
| 87f0718e77 | |||
| f59b40ad1c | |||
| 5ff0fa452c | |||
| 452005583c | |||
| 474c073167 | |||
| 5e7311eb25 | |||
| 51b5c55b95 | |||
| 93084043a9 | |||
| a010f731e0 | |||
| f119229861 | |||
| 6057d2f880 | |||
| dbc62562c8 | |||
| 61cf833aa4 | |||
| 08d9507701 | |||
| b975f1ba9d | |||
| 3f91a3dbcd | |||
| 0b82e14049 | |||
| 94a6e7af25 | |||
| adb55de33e | |||
| f4cb550c94 | |||
| 08bd335f0a | |||
| 4b35a2a33d | |||
| 78fd017e03 | |||
| 5d88a33998 | |||
| 53a6147178 | |||
| 6d6275e78d | |||
| b49e55a586 | |||
| c239fd8973 | |||
| 60c9ed47d7 | |||
| ef22698f65 | |||
| aa0c36ff4e | |||
| c0c64cd531 | |||
| b8b97279c6 | |||
| 8c4af3030e | |||
| 248f0e0054 | |||
| 967f6904ab | |||
| b1d0eba892 | |||
| 247e52ba30 | |||
| 57f9d2ccc4 | |||
| 186ef7ebca | |||
| 7a6b00728c | |||
| 57f238a0c6 | |||
| f649315463 | |||
| 3494ae1319 | |||
| 9e8e889fb7 | |||
| f39bc64c96 | |||
| 4d4875b133 | |||
| d87326f644 | |||
| 56970d0796 | |||
| 3c1bce1eb8 | |||
| 94ddc91a18 | |||
| a74adc48b0 | |||
| 0c2d644130 | |||
| cbbf252e68 | |||
| 956fe14902 | |||
| afdb6618c7 | |||
| 573ee67610 | |||
| b2ce461249 | |||
| d78246d05c | |||
| 41e2c1f10e | |||
| d808038075 | |||
| ea65616bec | |||
| 7a3c8de073 | |||
| 23ca5d8aac | |||
| 681fccc616 | |||
| 25fa01a3d6 | |||
| 04b41a62c4 | |||
| 226b6d45e1 | |||
| caaf98d9ea | |||
| 7a5b90d11a | |||
| e707be9423 | |||
| faf878c23f | |||
| 91ac70e298 | |||
| e6eaf9846c |
+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
|
||||
|
||||
@@ -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 |
@@ -35,7 +35,7 @@ updates:
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: dev
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
directory: /spinifex/runtime/roles/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: sunday
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
with:
|
||||
sparse-checkout: spinifex/services/spinifexui/frontend
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
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
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
+2
-1
@@ -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).
|
||||
|
||||
@@ -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,7 +259,7 @@ 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
|
||||
@@ -276,7 +276,7 @@ coverage-badge:
|
||||
# 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 +376,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)...."
|
||||
@@ -423,7 +423,7 @@ fix:
|
||||
|
||||
govulncheck:
|
||||
@echo "Running govulncheck..."
|
||||
$(_Q)scripts/run-gate.sh govulncheck go tool govulncheck ./...
|
||||
$(_Q)scripts/run-gate.sh govulncheck scripts/govulncheck-gate.sh
|
||||
@echo " govulncheck ok"
|
||||
|
||||
# NilAway — advisory nil-panic analysis. Not in preflight due to false positives
|
||||
|
||||
@@ -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,167 @@
|
||||
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: "gateway/tagging", To: "domains/ec2/tags", Debt: "close-out: gateway/tagging", Reason: "cross-service GetResources; clears when domains/tagging aggregates through per-domain capabilities"},
|
||||
{From: "gateway/tagging", To: "handlers/elbv2", Debt: "close-out: gateway/tagging", Reason: "cross-service GetResources; clears when domains/tagging aggregates through per-domain capabilities"},
|
||||
|
||||
{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", "tagging": "tagging",
|
||||
}
|
||||
|
||||
// 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,82 @@
|
||||
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",
|
||||
"gateway/tagging",
|
||||
"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
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+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
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
cli.Execute()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/loadgen"
|
||||
"github.com/mulgadc/spinifex/internal/testkit/loadgen"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Cluster v1 contracts
|
||||
|
||||
This package owns the versioned cluster control-plane wire contracts. It is
|
||||
not an AWS tenant-facing API: it lets platform consumers identify responding
|
||||
daemon nodes and inspect their health.
|
||||
|
||||
`NodesDiscoverSubject` retains the deployed `spinifex.nodes.discover` route.
|
||||
`NodeDiscoverResponse.Node` is the node identifier used for responder-set
|
||||
coverage. Do not change either incompatibly in this package; add a new version
|
||||
when a wire change is required.
|
||||
|
||||
`NodeHealthSubject` retains the deployed per-node NATS route, while
|
||||
`NodeHealthResponse` is also the daemon HTTPS `/health` representation. Do not
|
||||
change their subject or JSON shape incompatibly in this package; add a new
|
||||
version when a wire change is required.
|
||||
|
||||
`NodeVMsSubject` retains the deployed `spinifex.node.vms` fan-out route.
|
||||
`NodeVMsResponse` is the VM inventory reply used by the SPX operator CLI and
|
||||
by EKS host lookup. It is distinct from node status and capacity reporting:
|
||||
do not add scheduling or physical-GPU inventory merely because those callers
|
||||
also inspect nodes. Do not change its subject or JSON shape incompatibly; add
|
||||
a new version when a wire change is required.
|
||||
|
||||
`NodeStatusSubject` retains the deployed `spinifex.node.status` fan-out route.
|
||||
`NodeStatusResponse` carries daemon-reported status, schedulable capacity and
|
||||
physical-GPU inventory for the SPX operator CLI and EC2/EKS placement callers.
|
||||
It is an observation contract, not the owner of capacity, scheduling or GPU
|
||||
allocation policy. Do not change its subject or JSON shape incompatibly; add
|
||||
a new version when a wire change is required.
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package clusterv1 defines version 1 of the cluster control-plane contract.
|
||||
// Its NATS subjects and JSON fields are wire compatibility, not implementation
|
||||
// details of a daemon or consumer.
|
||||
package clusterv1
|
||||
|
||||
const (
|
||||
// NodesDiscoverSubject fans out a membership discovery request to every
|
||||
// active daemon. Its reply payload is NodeDiscoverResponse.
|
||||
NodesDiscoverSubject = "spinifex.nodes.discover"
|
||||
)
|
||||
|
||||
// NodeDiscoverResponse identifies one daemon that responded to a membership
|
||||
// discovery request.
|
||||
type NodeDiscoverResponse struct {
|
||||
Node string `json:"node"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package clusterv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNodeDiscoverResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(NodeDiscoverResponse{Node: "node-a"})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"node":"node-a"}`, string(data))
|
||||
}
|
||||
|
||||
func TestNodesDiscoverSubject(t *testing.T) {
|
||||
require.Equal(t, "spinifex.nodes.discover", NodesDiscoverSubject)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package clusterv1
|
||||
|
||||
import "fmt"
|
||||
|
||||
// NodeHealthSubject returns the NATS route for one daemon's health response.
|
||||
// It intentionally performs no node-ID validation: callers have always
|
||||
// supplied the configured node identifier directly to NATS, and this
|
||||
// structural move must preserve that behaviour.
|
||||
func NodeHealthSubject(node string) string {
|
||||
return fmt.Sprintf("spinifex.admin.%s.health", node)
|
||||
}
|
||||
|
||||
// NodeHealthResponse is the daemon health representation returned by both the
|
||||
// per-node NATS health route and the daemon HTTPS /health endpoint.
|
||||
type NodeHealthResponse struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
Epoch uint64 `json:"epoch"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Services []string `json:"services"`
|
||||
ServiceHealth map[string]string `json:"service_health,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package clusterv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNodeHealthResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(NodeHealthResponse{
|
||||
Node: "node-a",
|
||||
Status: "running",
|
||||
ConfigHash: "abc123",
|
||||
Epoch: 7,
|
||||
Uptime: 42,
|
||||
Services: []string{"nats", "spinifex"},
|
||||
ServiceHealth: map[string]string{
|
||||
"nats": "ok",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"node":"node-a",
|
||||
"status":"running",
|
||||
"config_hash":"abc123",
|
||||
"epoch":7,
|
||||
"uptime":42,
|
||||
"services":["nats","spinifex"],
|
||||
"service_health":{"nats":"ok"}
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestNodeHealthSubject(t *testing.T) {
|
||||
require.Equal(t, "spinifex.admin.node-a.health", NodeHealthSubject("node-a"))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package clusterv1
|
||||
|
||||
const (
|
||||
// NodeVMsSubject fans out a request for each responding daemon's VM
|
||||
// inventory. Its reply payload is NodeVMsResponse.
|
||||
NodeVMsSubject = "spinifex.node.vms"
|
||||
)
|
||||
|
||||
// VMGPUInfo describes one GPU attached to a VM in a NodeVMsResponse.
|
||||
type VMGPUInfo struct {
|
||||
Model string `json:"model"`
|
||||
VRAMMiB int64 `json:"vram_mib"`
|
||||
PCIAddress string `json:"pci_address,omitempty"` // whole-GPU passthrough only
|
||||
Profile string `json:"profile,omitempty"` // MIG profile name; empty for whole-GPU
|
||||
MdevPath string `json:"mdev_path,omitempty"` // MIG only
|
||||
}
|
||||
|
||||
// VMInfo describes one VM in a node's inventory response.
|
||||
type VMInfo struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Status string `json:"status"`
|
||||
InstanceType string `json:"instance_type"`
|
||||
VCPU int `json:"vcpu"`
|
||||
MemoryGB float64 `json:"memory_gb"`
|
||||
LaunchTime int64 `json:"launch_time"`
|
||||
// ManagedBy is the Spinifex platform component that owns this VM
|
||||
// (e.g. "elbv2"). Empty for customer VMs. The UI uses this to filter
|
||||
// system-managed resources out of customer-facing listings.
|
||||
ManagedBy string `json:"managed_by,omitempty"`
|
||||
GPUs []VMGPUInfo `json:"gpus,omitempty"`
|
||||
// Health is a display label for instance health: "ok", "impaired",
|
||||
// "recovering", or "-" for non-running VMs. CrashCount is the lifetime
|
||||
// crash tally within the current restart window.
|
||||
Health string `json:"health,omitempty"`
|
||||
CrashCount int `json:"crash_count,omitempty"`
|
||||
}
|
||||
|
||||
// NodeVMsResponse is one daemon's reply on NodeVMsSubject. The subject fans
|
||||
// out, so callers collect one response per responding node.
|
||||
type NodeVMsResponse struct {
|
||||
Node string `json:"node"`
|
||||
Host string `json:"host"`
|
||||
VMs []VMInfo `json:"vms"`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package clusterv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNodeVMsResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(NodeVMsResponse{
|
||||
Node: "node-a",
|
||||
Host: "10.0.0.1",
|
||||
VMs: []VMInfo{{
|
||||
InstanceID: "i-abc123",
|
||||
Status: "running",
|
||||
InstanceType: "t3.small",
|
||||
VCPU: 2,
|
||||
MemoryGB: 2.0,
|
||||
LaunchTime: 1736935200,
|
||||
ManagedBy: "elbv2",
|
||||
GPUs: []VMGPUInfo{{
|
||||
Model: "NVIDIA A10",
|
||||
VRAMMiB: 23028,
|
||||
PCIAddress: "0000:01:00.0",
|
||||
Profile: "MIG 1g.6gb",
|
||||
MdevPath: "/sys/bus/mdev/devices/example",
|
||||
}},
|
||||
Health: "ok",
|
||||
CrashCount: 2,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"node":"node-a",
|
||||
"host":"10.0.0.1",
|
||||
"vms":[{
|
||||
"instance_id":"i-abc123",
|
||||
"status":"running",
|
||||
"instance_type":"t3.small",
|
||||
"vcpu":2,
|
||||
"memory_gb":2,
|
||||
"launch_time":1736935200,
|
||||
"managed_by":"elbv2",
|
||||
"gpus":[{
|
||||
"model":"NVIDIA A10",
|
||||
"vram_mib":23028,
|
||||
"pci_address":"0000:01:00.0",
|
||||
"profile":"MIG 1g.6gb",
|
||||
"mdev_path":"/sys/bus/mdev/devices/example"
|
||||
}],
|
||||
"health":"ok",
|
||||
"crash_count":2
|
||||
}]
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestNodeVMsSubject(t *testing.T) {
|
||||
require.Equal(t, "spinifex.node.vms", NodeVMsSubject)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package types
|
||||
package clusterv1
|
||||
|
||||
// NodeDiscoverResponse is the response for node discovery requests.
|
||||
type NodeDiscoverResponse struct {
|
||||
Node string `json:"node"`
|
||||
}
|
||||
const (
|
||||
// NodeStatusSubject fans out a request for each responding daemon's status
|
||||
// and schedulable-capacity snapshot. Its reply payload is NodeStatusResponse.
|
||||
NodeStatusSubject = "spinifex.node.status"
|
||||
)
|
||||
|
||||
// GPUSliceInfo describes a single MIG slice within a physical GPU.
|
||||
type GPUSliceInfo struct {
|
||||
@@ -27,17 +28,9 @@ type GPUInfo struct {
|
||||
Slices []GPUSliceInfo `json:"slices,omitempty"`
|
||||
}
|
||||
|
||||
// VMGPUInfo describes one GPU attached to a VM.
|
||||
type VMGPUInfo struct {
|
||||
Model string `json:"model"`
|
||||
VRAMMiB int64 `json:"vram_mib"`
|
||||
PCIAddress string `json:"pci_address,omitempty"` // whole-GPU passthrough only
|
||||
Profile string `json:"profile,omitempty"` // MIG profile name; empty for whole-GPU
|
||||
MdevPath string `json:"mdev_path,omitempty"` // MIG only
|
||||
}
|
||||
|
||||
// NodeStatusResponse is returned by the spinifex.node.status NATS topic (fan-out).
|
||||
// Schedulable capacity = TotalVCPU - ReservedVCPU - AllocVCPU (same for memory).
|
||||
// NodeStatusResponse is one daemon's reply on NodeStatusSubject. Schedulable
|
||||
// capacity = TotalVCPU - ReservedVCPU - AllocVCPU (and the same for memory).
|
||||
// The subject fans out, so callers collect one response per responding node.
|
||||
type NodeStatusResponse struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
@@ -81,30 +74,3 @@ type InstanceTypeCap struct {
|
||||
MemoryGB float64 `json:"memory_gb"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
// VMInfo describes a single VM for the cluster stats CLI.
|
||||
type VMInfo struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Status string `json:"status"`
|
||||
InstanceType string `json:"instance_type"`
|
||||
VCPU int `json:"vcpu"`
|
||||
MemoryGB float64 `json:"memory_gb"`
|
||||
LaunchTime int64 `json:"launch_time"`
|
||||
// ManagedBy is the Spinifex platform component that owns this VM
|
||||
// (e.g. "elbv2"). Empty for customer VMs. The UI uses this to filter
|
||||
// system-managed resources out of customer-facing listings.
|
||||
ManagedBy string `json:"managed_by,omitempty"`
|
||||
GPUs []VMGPUInfo `json:"gpus,omitempty"`
|
||||
// Health is a display label for instance health: "ok", "impaired",
|
||||
// "recovering", or "-" for non-running VMs. CrashCount is the lifetime
|
||||
// crash tally within the current restart window.
|
||||
Health string `json:"health,omitempty"`
|
||||
CrashCount int `json:"crash_count,omitempty"`
|
||||
}
|
||||
|
||||
// NodeVMsResponse is returned by the spinifex.node.vms NATS topic (fan-out).
|
||||
type NodeVMsResponse struct {
|
||||
Node string `json:"node"`
|
||||
Host string `json:"host"`
|
||||
VMs []VMInfo `json:"vms"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package clusterv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNodeStatusResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(NodeStatusResponse{
|
||||
Node: "node-a",
|
||||
Status: "Ready",
|
||||
Host: "10.0.0.1",
|
||||
Region: "ap-southeast-2",
|
||||
AZ: "ap-southeast-2a",
|
||||
Uptime: 42,
|
||||
Services: []string{"nats", "spinifex"},
|
||||
TotalVCPU: 16,
|
||||
TotalMemGB: 64,
|
||||
ReservedVCPU: 2,
|
||||
ReservedMemGB: 4,
|
||||
AllocVCPU: 4,
|
||||
AllocMemGB: 8,
|
||||
TotalGPUs: 1,
|
||||
AllocGPUs: 1,
|
||||
GPUCapable: true,
|
||||
GPUPassthrough: true,
|
||||
GPUModels: []string{"NVIDIA A10"},
|
||||
GPUs: []GPUInfo{{
|
||||
PCIAddress: "0000:01:00.0",
|
||||
Model: "NVIDIA A10",
|
||||
VRAMMiB: 23028,
|
||||
MIGEnabled: true,
|
||||
MIGProfile: "1g.6gb",
|
||||
InstanceID: "i-abc123",
|
||||
Slices: []GPUSliceInfo{{
|
||||
GIID: 1,
|
||||
Profile: "1g.6gb",
|
||||
VRAMMiB: 6144,
|
||||
MdevPath: "/sys/bus/mdev/devices/example",
|
||||
InstanceID: "i-abc123",
|
||||
}},
|
||||
}},
|
||||
VMCount: 1,
|
||||
InstanceTypes: []InstanceTypeCap{{
|
||||
Name: "g5.xlarge", VCPU: 4, MemoryGB: 16, Available: 2,
|
||||
}},
|
||||
NATSRole: "leader",
|
||||
PredastoreRole: "follower",
|
||||
OVNNBRole: "leader",
|
||||
OVNSBRole: "follower",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"node":"node-a", "status":"Ready", "host":"10.0.0.1",
|
||||
"region":"ap-southeast-2", "az":"ap-southeast-2a", "uptime":42,
|
||||
"services":["nats","spinifex"],
|
||||
"total_vcpu":16, "total_mem_gb":64,
|
||||
"reserved_vcpu":2, "reserved_mem_gb":4,
|
||||
"alloc_vcpu":4, "alloc_mem_gb":8,
|
||||
"total_gpus":1, "alloc_gpus":1,
|
||||
"gpu_capable":true, "gpu_passthrough":true, "gpu_models":["NVIDIA A10"],
|
||||
"gpus":[{
|
||||
"pci_address":"0000:01:00.0", "model":"NVIDIA A10", "vram_mib":23028,
|
||||
"mig_enabled":true, "mig_profile":"1g.6gb", "instance_id":"i-abc123",
|
||||
"slices":[{
|
||||
"gi_id":1, "profile":"1g.6gb", "vram_mib":6144,
|
||||
"mdev_path":"/sys/bus/mdev/devices/example", "instance_id":"i-abc123"
|
||||
}]
|
||||
}],
|
||||
"vm_count":1,
|
||||
"instance_types":[{"name":"g5.xlarge", "vcpu":4, "memory_gb":16, "available":2}],
|
||||
"nats_role":"leader", "predastore_role":"follower",
|
||||
"ovn_nb_role":"leader", "ovn_sb_role":"follower"
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestNodeStatusSubject(t *testing.T) {
|
||||
require.Equal(t, "spinifex.node.status", NodeStatusSubject)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# EC2 instance-command contract, version 1
|
||||
|
||||
This package owns the cross-process contract used to direct a command to the
|
||||
node that owns a running EC2 instance. It is not an in-process service
|
||||
interface or the owner of instance state.
|
||||
|
||||
## Route and purpose
|
||||
|
||||
The subject is `ec2.cmd.<instance-id>`. It is request/reply, and the daemon
|
||||
that hosts that instance's QEMU process is the only subscriber. This route
|
||||
exists because lifecycle operations such as start, stop, terminate, volume
|
||||
attach/detach and ENI attach/detach must reach that one process; sending them
|
||||
through the normal daemon queue group could select a different node.
|
||||
|
||||
`InstanceCommandSubject` constructs a targeted subject. The corresponding
|
||||
subscription and permission pattern is `ec2.cmd.*`: the `*` deliberately
|
||||
matches exactly one final instance-ID token, not an arbitrary descendant.
|
||||
|
||||
## Compatibility boundary
|
||||
|
||||
`EC2InstanceCommand`, its nested data objects, their JSON field names and the
|
||||
subject shape are wire compatibility. Callers and receivers must use this
|
||||
package rather than rebuild those details. `commands_test.go` preserves the
|
||||
version-1 JSON shape and subject form.
|
||||
|
||||
This package owns shared command payloads and the drain-volume response. It
|
||||
does not define every action's reply body, authorize the request, store EC2
|
||||
state or implement NATS transport. Those remain responsibilities of the EC2
|
||||
domain and its runtime composition.
|
||||
|
||||
Any incompatible subject or payload change requires an explicit versioned
|
||||
contract or an approved compatibility plan; it is not a package refactor.
|
||||
|
||||
## Default VPC request
|
||||
|
||||
`EnsureDefaultVpcSubject` (`ec2.EnsureDefaultVpc`) is the request/reply route on which the gateway's account creation asks a daemon in the `spinifex-workers` queue group to build the account's default VPC.
|
||||
Only the subject is owned here today.
|
||||
The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declared as anonymous structs on each side; moving them into this package is a contract change that needs its own characterization first.
|
||||
@@ -1,4 +1,26 @@
|
||||
package types
|
||||
// Package ec2v1 defines version 1 of the cross-process EC2 instance-command
|
||||
// contract. These payloads travel on ec2.cmd.<instance-id>; their JSON fields
|
||||
// are wire compatibility, not an implementation detail of a caller.
|
||||
package ec2v1
|
||||
|
||||
const (
|
||||
// InstanceCommandSubjectPrefix is the NATS subject family whose final token
|
||||
// identifies the instance-owning daemon.
|
||||
InstanceCommandSubjectPrefix = "ec2.cmd."
|
||||
|
||||
// InstanceCommandSubjectWildcard matches exactly one instance-ID token in
|
||||
// this contract family. It is the NATS permission/subscription pattern for
|
||||
// the per-instance command route, rather than the broader descendant match.
|
||||
InstanceCommandSubjectWildcard = InstanceCommandSubjectPrefix + "*"
|
||||
)
|
||||
|
||||
// InstanceCommandSubject returns the targeted command subject for instanceID.
|
||||
// It intentionally performs no validation: callers have always supplied the
|
||||
// already-validated instance identifier directly to NATS, and this structural
|
||||
// move must not change that behaviour.
|
||||
func InstanceCommandSubject(instanceID string) string {
|
||||
return InstanceCommandSubjectPrefix + instanceID
|
||||
}
|
||||
|
||||
// EC2InstanceCommand is the NATS wire format for EC2 instance commands
|
||||
// (stop, terminate, start, attach/detach-volume, attach/detach-eni).
|
||||
@@ -0,0 +1,85 @@
|
||||
package ec2v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
command := EC2InstanceCommand{
|
||||
ID: "i-0123456789abcdef0",
|
||||
Attributes: EC2CommandAttributes{
|
||||
StopInstance: true,
|
||||
TerminateInstance: true,
|
||||
StartInstance: true,
|
||||
AttachVolume: true,
|
||||
DetachVolume: true,
|
||||
DrainVolume: true,
|
||||
RebootInstance: true,
|
||||
AttachENI: true,
|
||||
DetachENI: true,
|
||||
AssociateIamInstanceProfile: true,
|
||||
SetSpotLineage: true,
|
||||
SetInstanceTags: true,
|
||||
RemoveInstanceTags: true,
|
||||
SetInstanceMonitoring: true,
|
||||
},
|
||||
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
|
||||
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
|
||||
DrainVolumeData: &DrainVolumeData{VolumeID: "vol-3"},
|
||||
AttachENIData: &AttachENIData{NetworkInterfaceID: "eni-1", DeviceIndex: 2},
|
||||
DetachENIData: &DetachENIData{AttachmentID: "eni-attach-1", Force: true},
|
||||
IamProfileAssociationData: &IamProfileAssociationData{InstanceProfileArn: "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
SpotLineageData: &SpotLineageData{SpotInstanceRequestId: "sir-1"},
|
||||
InstanceTagsData: &InstanceTagsData{Tags: map[string]string{"Name": "example"}, TagKeys: []string{"old"}},
|
||||
InstanceMonitoringData: &InstanceMonitoringData{Enabled: true},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(command)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"id": "i-0123456789abcdef0",
|
||||
"attributes": {
|
||||
"stop_instance": true,
|
||||
"delete_instance": true,
|
||||
"start_instance": true,
|
||||
"attach_volume": true,
|
||||
"detach_volume": true,
|
||||
"drain_volume": true,
|
||||
"reboot_instance": true,
|
||||
"attach_eni": true,
|
||||
"detach_eni": true,
|
||||
"associate_iam_instance_profile": true,
|
||||
"set_spot_lineage": true,
|
||||
"set_instance_tags": true,
|
||||
"remove_instance_tags": true,
|
||||
"set_instance_monitoring": true
|
||||
},
|
||||
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
|
||||
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
|
||||
"drain_volume_data": {"volume_id": "vol-3"},
|
||||
"attach_eni_data": {"network_interface_id": "eni-1", "device_index": 2},
|
||||
"detach_eni_data": {"attachment_id": "eni-attach-1", "force": true},
|
||||
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
"spot_lineage_data": {"spot_instance_request_id": "sir-1"},
|
||||
"instance_tags_data": {"tags": {"Name": "example"}, "tag_keys": ["old"]},
|
||||
"instance_monitoring_data": {"enabled": true}
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestDrainVolumeResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(DrainVolumeResponse{
|
||||
VolumeID: "vol-1",
|
||||
Status: DrainVolumeStatusDrained,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"volume_id":"vol-1","status":"drained"}`, string(data))
|
||||
require.Equal(t, "not-running", DrainVolumeStatusNotRunning)
|
||||
}
|
||||
|
||||
func TestInstanceCommandSubjects(t *testing.T) {
|
||||
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
|
||||
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ec2v1
|
||||
|
||||
// EnsureDefaultVpcSubject is the request/reply subject on which the daemon
|
||||
// builds an account's default VPC and acknowledges, so the gateway and the
|
||||
// daemon share the route without importing each other.
|
||||
const EnsureDefaultVpcSubject = "ec2.EnsureDefaultVpc"
|
||||
@@ -0,0 +1,15 @@
|
||||
package ec2v1_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
)
|
||||
|
||||
// The gateway's CreateAccount request and the daemon's subscription meet on
|
||||
// this literal; a deployed daemon and gateway of different builds must agree.
|
||||
func TestEnsureDefaultVpcSubject(t *testing.T) {
|
||||
require.Equal(t, "ec2.EnsureDefaultVpc", ec2v1.EnsureDefaultVpcSubject)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# ECS agent/scheduler bus contract, version 1
|
||||
|
||||
This package owns the deployed wire vocabulary for the `ecs.bus.*`
|
||||
scheduler-to-agent boundary: subjects, payload JSON and the Layer-2 message
|
||||
types. It is a characterization of what is deployed today, not a redesign; it
|
||||
does not move the scheduler, the agent, or perform any NATS I/O itself.
|
||||
|
||||
## Route table
|
||||
|
||||
| Subject | Payload | Reply | Delivery | Queue group | Producer | Consumer |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `ecs.bus.<account>.<cluster>.instance-register.<instanceId>` | `RegisterInstance` | none | fire-and-forget | none | none today | `spinifex_daemon` leader, `handlers/ecs/scheduler.go` `onRegister` |
|
||||
| `ecs.bus.<account>.<cluster>.instance-heartbeat.<instanceId>` | `Heartbeat` | none | fire-and-forget | none | none today | `spinifex_daemon` leader, `handlers/ecs/scheduler.go` `onHeartbeat` |
|
||||
| `ecs.bus.<account>.<cluster>.assign.<instanceId>` | `Assign` | none | fire-and-forget | none | none today | none today |
|
||||
| `ecs.bus.<account>.<cluster>.task-state.<taskId>` | `TaskState` | none | fire-and-forget | none | none today | `spinifex_daemon` leader, `handlers/ecs/scheduler.go` `onTaskState` |
|
||||
| `ecs.bus.<account>.<cluster>.service-reconcile` | none | none | fire-and-forget | none | `spinifex_daemon`, `handlers/ecs/instances.go` `SubmitTaskStateChange` | `spinifex_daemon` leader, `handlers/ecs/scheduler.go` `onReconcileWake` |
|
||||
|
||||
The agent today registers, heartbeats and reports task state through the AWS
|
||||
gateway's `RegisterContainerInstance`, `PollAssignments` and
|
||||
`SubmitTaskStateChange` NATS actions instead of publishing on this bus; those
|
||||
actions are a separate, pre-existing subject family and carry some of the
|
||||
same message types (`Assign`, `TaskState`, `StopDirective`) as their wire
|
||||
body. Four of the five subject builders above, every route except
|
||||
`service-reconcile`, therefore have no current publisher. The scheduler still
|
||||
subscribes to all four wildcard patterns; only its election leader holds the
|
||||
subscriptions, which is why no queue group is needed. `service-reconcile` is
|
||||
the one route with a live producer and consumer: the worker that recorded a
|
||||
task-state change is rarely the leader, so it publishes a cross-node wake the
|
||||
leader's own subscription picks up.
|
||||
|
||||
`StopDirective`, `ContainerStatus`, `InstanceCapacity`, `PortMapping`,
|
||||
`AssignContainer` and `SystemControl` are nested payload types carried inside
|
||||
`Assign` and `TaskState`; they travel on the same routes as their parent type
|
||||
and are not separately addressed.
|
||||
|
||||
## Ownership
|
||||
|
||||
The ECS domain owns the meaning of every subject and type in this package.
|
||||
The ecs-agent binary baked into the ECS node AMI is a deployed consumer: it
|
||||
decodes `Assign` and `StopDirective` and encodes `TaskState`, all carried
|
||||
today by the AWS-gateway actions that reuse these types.
|
||||
It does not encode `Heartbeat` or `RegisterInstance`.
|
||||
|
||||
## Compatibility rules
|
||||
|
||||
This is the deployed v1 contract. Changes follow N/N-1: a subject, JSON field
|
||||
name, JSON field type, or message semantics must not be renamed, retyped, or
|
||||
reinterpreted to mean something else. The AMI-baked agent cannot be upgraded
|
||||
in lockstep with the daemon, so it must keep decoding the shapes it already
|
||||
knows; `messages_test.go` pins the older agent's `AssignContainer` shape
|
||||
against the current one for exactly this reason. A breaking change needs a
|
||||
new, explicitly versioned successor package, not an edit here.
|
||||
|
||||
## What this package does not do
|
||||
|
||||
It does not move the scheduler, the agent, or any subscriber out of its
|
||||
current package, and it changes no ECS lifecycle behaviour: every subject
|
||||
string, JSON field and message type here is identical to the one the
|
||||
`handlers/ecs/bus` package declared before this move.
|
||||
@@ -1,4 +1,4 @@
|
||||
package bus
|
||||
package ecsv1
|
||||
|
||||
import "time"
|
||||
|
||||
@@ -13,9 +13,9 @@ type InstanceCapacity struct {
|
||||
GPUIDs []string `json:"gpuIds,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterInstance is published on RegisterSubject when an ecs-agent boots and
|
||||
// joins its cluster. The scheduler records a container-instance entry keyed by
|
||||
// InstanceID (handlers_ecs.InstanceKey).
|
||||
// RegisterInstance is the registration payload the scheduler decodes from
|
||||
// RegisterSubject, which has no current publisher. It records a
|
||||
// container-instance entry keyed by InstanceID.
|
||||
type RegisterInstance struct {
|
||||
AccountID string `json:"accountId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
@@ -27,9 +27,9 @@ type RegisterInstance struct {
|
||||
RegisteredAt time.Time `json:"registeredAt"`
|
||||
}
|
||||
|
||||
// Heartbeat is published on HeartbeatSubject every ~30s while the agent is
|
||||
// healthy. A missed heartbeat past the scheduler's TTL marks the instance
|
||||
// DISCONNECTED (wired in Sprint 4e).
|
||||
// Heartbeat is the liveness payload the scheduler decodes from HeartbeatSubject,
|
||||
// which has no current publisher; the agent's registration action carries
|
||||
// liveness instead.
|
||||
type Heartbeat struct {
|
||||
AccountID string `json:"accountId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
@@ -53,9 +53,9 @@ const (
|
||||
TaskStatusStopped = "STOPPED"
|
||||
)
|
||||
|
||||
// PortMapping is a container port exposed on the host (bridge mode v1). Shared
|
||||
// by the persisted ContainerDef and the AssignContainer wire payload below; Name
|
||||
// is a pure echo the agent does not read.
|
||||
// PortMapping is a container port exposed on the host (bridge mode v1), as
|
||||
// carried in the AssignContainer wire payload below; Name is a pure echo the
|
||||
// agent does not read.
|
||||
type PortMapping struct {
|
||||
ContainerPort int `json:"containerPort"`
|
||||
HostPort int `json:"hostPort,omitempty"`
|
||||
@@ -66,7 +66,6 @@ type PortMapping struct {
|
||||
}
|
||||
|
||||
// SystemControl is a sysctl namespace/value pair (linux systemControls).
|
||||
// Shared by the persisted ContainerDef and the AssignContainer wire payload.
|
||||
type SystemControl struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Value string `json:"value"`
|
||||
@@ -79,7 +78,7 @@ type AssignContainer struct {
|
||||
CPU int `json:"cpu,omitempty"`
|
||||
MemoryMiB int `json:"memoryMiB,omitempty"`
|
||||
// GPU is the whole-GPU count requested via resourceRequirements type=GPU.
|
||||
// Device pinning/CDI injection is not wired yet (later Epic C tasks).
|
||||
// Device pinning/CDI injection is not wired yet.
|
||||
GPU int `json:"gpu,omitempty"`
|
||||
Essential bool `json:"essential"`
|
||||
Command []string `json:"command,omitempty"`
|
||||
@@ -89,7 +88,7 @@ type AssignContainer struct {
|
||||
// (host-side). Any other value means logs are discarded (warned at register).
|
||||
LogDriver string `json:"logDriver,omitempty"`
|
||||
// User, ReadonlyRootFilesystem, Privileged, PseudoTerminal, Interactive,
|
||||
// SystemControls and CapAdd/CapDrop mirror the persisted ContainerDef and
|
||||
// SystemControls and CapAdd/CapDrop mirror the persisted task definition and
|
||||
// are applied to the OCI spec by the agent. Nil bools mean "not requested",
|
||||
// distinct from an explicit false; an older agent decoding this struct
|
||||
// ignores fields it does not know, so adding fields here is safe.
|
||||
@@ -107,9 +106,9 @@ type AssignContainer struct {
|
||||
StopTimeout *int64 `json:"stopTimeout,omitempty"`
|
||||
}
|
||||
|
||||
// Assign is published on AssignSubject when the scheduler places a task on this
|
||||
// instance (scheduler → agent). The agent pulls each container image, creates and
|
||||
// starts the containers via containerd, then reports progress on TaskStateSubject.
|
||||
// Assign places a task on an instance (scheduler → agent). It is the payload of
|
||||
// the gateway's PollAssignments action; AssignSubject carries nothing today.
|
||||
// The agent pulls each image, starts the containers, then reports TaskState.
|
||||
type Assign struct {
|
||||
AccountID string `json:"accountId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
@@ -158,9 +157,9 @@ type ContainerStatus struct {
|
||||
GPUIDs []string `json:"gpuIds,omitempty"`
|
||||
}
|
||||
|
||||
// TaskState is published on TaskStateSubject as the agent drives a task through
|
||||
// its lifecycle (agent → scheduler). The scheduler updates the task record and
|
||||
// recomputes the instance's remaining capacity.
|
||||
// TaskState reports a task's lifecycle (agent → scheduler). It is the payload of
|
||||
// the gateway's SubmitTaskStateChange action; TaskStateSubject carries nothing
|
||||
// today. The scheduler updates the task record and the instance's capacity.
|
||||
type TaskState struct {
|
||||
AccountID string `json:"accountId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
@@ -0,0 +1,193 @@
|
||||
package ecsv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var pinTime = time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
func TestRegisterInstance_JSON(t *testing.T) {
|
||||
golden := `{"accountId":"123456789012","clusterName":"default","instanceId":"i-abc123",` +
|
||||
`"availabilityZone":"az-1","hostname":"host-1",` +
|
||||
`"capacity":{"cpu":2048,"memoryMiB":4096},"agentVersion":"1.0.0",` +
|
||||
`"registeredAt":"2026-01-15T10:30:00Z"}`
|
||||
msg := ecsv1.RegisterInstance{
|
||||
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123",
|
||||
AZ: "az-1", Hostname: "host-1",
|
||||
Capacity: ecsv1.InstanceCapacity{CPU: 2048, MemoryMiB: 4096},
|
||||
AgentVersion: "1.0.0", RegisteredAt: pinTime,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.RegisterInstance
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestInstanceCapacity_GPUFields(t *testing.T) {
|
||||
golden := `{"cpu":4096,"memoryMiB":8192,"gpu":2,"gpuIds":["GPU-1","GPU-2"]}`
|
||||
capacity := ecsv1.InstanceCapacity{CPU: 4096, MemoryMiB: 8192, GPU: 2, GPUIDs: []string{"GPU-1", "GPU-2"}}
|
||||
|
||||
data, err := json.Marshal(capacity)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.InstanceCapacity
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, capacity, decoded)
|
||||
}
|
||||
|
||||
func TestHeartbeat_JSON(t *testing.T) {
|
||||
golden := `{"accountId":"123456789012","clusterName":"default","instanceId":"i-abc123",` +
|
||||
`"status":"ACTIVE","runningTasks":3,"sentAt":"2026-01-15T10:30:00Z"}`
|
||||
msg := ecsv1.Heartbeat{
|
||||
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123",
|
||||
Status: ecsv1.StatusActive, RunningTasks: 3, SentAt: pinTime,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.Heartbeat
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestStopDirective_JSON(t *testing.T) {
|
||||
golden := `{"taskId":"t-deadbeef","reason":"drain"}`
|
||||
msg := ecsv1.StopDirective{TaskID: "t-deadbeef", Reason: "drain"}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.StopDirective
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestStopDirective_OmitsEmptyReason(t *testing.T) {
|
||||
data, err := json.Marshal(ecsv1.StopDirective{TaskID: "t-deadbeef"})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"taskId":"t-deadbeef"}`, string(data))
|
||||
}
|
||||
|
||||
func TestAssign_JSON(t *testing.T) {
|
||||
ro := true
|
||||
start := int64(30)
|
||||
golden := `{"accountId":"123456789012","clusterName":"default","instanceId":"i-abc123",` +
|
||||
`"taskId":"t-deadbeef","taskArn":"arn:aws:ecs:ap-southeast-2:123456789012:task/default/t-deadbeef",` +
|
||||
`"taskDefFamily":"web","taskDefRevision":2,` +
|
||||
`"containers":[{"name":"app","image":"registry/app:1","essential":true,` +
|
||||
`"readonlyRootFilesystem":true,"startTimeout":30}],` +
|
||||
`"eniId":"eni-1","eniMac":"02:00:00:00:00:01","eniPrivateIp":"10.0.1.5","eniSubnetId":"subnet-1",` +
|
||||
`"credId":"c-1","taskRoleArn":"arn:aws:iam::123456789012:role/task",` +
|
||||
`"executionRoleArn":"arn:aws:iam::123456789012:role/exec",` +
|
||||
`"assignedAt":"2026-01-15T10:30:00Z"}`
|
||||
msg := ecsv1.Assign{
|
||||
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123",
|
||||
TaskID: "t-deadbeef", TaskARN: "arn:aws:ecs:ap-southeast-2:123456789012:task/default/t-deadbeef",
|
||||
TaskDefFamily: "web", TaskDefRevision: 2,
|
||||
Containers: []ecsv1.AssignContainer{{
|
||||
Name: "app", Image: "registry/app:1", Essential: true,
|
||||
ReadonlyRootFilesystem: &ro, StartTimeout: &start,
|
||||
}},
|
||||
ENIID: "eni-1", ENIMacAddress: "02:00:00:00:00:01", ENIPrivateIP: "10.0.1.5", ENISubnetID: "subnet-1",
|
||||
CredID: "c-1", TaskRoleARN: "arn:aws:iam::123456789012:role/task",
|
||||
ExecutionRoleARN: "arn:aws:iam::123456789012:role/exec",
|
||||
AssignedAt: pinTime,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.Assign
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestAssignContainer_FullFields(t *testing.T) {
|
||||
ro, priv, pty, interactive := true, true, false, true
|
||||
start, stop := int64(30), int64(10)
|
||||
golden := `{"name":"app","image":"registry/app:1","cpu":256,"memoryMiB":512,"gpu":1,` +
|
||||
`"essential":true,"command":["/bin/app"],"environment":{"ENV":"prod"},` +
|
||||
`"portMappings":[{"containerPort":80,"hostPort":8080,"protocol":"tcp","name":"http"}],` +
|
||||
`"logDriver":"json-file","user":"1000","readonlyRootFilesystem":true,"privileged":true,` +
|
||||
`"pseudoTerminal":false,"interactive":true,` +
|
||||
`"systemControls":[{"namespace":"net.core.somaxconn","value":"1024"}],` +
|
||||
`"capAdd":["SYS_PTRACE"],"capDrop":["NET_RAW"],"startTimeout":30,"stopTimeout":10}`
|
||||
msg := ecsv1.AssignContainer{
|
||||
Name: "app", Image: "registry/app:1", CPU: 256, MemoryMiB: 512, GPU: 1,
|
||||
Essential: true, Command: []string{"/bin/app"}, Environment: map[string]string{"ENV": "prod"},
|
||||
PortMappings: []ecsv1.PortMapping{{ContainerPort: 80, HostPort: 8080, Protocol: "tcp", Name: "http"}},
|
||||
LogDriver: "json-file",
|
||||
User: "1000",
|
||||
ReadonlyRootFilesystem: &ro,
|
||||
Privileged: &priv,
|
||||
PseudoTerminal: &pty,
|
||||
Interactive: &interactive,
|
||||
SystemControls: []ecsv1.SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}},
|
||||
CapAdd: []string{"SYS_PTRACE"},
|
||||
CapDrop: []string{"NET_RAW"},
|
||||
StartTimeout: &start,
|
||||
StopTimeout: &stop,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.AssignContainer
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestAssignContainer_OmitsEmptyFields(t *testing.T) {
|
||||
data, err := json.Marshal(ecsv1.AssignContainer{Name: "app", Image: "registry/app:1"})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"name":"app","image":"registry/app:1","essential":false}`, string(data))
|
||||
}
|
||||
|
||||
func TestTaskState_JSON(t *testing.T) {
|
||||
exit := 0
|
||||
golden := `{"accountId":"123456789012","clusterName":"default","instanceId":"i-abc123",` +
|
||||
`"taskId":"t-deadbeef","lastStatus":"STOPPED",` +
|
||||
`"containers":[{"name":"app","status":"STOPPED","containerId":"c-1","exitCode":0,"gpuIds":["GPU-1"]}],` +
|
||||
`"reason":"done","reportedAt":"2026-01-15T10:30:00Z"}`
|
||||
msg := ecsv1.TaskState{
|
||||
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123",
|
||||
TaskID: "t-deadbeef", LastStatus: ecsv1.TaskStatusStopped,
|
||||
Containers: []ecsv1.ContainerStatus{{
|
||||
Name: "app", Status: ecsv1.TaskStatusStopped, ContainerID: "c-1",
|
||||
ExitCode: &exit, GPUIDs: []string{"GPU-1"},
|
||||
}},
|
||||
Reason: "done", ReportedAt: pinTime,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(data))
|
||||
|
||||
var decoded ecsv1.TaskState
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, msg, decoded)
|
||||
}
|
||||
|
||||
func TestLifecycleStatusConstants(t *testing.T) {
|
||||
assert.Equal(t, "ACTIVE", ecsv1.StatusActive)
|
||||
assert.Equal(t, "DRAINING", ecsv1.StatusDraining)
|
||||
assert.Equal(t, "PENDING", ecsv1.TaskStatusPending)
|
||||
assert.Equal(t, "RUNNING", ecsv1.TaskStatusRunning)
|
||||
assert.Equal(t, "STOPPED", ecsv1.TaskStatusStopped)
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
package bus_test
|
||||
package ecsv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
)
|
||||
|
||||
// oldAssignContainer models an ecs-agent built before the container-runtime
|
||||
// fields were added: the wire shape AssignContainer had before user,
|
||||
// readonlyRootFilesystem, privileged and the rest. The AMI-baked decode side.
|
||||
type oldAssignContainer struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
CPU int `json:"cpu,omitempty"`
|
||||
MemoryMiB int `json:"memoryMiB,omitempty"`
|
||||
GPU int `json:"gpu,omitempty"`
|
||||
Essential bool `json:"essential"`
|
||||
Command []string `json:"command,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
PortMappings []bus.PortMapping `json:"portMappings,omitempty"`
|
||||
LogDriver string `json:"logDriver,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
CPU int `json:"cpu,omitempty"`
|
||||
MemoryMiB int `json:"memoryMiB,omitempty"`
|
||||
GPU int `json:"gpu,omitempty"`
|
||||
Essential bool `json:"essential"`
|
||||
Command []string `json:"command,omitempty"`
|
||||
Environment map[string]string `json:"environment,omitempty"`
|
||||
PortMappings []ecsv1.PortMapping `json:"portMappings,omitempty"`
|
||||
LogDriver string `json:"logDriver,omitempty"`
|
||||
}
|
||||
|
||||
// An agent baked into the ECS AMI must survive a daemon upgrade that adds
|
||||
@@ -30,12 +30,12 @@ func TestAssignContainer_OlderAgentIgnoresUnknownFields(t *testing.T) {
|
||||
ro := true
|
||||
priv := true
|
||||
start := int64(30)
|
||||
current := bus.AssignContainer{
|
||||
current := ecsv1.AssignContainer{
|
||||
Name: "app", Image: "registry/app:1", Essential: true,
|
||||
User: "1000",
|
||||
ReadonlyRootFilesystem: &ro,
|
||||
Privileged: &priv,
|
||||
SystemControls: []bus.SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}},
|
||||
SystemControls: []ecsv1.SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}},
|
||||
CapAdd: []string{"SYS_PTRACE"},
|
||||
StartTimeout: &start,
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
// Package bus defines the Layer-2 internal scheduler↔agent NATS subjects and
|
||||
// wire payloads for the ECS control plane (ecs-v1.md Q14). Both the ecs-agent
|
||||
// (publisher) and the spinifex scheduler (subscriber, Sprint 4e) import it so the
|
||||
// subject shape and message schema have a single source of truth.
|
||||
// Package ecsv1 defines version 1 of the ECS scheduler-agent NATS subjects and
|
||||
// wire payloads. The ecs-agent and the spinifex scheduler both consume this
|
||||
// shared vocabulary; the README's route table records which routes are live.
|
||||
//
|
||||
// Cluster identity is "<accountID>.<clusterName>" (matching the KV layout), not a
|
||||
// UUID. The per-AZ NATS routing inserts "{azID}" after "ecs." — keep
|
||||
// these builders the one place that knows the shape so that change stays local.
|
||||
package bus
|
||||
// UUID; these builders are the one place that knows the subject shape.
|
||||
package ecsv1
|
||||
|
||||
import "fmt"
|
||||
|
||||
@@ -15,24 +13,27 @@ func Prefix(accountID, clusterName string) string {
|
||||
return fmt.Sprintf("ecs.bus.%s.%s", accountID, clusterName)
|
||||
}
|
||||
|
||||
// RegisterSubject is published by the agent at boot (agent → scheduler).
|
||||
// RegisterSubject is the per-instance registration subject (agent → scheduler).
|
||||
// The scheduler subscribes to it; nothing publishes on it today.
|
||||
func RegisterSubject(accountID, clusterName, instanceID string) string {
|
||||
return fmt.Sprintf("%s.instance-register.%s", Prefix(accountID, clusterName), instanceID)
|
||||
}
|
||||
|
||||
// HeartbeatSubject is published by the agent every 30s (agent → scheduler).
|
||||
// HeartbeatSubject is the per-instance liveness subject (agent → scheduler).
|
||||
// The scheduler subscribes to it; nothing publishes on it today.
|
||||
func HeartbeatSubject(accountID, clusterName, instanceID string) string {
|
||||
return fmt.Sprintf("%s.instance-heartbeat.%s", Prefix(accountID, clusterName), instanceID)
|
||||
}
|
||||
|
||||
// AssignSubject is the per-instance task-assignment subject (scheduler → agent).
|
||||
// The agent does not subscribe to it until Sprint 4e.
|
||||
// No production code subscribes to it today; assignments are delivered through
|
||||
// the AWS-API gateway poll path instead.
|
||||
func AssignSubject(accountID, clusterName, instanceID string) string {
|
||||
return fmt.Sprintf("%s.assign.%s", Prefix(accountID, clusterName), instanceID)
|
||||
}
|
||||
|
||||
// TaskStateSubject is the per-task state-report subject (agent → scheduler),
|
||||
// published from Sprint 4e.
|
||||
// TaskStateSubject is the per-task state-report subject (agent → scheduler).
|
||||
// The scheduler subscribes to it; nothing publishes on it today.
|
||||
func TaskStateSubject(accountID, clusterName, taskID string) string {
|
||||
return fmt.Sprintf("%s.task-state.%s", Prefix(accountID, clusterName), taskID)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package bus
|
||||
package ecsv1
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# EKS v1 contracts
|
||||
|
||||
This package owns the deployed EKS add-on contract between the control-plane guest (the on-VM addon-sync agent, `eks-gateway-fetch` and `eks-gateway-publish`) and the host (the AWS gateway and the cluster reconciler). It is not an AWS tenant-facing API.
|
||||
|
||||
## Delivery report
|
||||
|
||||
`AddonStatusSubject` retains the deployed `eks.addon.<account>.<cluster>.status` route. The guest's `mulga-eks-addon-sync.sh` builds an `AddonStatusReport` with `printf`, so it always sends `message` (possibly empty); the gateway relays the payload verbatim on the `addon` channel of `internal-publish`. `AddonDeliveryPhase` is the closed set `applied`, `ready`, `failed`.
|
||||
|
||||
## Staged manifests
|
||||
|
||||
`InternalAddonsResponse` is the body of `GET /clusters/{name}/internal-addons/{account}`. The gateway renders it with the AWS SDK REST-JSON marshaller (`jsonutil.BuildJSON`), which ignores `json` tags, so the deployed body uses Go field names and always includes empty strings, for example `{"Addons":[{"AddonName":"x","AddonVersion":"1","ServiceAccountRoleArn":"","ConfigurationValues":""}]}`. The guest decodes it with `encoding/json`, which matches keys case-insensitively, then emits it as tab-separated lines for the shell agent.
|
||||
|
||||
The `json` tags on `StagedAddonManifest` are the host-internal NATS form used between the gateway and the daemon (`eks.ListStagedAddonManifests`). They do not describe the guest-facing body.
|
||||
|
||||
## Compatibility boundary
|
||||
|
||||
The subject, phase values, both JSON forms and Go field names are wire compatibility; `addon_status_test.go` and `addon_manifest_test.go` pin them with literal bytes. A generation-aware or otherwise incompatible delivery protocol needs a successor version; it is not a v1 change.
|
||||
@@ -0,0 +1,18 @@
|
||||
package eksv1
|
||||
|
||||
// StagedAddonManifest describes one add-on staged for delivery: the bundled
|
||||
// add-on and version plus the operator config the guest renders it with. The
|
||||
// json tags are the host-internal NATS form; see InternalAddonsResponse.
|
||||
type StagedAddonManifest struct {
|
||||
AddonName string `json:"addonName"`
|
||||
AddonVersion string `json:"addonVersion"`
|
||||
ServiceAccountRoleArn string `json:"serviceAccountRoleArn,omitempty"`
|
||||
ConfigurationValues string `json:"configurationValues,omitempty"`
|
||||
}
|
||||
|
||||
// InternalAddonsResponse is the internal-addons HTTP body. The gateway's SDK
|
||||
// REST-JSON marshaller ignores json tags, so Go field names (and order) are the
|
||||
// deployed bytes; the guest decodes them case-insensitively with encoding/json.
|
||||
type InternalAddonsResponse struct {
|
||||
Addons []StagedAddonManifest `json:"addons"`
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package eksv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testManifests = []StagedAddonManifest{
|
||||
{
|
||||
AddonName: "aws-load-balancer-controller",
|
||||
AddonVersion: "2.11.0",
|
||||
ServiceAccountRoleArn: "arn:aws:iam::123456789012:role/alb",
|
||||
ConfigurationValues: `{"replicaCount":2}`,
|
||||
},
|
||||
{AddonName: "spinifex-noop", AddonVersion: "0.1.0"},
|
||||
}
|
||||
|
||||
// The host-internal NATS form: camelCase tags, empty optionals omitted.
|
||||
func TestStagedAddonManifestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(testManifests)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `[`+
|
||||
`{"addonName":"aws-load-balancer-controller","addonVersion":"2.11.0",`+
|
||||
`"serviceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","configurationValues":"{\"replicaCount\":2}"},`+
|
||||
`{"addonName":"spinifex-noop","addonVersion":"0.1.0"}]`,
|
||||
string(data))
|
||||
}
|
||||
|
||||
// The deployed guest body, as rendered by the gateway's SDK REST-JSON
|
||||
// marshaller: Go field names in declaration order, empty strings present.
|
||||
func TestInternalAddonsResponse_RESTJSONBody(t *testing.T) {
|
||||
data, err := jsonutil.BuildJSON(&InternalAddonsResponse{Addons: testManifests})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `{"Addons":[`+
|
||||
`{"AddonName":"aws-load-balancer-controller","AddonVersion":"2.11.0",`+
|
||||
`"ServiceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","ConfigurationValues":"{\"replicaCount\":2}"},`+
|
||||
`{"AddonName":"spinifex-noop","AddonVersion":"0.1.0","ServiceAccountRoleArn":"","ConfigurationValues":""}]}`,
|
||||
string(data))
|
||||
|
||||
empty, err := jsonutil.BuildJSON(&InternalAddonsResponse{Addons: []StagedAddonManifest{}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `{"Addons":[]}`, string(empty))
|
||||
}
|
||||
|
||||
// The tag form encoding/json would emit; not what the gateway sends, but the
|
||||
// guest must decode both this and the REST-JSON body identically.
|
||||
func TestInternalAddonsResponse_EncodingJSONForm(t *testing.T) {
|
||||
data, err := json.Marshal(InternalAddonsResponse{Addons: testManifests})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `{"addons":[`+
|
||||
`{"addonName":"aws-load-balancer-controller","addonVersion":"2.11.0",`+
|
||||
`"serviceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","configurationValues":"{\"replicaCount\":2}"},`+
|
||||
`{"addonName":"spinifex-noop","addonVersion":"0.1.0"}]}`,
|
||||
string(data))
|
||||
}
|
||||
|
||||
func TestInternalAddonsResponse_DecodesBothForms(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"rest-json": `{"Addons":[` +
|
||||
`{"AddonName":"aws-load-balancer-controller","AddonVersion":"2.11.0",` +
|
||||
`"ServiceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","ConfigurationValues":"{\"replicaCount\":2}"},` +
|
||||
`{"AddonName":"spinifex-noop","AddonVersion":"0.1.0","ServiceAccountRoleArn":"","ConfigurationValues":""}]}`,
|
||||
"encoding-json": `{"addons":[` +
|
||||
`{"addonName":"aws-load-balancer-controller","addonVersion":"2.11.0",` +
|
||||
`"serviceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","configurationValues":"{\"replicaCount\":2}"},` +
|
||||
`{"addonName":"spinifex-noop","addonVersion":"0.1.0"}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var got InternalAddonsResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &got))
|
||||
require.Equal(t, InternalAddonsResponse{Addons: testManifests}, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package eksv1 defines version 1 of the EKS add-on contract between the
|
||||
// control-plane guest and the host. v1 is the deployed guest contract; a
|
||||
// generation-aware delivery protocol needs a successor version, not a v1 edit.
|
||||
package eksv1
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AddonStatusSubject returns the per-cluster NATS subject that carries every
|
||||
// add-on delivery report: "eks.addon.{accountID}.{clusterName}.status".
|
||||
func AddonStatusSubject(accountID, clusterName string) string {
|
||||
return fmt.Sprintf("eks.addon.%s.%s.status", accountID, clusterName)
|
||||
}
|
||||
|
||||
// AddonDeliveryPhase is the guest-observed delivery phase of one add-on. The
|
||||
// host-side add-on owner maps it onto the AWS-visible add-on status.
|
||||
type AddonDeliveryPhase string
|
||||
|
||||
const (
|
||||
// AddonPhaseApplied: the rendered manifest is in the K3s auto-deploy dir
|
||||
// but its workloads are not yet Ready.
|
||||
AddonPhaseApplied AddonDeliveryPhase = "applied"
|
||||
// AddonPhaseReady: the add-on's workloads rolled out successfully.
|
||||
AddonPhaseReady AddonDeliveryPhase = "ready"
|
||||
// AddonPhaseFailed: render or rollout failed.
|
||||
AddonPhaseFailed AddonDeliveryPhase = "failed"
|
||||
)
|
||||
|
||||
// AddonStatusReport is the add-on delivery report payload. The guest builds it
|
||||
// by hand (always sending "message", even empty) and the gateway relays it
|
||||
// verbatim onto AddonStatusSubject. TS is the publish time in unix seconds.
|
||||
type AddonStatusReport struct {
|
||||
Addon string `json:"addon"`
|
||||
Version string `json:"version"`
|
||||
Phase AddonDeliveryPhase `json:"phase"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eksv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddonStatusSubject(t *testing.T) {
|
||||
require.Equal(t, "eks.addon.111122223333.prod.status", AddonStatusSubject("111122223333", "prod"))
|
||||
}
|
||||
|
||||
func TestAddonStatusReportJSONContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
report AddonStatusReport
|
||||
want string
|
||||
}{
|
||||
{
|
||||
report: AddonStatusReport{Addon: "coredns", Version: "1.11.3", Phase: AddonPhaseApplied, TS: 1736935200},
|
||||
want: `{"addon":"coredns","version":"1.11.3","phase":"applied","ts":1736935200}`,
|
||||
},
|
||||
{
|
||||
report: AddonStatusReport{Addon: "coredns", Version: "1.11.3", Phase: AddonPhaseReady, TS: 1736935200},
|
||||
want: `{"addon":"coredns","version":"1.11.3","phase":"ready","ts":1736935200}`,
|
||||
},
|
||||
{
|
||||
report: AddonStatusReport{Addon: "coredns", Version: "1.11.3", Phase: AddonPhaseFailed, Message: "rollout stalled", TS: 1736935200},
|
||||
want: `{"addon":"coredns","version":"1.11.3","phase":"failed","message":"rollout stalled","ts":1736935200}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
data, err := json.Marshal(tc.report)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mulga-eks-addon-sync.sh builds the report with printf and always sends
|
||||
// "message", empty when there is nothing to say.
|
||||
func TestAddonStatusReport_DecodesGuestPrintfForm(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
want AddonStatusReport
|
||||
}{
|
||||
{
|
||||
body: `{"addon":"aws-load-balancer-controller","version":"2.11.0","phase":"applied","message":"","ts":1736935200}`,
|
||||
want: AddonStatusReport{Addon: "aws-load-balancer-controller", Version: "2.11.0", Phase: AddonPhaseApplied, TS: 1736935200},
|
||||
},
|
||||
{
|
||||
body: `{"addon":"aws-load-balancer-controller","version":"2.11.0","phase":"ready","message":"","ts":1736935200}`,
|
||||
want: AddonStatusReport{Addon: "aws-load-balancer-controller", Version: "2.11.0", Phase: AddonPhaseReady, TS: 1736935200},
|
||||
},
|
||||
{
|
||||
body: `{"addon":"aws-load-balancer-controller","version":"9.9.9","phase":"failed","message":"no baked bundle for version 9.9.9","ts":1736935200}`,
|
||||
want: AddonStatusReport{Addon: "aws-load-balancer-controller", Version: "9.9.9", Phase: AddonPhaseFailed, Message: "no baked bundle for version 9.9.9", TS: 1736935200},
|
||||
},
|
||||
} {
|
||||
t.Run(string(tc.want.Phase), func(t *testing.T) {
|
||||
var got AddonStatusReport
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.body), &got))
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eksv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A v1 decoder ignores fields a later sender adds, so a report carrying them
|
||||
// decodes to the same value as one without.
|
||||
func TestAddonStatusReport_IgnoresUnknownFields(t *testing.T) {
|
||||
body := `{"addon":"argocd","version":"3.0.23","phase":"ready","message":"","ts":1736935200,` +
|
||||
`"incarnationId":"inc-1","generation":4,"executorEpoch":7}`
|
||||
var got AddonStatusReport
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &got))
|
||||
require.Equal(t, AddonStatusReport{Addon: "argocd", Version: "3.0.23", Phase: AddonPhaseReady, TS: 1736935200}, got)
|
||||
}
|
||||
|
||||
// Both deployed body forms tolerate added top-level and per-add-on fields.
|
||||
func TestInternalAddonsResponse_IgnoresUnknownFields(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"rest-json": `{"Addons":[{"AddonName":"spinifex-noop","AddonVersion":"0.1.0","ServiceAccountRoleArn":"",` +
|
||||
`"ConfigurationValues":"","IncarnationId":"inc-1","Generation":4}],"ExecutorEpoch":7}`,
|
||||
"encoding-json": `{"addons":[{"addonName":"spinifex-noop","addonVersion":"0.1.0",` +
|
||||
`"incarnationId":"inc-1","generation":4}],"executorEpoch":7}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var got InternalAddonsResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &got))
|
||||
require.Equal(t, InternalAddonsResponse{Addons: []StagedAddonManifest{{AddonName: "spinifex-noop", AddonVersion: "0.1.0"}}}, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Network realisation contract, version 1
|
||||
|
||||
This package owns the deployed wire vocabulary for the `vpc.*`
|
||||
control-plane-to-network-realisation boundary: subjects, payload JSON, the
|
||||
`{success,error}` acknowledgement envelope, delivery mode and the
|
||||
`vpcd-workers` queue group. It does not move `vpcd`, perform any NATS I/O, or
|
||||
change any lifecycle or reliability semantics. It is a characterization of
|
||||
what is deployed today, split one route family per file, not a redesign.
|
||||
|
||||
## Ownership
|
||||
|
||||
Network owns the meaning and wire contract of every route below, because it
|
||||
owns OVN and host realisation. EC2 and the other managed-service domains own
|
||||
their own AWS-visible state transitions and decide when, and whether, to
|
||||
issue a projection onto one of these subjects; this package does not grant
|
||||
them authority over a route's semantics, only a stable shape to publish
|
||||
against.
|
||||
|
||||
## Route table
|
||||
|
||||
| Subject | Payload | Reply | Delivery | Queue group |
|
||||
|---|---|---|---|---|
|
||||
| `vpc.create` | `VPCEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete` | `VPCEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.create-subnet` | `SubnetEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete-subnet` | `SubnetEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.create-port` | `PortEvent` | `AckEnvelope` | request/reply, 5s | `vpcd-workers` |
|
||||
| `vpc.delete-port` | `PortEvent` | `AckEnvelope` | request/reply, 5s; caller treats failure as non-fatal | `vpcd-workers` |
|
||||
| `vpc.update-port-sgs` | `PortSecurityGroupsUpdateEvent` | `AckEnvelope` | request/reply, 5s | `vpcd-workers` |
|
||||
| `vpc.igw-attach` | `InternetGatewayEvent` | none | fire-and-forget, non-fatal | `vpcd-workers` |
|
||||
| `vpc.igw-detach` | `InternetGatewayEvent` | none | fire-and-forget, non-fatal | `vpcd-workers` |
|
||||
| `vpc.add-nat` | `NATEvent` | `AckEnvelope` | request/reply, 45s (OVN flows barrier); some callers fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete-nat` | `NATEvent` | `AckEnvelope` | request/reply, 15s, retried on `ErrNoResponders` (3 attempts, 500ms apart) | `vpcd-workers` |
|
||||
| `vpc.add-nat-gateway` | `NATGatewayEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete-nat-gateway` | `NATGatewayEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.add-igw-route` | `IGWRouteEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete-igw-route` | `IGWRouteEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.gate-subnet-egress` | `SubnetEgressGateEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.ungate-subnet-egress` | `SubnetEgressUngateEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.add-system-egress` | `SystemEgressEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.delete-system-egress` | `SystemEgressEvent` | none | fire-and-forget | `vpcd-workers` |
|
||||
| `vpc.create-sg` | `SecurityGroupEvent` | `AckEnvelope` | request/reply, 5s | `vpcd-workers` |
|
||||
| `vpc.delete-sg` | `SecurityGroupEvent` | `AckEnvelope` | request/reply, 5s, idempotent | `vpcd-workers` |
|
||||
| `vpc.update-sg` | `SecurityGroupEvent` | `AckEnvelope` | request/reply, 5s | `vpcd-workers` |
|
||||
|
||||
Producer for every route above is the daemon (`spinifex-daemon`), through the
|
||||
typed client in `domains/network/projection`; `vpc.add-system-egress` has no
|
||||
current producer.
|
||||
Consumer for every route is `vpcd`, by way of `domains/network/subscribers`.
|
||||
This package is the sole declared owner of every subject and type above,
|
||||
including `vpc.igw-attach`/`vpc.igw-detach`, which were previously also
|
||||
declared in `contracts/ec2/v1`; that duplicate has been removed.
|
||||
|
||||
## Compatibility rules
|
||||
|
||||
This is the deployed v1 contract. Changes follow N/N-1: a subject, JSON field
|
||||
name, JSON field type, or the ack envelope shape must not be renamed,
|
||||
retyped, or reinterpreted to mean something else. A breaking change needs a
|
||||
new, explicitly versioned successor package, not an edit here. Every subject
|
||||
and golden JSON literal in this package is pinned by an external `_test`
|
||||
package; a failing pin means the change broke compatibility, not that the
|
||||
test is stale.
|
||||
|
||||
## What this package does not do
|
||||
|
||||
It does not move `vpcd` or any subscriber out of its current package. It does
|
||||
not change any route's delivery mode, timeout, retry budget, queue group, or
|
||||
the NAT add barrier.
|
||||
@@ -0,0 +1,9 @@
|
||||
package networkv1
|
||||
|
||||
// AckEnvelope is the reply body on every request/reply route in this
|
||||
// package: Success true with no error, or Success false with Error set.
|
||||
// Fire-and-forget routes never populate it; there is no reply subject.
|
||||
type AckEnvelope struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAckEnvelope_JSON pins both ack envelope shapes: bare success, and
|
||||
// failure with its error populated. Error is omitempty, so the two must
|
||||
// not be mistaken for the same shape.
|
||||
func TestAckEnvelope_JSON(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
golden := `{"success":true}`
|
||||
env := networkv1.AckEnvelope{Success: true}
|
||||
|
||||
marshaled, err := json.Marshal(env)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.AckEnvelope
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, env, decoded)
|
||||
})
|
||||
|
||||
t.Run("error", func(t *testing.T) {
|
||||
golden := `{"success":false,"error":"boom"}`
|
||||
env := networkv1.AckEnvelope{Success: false, Error: "boom"}
|
||||
|
||||
marshaled, err := json.Marshal(env)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.AckEnvelope
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, env, decoded)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// IGWRouteAddSubject asks vpcd to install the per-subnet egress policy
|
||||
// for a route-table route to an internet gateway. Fire-and-forget.
|
||||
IGWRouteAddSubject = "vpc.add-igw-route"
|
||||
|
||||
// IGWRouteDeleteSubject asks vpcd to remove that egress policy.
|
||||
// Fire-and-forget.
|
||||
IGWRouteDeleteSubject = "vpc.delete-igw-route"
|
||||
)
|
||||
|
||||
// IGWRouteEvent is the payload on IGWRouteAddSubject and
|
||||
// IGWRouteDeleteSubject.
|
||||
type IGWRouteEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
DestinationCidr string `json:"destination_cidr"`
|
||||
InternetGatewayId string `json:"internet_gateway_id"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIGWRouteSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.add-igw-route", networkv1.IGWRouteAddSubject)
|
||||
assert.Equal(t, "vpc.delete-igw-route", networkv1.IGWRouteDeleteSubject)
|
||||
}
|
||||
|
||||
func TestIGWRouteEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","subnet_id":"subnet-1","destination_cidr":"0.0.0.0/0","internet_gateway_id":"igw-1"}`
|
||||
evt := networkv1.IGWRouteEvent{
|
||||
VpcId: "vpc-1", SubnetId: "subnet-1", DestinationCidr: "0.0.0.0/0", InternetGatewayId: "igw-1",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.IGWRouteEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// InternetGatewayAttachSubject asks vpcd to build the OVN external
|
||||
// switch, gateway and SNAT. Fire-and-forget, non-fatal on failure.
|
||||
InternetGatewayAttachSubject = "vpc.igw-attach"
|
||||
|
||||
// InternetGatewayDetachSubject asks vpcd to tear down the OVN external
|
||||
// switch, gateway and NAT. Fire-and-forget, non-fatal on failure.
|
||||
InternetGatewayDetachSubject = "vpc.igw-detach"
|
||||
)
|
||||
|
||||
// InternetGatewayEvent is the payload on InternetGatewayAttachSubject and
|
||||
// InternetGatewayDetachSubject.
|
||||
type InternetGatewayEvent struct {
|
||||
InternetGatewayId string `json:"internet_gateway_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInternetGatewaySubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.igw-attach", networkv1.InternetGatewayAttachSubject)
|
||||
assert.Equal(t, "vpc.igw-detach", networkv1.InternetGatewayDetachSubject)
|
||||
}
|
||||
|
||||
func TestInternetGatewayEvent_JSON(t *testing.T) {
|
||||
golden := `{"internet_gateway_id":"igw-1","vpc_id":"vpc-1"}`
|
||||
evt := networkv1.InternetGatewayEvent{InternetGatewayId: "igw-1", VpcId: "vpc-1"}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.InternetGatewayEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// NATAddSubject asks vpcd to commit the OVN dnat_and_snat rule.
|
||||
// Request/reply, 45s timeout for the OVN flows barrier; some callers
|
||||
// publish it fire-and-forget instead. See README for both paths.
|
||||
NATAddSubject = "vpc.add-nat"
|
||||
|
||||
// NATDeleteSubject asks vpcd to delete the dnat_and_snat rule and
|
||||
// unbind host plumbing. Request/reply, 15s timeout, retried on
|
||||
// ErrNoResponders. See README for the retry budget.
|
||||
NATDeleteSubject = "vpc.delete-nat"
|
||||
)
|
||||
|
||||
// NATEvent is the payload on NATAddSubject and NATDeleteSubject.
|
||||
type NATEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
ExternalIP string `json:"external_ip"`
|
||||
LogicalIP string `json:"logical_ip"`
|
||||
PortName string `json:"port_name"`
|
||||
MAC string `json:"mac"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// NATGatewayAddSubject asks vpcd to install the NAT Gateway's SNAT
|
||||
// rule, and the per-subnet egress policy when SubnetId is set.
|
||||
// Fire-and-forget.
|
||||
NATGatewayAddSubject = "vpc.add-nat-gateway"
|
||||
|
||||
// NATGatewayDeleteSubject asks vpcd to remove the NAT Gateway's SNAT
|
||||
// rule and per-subnet egress policy. Fire-and-forget.
|
||||
NATGatewayDeleteSubject = "vpc.delete-nat-gateway"
|
||||
)
|
||||
|
||||
// NATGatewayEvent is the payload on NATGatewayAddSubject and
|
||||
// NATGatewayDeleteSubject. SubnetId and DestinationCidr are omitempty
|
||||
// because one of today's two publishers (domains/ec2/natgw) never sets
|
||||
// them; omitting matches that publisher's wire bytes exactly. See README.
|
||||
type NATGatewayEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
NatGatewayId string `json:"nat_gateway_id"`
|
||||
PublicIp string `json:"public_ip"`
|
||||
SubnetCidr string `json:"subnet_cidr"`
|
||||
SubnetId string `json:"subnet_id,omitempty"`
|
||||
DestinationCidr string `json:"destination_cidr,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNATGatewaySubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.add-nat-gateway", networkv1.NATGatewayAddSubject)
|
||||
assert.Equal(t, "vpc.delete-nat-gateway", networkv1.NATGatewayDeleteSubject)
|
||||
}
|
||||
|
||||
func TestNATGatewayEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","nat_gateway_id":"nat-1","public_ip":"203.0.113.5","subnet_cidr":"10.0.1.0/24","subnet_id":"subnet-1","destination_cidr":"0.0.0.0/0"}`
|
||||
evt := networkv1.NATGatewayEvent{
|
||||
VpcId: "vpc-1", NatGatewayId: "nat-1", PublicIp: "203.0.113.5",
|
||||
SubnetCidr: "10.0.1.0/24", SubnetId: "subnet-1", DestinationCidr: "0.0.0.0/0",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.NATGatewayEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
|
||||
// TestNATGatewayEvent_NarrowPublisherShapeStillDecodes pins that the
|
||||
// narrower 4-field shape one of today's two publishers sends (domains/ec2/
|
||||
// natgw) round-trips exactly: SubnetId and DestinationCidr stay absent from
|
||||
// the wire on both the decode and the re-encode, not merely zero-valued.
|
||||
func TestNATGatewayEvent_NarrowPublisherShapeStillDecodes(t *testing.T) {
|
||||
narrow := `{"vpc_id":"vpc-1","nat_gateway_id":"nat-1","public_ip":"203.0.113.5","subnet_cidr":"10.0.1.0/24"}`
|
||||
var decoded networkv1.NATGatewayEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(narrow), &decoded))
|
||||
want := networkv1.NATGatewayEvent{
|
||||
VpcId: "vpc-1", NatGatewayId: "nat-1", PublicIp: "203.0.113.5", SubnetCidr: "10.0.1.0/24",
|
||||
}
|
||||
assert.Equal(t, want, decoded)
|
||||
|
||||
marshaled, err := json.Marshal(decoded)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, narrow, string(marshaled))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNATSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.add-nat", networkv1.NATAddSubject)
|
||||
assert.Equal(t, "vpc.delete-nat", networkv1.NATDeleteSubject)
|
||||
}
|
||||
|
||||
func TestNATEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","external_ip":"203.0.113.5","logical_ip":"10.0.1.5","port_name":"port-eni-1","mac":"02:00:00:00:00:01"}`
|
||||
evt := networkv1.NATEvent{
|
||||
VpcId: "vpc-1", ExternalIP: "203.0.113.5", LogicalIP: "10.0.1.5",
|
||||
PortName: "port-eni-1", MAC: "02:00:00:00:00:01",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.NATEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// PortCreateSubject asks vpcd to create the port's OVN logical switch
|
||||
// port. Request/reply so an OVSDB failure surfaces to the caller rather
|
||||
// than being swallowed; the deployed timeout is 5s.
|
||||
PortCreateSubject = "vpc.create-port"
|
||||
|
||||
// PortDeleteSubject asks vpcd to remove the port's OVN logical switch
|
||||
// port. Request/reply, 5s timeout, but the deployed caller treats
|
||||
// failure as non-fatal and relies on the reconciler's orphan prune.
|
||||
PortDeleteSubject = "vpc.delete-port"
|
||||
)
|
||||
|
||||
// PortEvent is the payload on PortCreateSubject and PortDeleteSubject.
|
||||
// SecurityGroupIds and SuppressDHCP are meaningful on create only; delete
|
||||
// carries them zero-valued.
|
||||
type PortEvent struct {
|
||||
NetworkInterfaceId string `json:"network_interface_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
PrivateIpAddress string `json:"private_ip_address"`
|
||||
MacAddress string `json:"mac_address"`
|
||||
SecurityGroupIds []string `json:"security_group_ids,omitempty"`
|
||||
SuppressDHCP bool `json:"suppress_dhcp,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package networkv1
|
||||
|
||||
// PortSecurityGroupsUpdateSubject asks vpcd to reconcile a port's OVN
|
||||
// port-group memberships against a declarative list; vpcd computes the
|
||||
// diff. Request/reply, 5s timeout, errors propagate to the caller.
|
||||
const PortSecurityGroupsUpdateSubject = "vpc.update-port-sgs"
|
||||
|
||||
// PortSecurityGroupsUpdateEvent is the payload on
|
||||
// PortSecurityGroupsUpdateSubject.
|
||||
type PortSecurityGroupsUpdateEvent struct {
|
||||
NetworkInterfaceId string `json:"network_interface_id"`
|
||||
PrivateIpAddress string `json:"private_ip_address"`
|
||||
SecurityGroupIds []string `json:"security_group_ids"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPortSecurityGroupsUpdateSubject(t *testing.T) {
|
||||
assert.Equal(t, "vpc.update-port-sgs", networkv1.PortSecurityGroupsUpdateSubject)
|
||||
}
|
||||
|
||||
func TestPortSecurityGroupsUpdateEvent_JSON(t *testing.T) {
|
||||
golden := `{"network_interface_id":"eni-1","private_ip_address":"10.0.1.5","security_group_ids":["sg-1","sg-2"]}`
|
||||
evt := networkv1.PortSecurityGroupsUpdateEvent{
|
||||
NetworkInterfaceId: "eni-1", PrivateIpAddress: "10.0.1.5",
|
||||
SecurityGroupIds: []string{"sg-1", "sg-2"},
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.PortSecurityGroupsUpdateEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPortSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.create-port", networkv1.PortCreateSubject)
|
||||
assert.Equal(t, "vpc.delete-port", networkv1.PortDeleteSubject)
|
||||
}
|
||||
|
||||
func TestPortEvent_JSON(t *testing.T) {
|
||||
golden := `{"network_interface_id":"eni-1","subnet_id":"subnet-1","vpc_id":"vpc-1","private_ip_address":"10.0.1.5","mac_address":"02:00:00:00:00:01","security_group_ids":["sg-1"],"suppress_dhcp":true}`
|
||||
evt := networkv1.PortEvent{
|
||||
NetworkInterfaceId: "eni-1", SubnetId: "subnet-1", VpcId: "vpc-1",
|
||||
PrivateIpAddress: "10.0.1.5", MacAddress: "02:00:00:00:00:01",
|
||||
SecurityGroupIds: []string{"sg-1"}, SuppressDHCP: true,
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.PortEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
|
||||
func TestPortEvent_OmitsEmptyFields(t *testing.T) {
|
||||
golden := `{"network_interface_id":"eni-1","subnet_id":"subnet-1","vpc_id":"vpc-1","private_ip_address":"10.0.1.5","mac_address":"02:00:00:00:00:01"}`
|
||||
evt := networkv1.PortEvent{
|
||||
NetworkInterfaceId: "eni-1", SubnetId: "subnet-1", VpcId: "vpc-1",
|
||||
PrivateIpAddress: "10.0.1.5", MacAddress: "02:00:00:00:00:01",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package networkv1 is the versioned wire vocabulary for the vpc.*
|
||||
// control-plane-to-network-realisation boundary. See README.md for the
|
||||
// route table, ownership statement and compatibility rules.
|
||||
package networkv1
|
||||
|
||||
// QueueGroup is the NATS queue group every vpcd subscribes under, so
|
||||
// exactly one vpcd instance processes a given event.
|
||||
const QueueGroup = "vpcd-workers"
|
||||
@@ -0,0 +1,13 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestQueueGroup pins the queue group literal every vpcd subscribes under.
|
||||
func TestQueueGroup(t *testing.T) {
|
||||
assert.Equal(t, "vpcd-workers", networkv1.QueueGroup)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// SecurityGroupCreateSubject asks vpcd to ensure the SG's port group
|
||||
// then apply its ACL set. Request/reply, 5s timeout.
|
||||
SecurityGroupCreateSubject = "vpc.create-sg"
|
||||
|
||||
// SecurityGroupDeleteSubject asks vpcd to remove the SG's port group
|
||||
// and ACLs. Request/reply, 5s timeout, idempotent.
|
||||
SecurityGroupDeleteSubject = "vpc.delete-sg"
|
||||
|
||||
// SecurityGroupUpdateSubject asks vpcd to replace the SG's ACL set.
|
||||
// Request/reply, 5s timeout; the port group is unaffected.
|
||||
SecurityGroupUpdateSubject = "vpc.update-sg"
|
||||
)
|
||||
|
||||
// SecurityGroupRule is a security-group rule as EC2 stores it. Network's ACL
|
||||
// builder only reads IpProtocol, FromPort, ToPort, CidrIp and SourceSG; the
|
||||
// rest exist so the wire shape matches the publisher's stored record exactly.
|
||||
type SecurityGroupRule struct {
|
||||
RuleId string `json:"rule_id"`
|
||||
IpProtocol string `json:"ip_protocol"`
|
||||
FromPort int64 `json:"from_port"`
|
||||
ToPort int64 `json:"to_port"`
|
||||
CidrIp string `json:"cidr_ip,omitempty"`
|
||||
CidrIpv6 string `json:"cidr_ipv6,omitempty"`
|
||||
SourceSG string `json:"source_sg,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityGroupEvent is the payload on SecurityGroupCreateSubject,
|
||||
// SecurityGroupDeleteSubject and SecurityGroupUpdateSubject.
|
||||
type SecurityGroupEvent struct {
|
||||
GroupId string `json:"group_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
IngressRules []SecurityGroupRule `json:"ingress_rules,omitempty"`
|
||||
EgressRules []SecurityGroupRule `json:"egress_rules,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSecurityGroupSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.create-sg", networkv1.SecurityGroupCreateSubject)
|
||||
assert.Equal(t, "vpc.delete-sg", networkv1.SecurityGroupDeleteSubject)
|
||||
assert.Equal(t, "vpc.update-sg", networkv1.SecurityGroupUpdateSubject)
|
||||
}
|
||||
|
||||
func TestSecurityGroupEvent_JSON(t *testing.T) {
|
||||
golden := `{"group_id":"sg-1","vpc_id":"vpc-1","ingress_rules":[{"rule_id":"sgr-1","ip_protocol":"tcp","from_port":22,"to_port":22,"cidr_ip":"0.0.0.0/0"}],"egress_rules":[{"rule_id":"sgr-2","ip_protocol":"-1","from_port":0,"to_port":0,"source_sg":"sg-2"}]}`
|
||||
evt := networkv1.SecurityGroupEvent{
|
||||
GroupId: "sg-1", VpcId: "vpc-1",
|
||||
IngressRules: []networkv1.SecurityGroupRule{{RuleId: "sgr-1", IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0"}},
|
||||
EgressRules: []networkv1.SecurityGroupRule{{RuleId: "sgr-2", IpProtocol: "-1", SourceSG: "sg-2"}},
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.SecurityGroupEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
|
||||
// TestSecurityGroupRule_FullShapeRoundTrip pins the EC2 publisher's stored
|
||||
// rule shape (rule_id, cidr_ipv6, description, tags) round-tripping losslessly;
|
||||
// network's ACL builder still only reads the first five fields.
|
||||
func TestSecurityGroupRule_FullShapeRoundTrip(t *testing.T) {
|
||||
wider := `{"rule_id":"sgr-1","ip_protocol":"tcp","from_port":22,"to_port":22,"cidr_ip":"0.0.0.0/0","cidr_ipv6":"::/0","description":"ssh","tags":{"k":"v"}}`
|
||||
var decoded networkv1.SecurityGroupRule
|
||||
require.NoError(t, json.Unmarshal([]byte(wider), &decoded))
|
||||
assert.Equal(t, networkv1.SecurityGroupRule{
|
||||
RuleId: "sgr-1", IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0",
|
||||
CidrIpv6: "::/0", Description: "ssh", Tags: map[string]string{"k": "v"},
|
||||
}, decoded)
|
||||
|
||||
marshaled, err := json.Marshal(decoded)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, wider, string(marshaled))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// SubnetCreateSubject asks vpcd to ensure the subnet's logical switch
|
||||
// exists, pre-ensuring the VPC router first since vpc.create and
|
||||
// vpc.create-subnet carry no ordering guarantee. Fire-and-forget.
|
||||
SubnetCreateSubject = "vpc.create-subnet"
|
||||
|
||||
// SubnetDeleteSubject asks vpcd to remove the subnet's logical switch.
|
||||
// Fire-and-forget.
|
||||
SubnetDeleteSubject = "vpc.delete-subnet"
|
||||
)
|
||||
|
||||
// SubnetEvent is the payload on SubnetCreateSubject and SubnetDeleteSubject.
|
||||
type SubnetEvent struct {
|
||||
SubnetId string `json:"subnet_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
CidrBlock string `json:"cidr_block"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// SubnetEgressGateSubject asks vpcd to install a DROP policy for a
|
||||
// subnet that lost its default egress target. Fire-and-forget.
|
||||
SubnetEgressGateSubject = "vpc.gate-subnet-egress"
|
||||
|
||||
// SubnetEgressUngateSubject asks vpcd to remove that DROP policy once
|
||||
// the subnet regains an egress target. Fire-and-forget.
|
||||
SubnetEgressUngateSubject = "vpc.ungate-subnet-egress"
|
||||
)
|
||||
|
||||
// SubnetEgressGateEvent is the payload on SubnetEgressGateSubject.
|
||||
type SubnetEgressGateEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
DestinationCidr string `json:"destination_cidr"`
|
||||
}
|
||||
|
||||
// SubnetEgressUngateEvent is the payload on SubnetEgressUngateSubject.
|
||||
// Identical shape to SubnetEgressGateEvent, kept distinct because the two
|
||||
// routes are opposite decisions, not variants of one event.
|
||||
type SubnetEgressUngateEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
DestinationCidr string `json:"destination_cidr"`
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSubnetEgressGateSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.gate-subnet-egress", networkv1.SubnetEgressGateSubject)
|
||||
assert.Equal(t, "vpc.ungate-subnet-egress", networkv1.SubnetEgressUngateSubject)
|
||||
}
|
||||
|
||||
func TestSubnetEgressGateEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","subnet_id":"subnet-1","destination_cidr":"0.0.0.0/0"}`
|
||||
evt := networkv1.SubnetEgressGateEvent{VpcId: "vpc-1", SubnetId: "subnet-1", DestinationCidr: "0.0.0.0/0"}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.SubnetEgressGateEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
|
||||
func TestSubnetEgressUngateEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","subnet_id":"subnet-1","destination_cidr":"0.0.0.0/0"}`
|
||||
evt := networkv1.SubnetEgressUngateEvent{VpcId: "vpc-1", SubnetId: "subnet-1", DestinationCidr: "0.0.0.0/0"}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.SubnetEgressUngateEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSubnetSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.create-subnet", networkv1.SubnetCreateSubject)
|
||||
assert.Equal(t, "vpc.delete-subnet", networkv1.SubnetDeleteSubject)
|
||||
}
|
||||
|
||||
func TestSubnetEvent_JSON(t *testing.T) {
|
||||
golden := `{"subnet_id":"subnet-1","vpc_id":"vpc-1","cidr_block":"10.0.1.0/24"}`
|
||||
evt := networkv1.SubnetEvent{SubnetId: "subnet-1", VpcId: "vpc-1", CidrBlock: "10.0.1.0/24"}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.SubnetEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// SystemEgressAddSubject asks vpcd to install a /32 reroute plus
|
||||
// snat-only NAT for a single system instance. Fire-and-forget. No
|
||||
// production publisher remains today; see README.
|
||||
SystemEgressAddSubject = "vpc.add-system-egress"
|
||||
|
||||
// SystemEgressDeleteSubject asks vpcd to remove that reroute and NAT.
|
||||
// Fire-and-forget.
|
||||
SystemEgressDeleteSubject = "vpc.delete-system-egress"
|
||||
)
|
||||
|
||||
// SystemEgressEvent is the payload on SystemEgressAddSubject and
|
||||
// SystemEgressDeleteSubject.
|
||||
type SystemEgressEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
InstanceIp string `json:"instance_ip"`
|
||||
ExternalIp string `json:"external_ip"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSystemEgressSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.add-system-egress", networkv1.SystemEgressAddSubject)
|
||||
assert.Equal(t, "vpc.delete-system-egress", networkv1.SystemEgressDeleteSubject)
|
||||
}
|
||||
|
||||
func TestSystemEgressEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","subnet_id":"subnet-1","instance_ip":"10.0.1.5","external_ip":"203.0.113.5"}`
|
||||
evt := networkv1.SystemEgressEvent{
|
||||
VpcId: "vpc-1", SubnetId: "subnet-1", InstanceIp: "10.0.1.5", ExternalIp: "203.0.113.5",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.SystemEgressEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package networkv1
|
||||
|
||||
const (
|
||||
// VPCCreateSubject asks vpcd to ensure the VPC's logical router exists.
|
||||
// Fire-and-forget: the publisher does not wait for the ack envelope.
|
||||
VPCCreateSubject = "vpc.create"
|
||||
|
||||
// VPCDeleteSubject asks vpcd to remove the VPC's logical router.
|
||||
// Fire-and-forget.
|
||||
VPCDeleteSubject = "vpc.delete"
|
||||
)
|
||||
|
||||
// VPCEvent is the payload on VPCCreateSubject and VPCDeleteSubject.
|
||||
// CidrBlock and VNI are meaningful on create only; delete needs only VpcId.
|
||||
type VPCEvent struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
CidrBlock string `json:"cidr_block"`
|
||||
VNI int64 `json:"vni"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package networkv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVPCSubjects(t *testing.T) {
|
||||
assert.Equal(t, "vpc.create", networkv1.VPCCreateSubject)
|
||||
assert.Equal(t, "vpc.delete", networkv1.VPCDeleteSubject)
|
||||
}
|
||||
|
||||
func TestVPCEvent_JSON(t *testing.T) {
|
||||
golden := `{"vpc_id":"vpc-1","cidr_block":"10.0.0.0/16","vni":42}`
|
||||
evt := networkv1.VPCEvent{VpcId: "vpc-1", CidrBlock: "10.0.0.0/16", VNI: 42}
|
||||
|
||||
marshaled, err := json.Marshal(evt)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded networkv1.VPCEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, evt, decoded)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Operator v1 contracts
|
||||
|
||||
This package owns versioned cross-process interfaces for deployment
|
||||
administration. It is separate from AWS tenant-facing service contracts.
|
||||
|
||||
`StorageConfigSubject` supplies the Predastore topology consumed by SPX
|
||||
`GetStorageStatus`. Its response deliberately contains Predastore-specific
|
||||
roles and Reed-Solomon configuration. It is an operational status contract,
|
||||
not a generic provider abstraction; do not change its subject or JSON shape
|
||||
incompatibly. Add a new version when that is required.
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package operatorv1 defines version 1 of cross-process operator-plane
|
||||
// contracts. These are deployment-administration interfaces, not AWS
|
||||
// tenant-facing APIs.
|
||||
package operatorv1
|
||||
|
||||
const (
|
||||
// StorageConfigSubject returns the current Predastore topology as configured
|
||||
// for this deployment. It is consumed by the SPX operator status path.
|
||||
StorageConfigSubject = "spinifex.storage.config"
|
||||
)
|
||||
|
||||
// StorageConfigResponse is the operator-plane response on
|
||||
// StorageConfigSubject. It intentionally describes the current Predastore
|
||||
// implementation (Reed-Solomon and meta/blob roles); it is not a generic
|
||||
// storage-provider contract.
|
||||
type StorageConfigResponse struct {
|
||||
Encoding StorageEncoding `json:"encoding"`
|
||||
MetaNodes []StorageMetaNode `json:"meta_nodes"`
|
||||
BlobNodes []StorageBlobNode `json:"blob_nodes"`
|
||||
Buckets []StorageBucket `json:"buckets"`
|
||||
}
|
||||
|
||||
// StorageEncoding describes Predastore's Reed-Solomon erasure coding
|
||||
// configuration.
|
||||
type StorageEncoding struct {
|
||||
DataShards int `json:"data_shards"`
|
||||
ParityShards int `json:"parity_shards"`
|
||||
}
|
||||
|
||||
// StorageMetaNode describes a Predastore meta node, one member of the Raft
|
||||
// quorum over global state. Credentials are deliberately excluded.
|
||||
type StorageMetaNode struct {
|
||||
ID int `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// StorageBlobNode describes a Predastore blob node that holds erasure-coded
|
||||
// object shards.
|
||||
type StorageBlobNode struct {
|
||||
ID int `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// StorageBucket describes a configured S3 bucket.
|
||||
type StorageBucket struct {
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package operatorv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStorageConfigResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(StorageConfigResponse{
|
||||
Encoding: StorageEncoding{DataShards: 2, ParityShards: 1},
|
||||
MetaNodes: []StorageMetaNode{{ID: 1, Host: "10.0.0.1", Port: 7660}},
|
||||
BlobNodes: []StorageBlobNode{{ID: 2, Host: "10.0.0.1", Port: 6660}},
|
||||
Buckets: []StorageBucket{{Name: "objects", Region: "ap-southeast-2"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"encoding":{"data_shards":2,"parity_shards":1},
|
||||
"meta_nodes":[{"id":1,"host":"10.0.0.1","port":7660}],
|
||||
"blob_nodes":[{"id":2,"host":"10.0.0.1","port":6660}],
|
||||
"buckets":[{"name":"objects","region":"ap-southeast-2"}]
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestStorageConfigSubject(t *testing.T) {
|
||||
require.Equal(t, "spinifex.storage.config", StorageConfigSubject)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Telemetry v1 contracts
|
||||
|
||||
`GuestTelemetryMeta` is the JSON sidecar contract between the VM runtime and
|
||||
the qmp-collector role. The writer creates and refreshes
|
||||
`qmp-telemetry-<instance-id>.json`; the collector reads it to locate the
|
||||
dedicated QMP socket, determine the monitoring interval, and select VPC data
|
||||
plane taps. Do not change this JSON shape incompatibly; add a new version when
|
||||
required.
|
||||
|
||||
The `metrics.ec2.*` NATS batch schema is deliberately not owned here yet.
|
||||
Goanna consumes that stream in another repository, so its ownership requires a
|
||||
cross-repository contract decision rather than a Spinifex-only move.
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package telemetryv1 defines version 1 of cross-process telemetry contracts.
|
||||
package telemetryv1
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// QMPTelemetryPrefix names the per-VM telemetry QMP socket and its metadata
|
||||
// sidecar. Both the VM runtime and qmp-collector use this prefix to locate the
|
||||
// versioned discovery record.
|
||||
const QMPTelemetryPrefix = "qmp-telemetry-"
|
||||
|
||||
// GuestTelemetryMetaPath returns the qmp-collector discovery-file path for an
|
||||
// instance within runtimeDir.
|
||||
func GuestTelemetryMetaPath(runtimeDir, instanceID string) string {
|
||||
return filepath.Join(runtimeDir, QMPTelemetryPrefix+instanceID+".json")
|
||||
}
|
||||
|
||||
// GuestTelemetryMeta is the qmp-collector's per-VM discovery record. The VM
|
||||
// runtime writes qmp-telemetry-<instance-id>.json next to the dedicated
|
||||
// telemetry QMP socket; qmp-collector reads and refreshes it after VM or ENI
|
||||
// lifecycle changes.
|
||||
type GuestTelemetryMeta struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
VCPUs int `json:"vcpus"`
|
||||
// PeriodSeconds is the collection interval: 60 for detailed monitoring,
|
||||
// 300 for basic — the EC2 monitoring tiers.
|
||||
PeriodSeconds int `json:"period_seconds"`
|
||||
// Taps are the VPC data-plane tap devices whose host-side counters yield
|
||||
// NetworkIn/Out. Control-plane NICs (mgmt, dev hostfwd) are excluded.
|
||||
Taps []string `json:"taps,omitempty"`
|
||||
Socket string `json:"socket"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package telemetryv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGuestTelemetryMetaJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(GuestTelemetryMeta{
|
||||
InstanceID: "i-0123456789abcdef0",
|
||||
AccountID: "123456789012",
|
||||
VCPUs: 2,
|
||||
PeriodSeconds: 60,
|
||||
Taps: []string{"tap0abc", "tap0def"},
|
||||
Socket: "/run/spinifex/qmp-telemetry-i-0123456789abcdef0.sock",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"instance_id":"i-0123456789abcdef0",
|
||||
"account_id":"123456789012",
|
||||
"vcpus":2,
|
||||
"period_seconds":60,
|
||||
"taps":["tap0abc","tap0def"],
|
||||
"socket":"/run/spinifex/qmp-telemetry-i-0123456789abcdef0.sock"
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestGuestTelemetryMetaPath(t *testing.T) {
|
||||
require.Equal(t,
|
||||
"/run/spinifex/qmp-telemetry-i-0123456789abcdef0.json",
|
||||
GuestTelemetryMetaPath("/run/spinifex", "i-0123456789abcdef0"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Viperblockd legacy contracts, version 1
|
||||
|
||||
This package owns the deployed, transitional configuration-update and
|
||||
state-reload request/reply protocols between Spinifex's legacy control plane
|
||||
and `viperblockd`. It makes those existing compatibility boundaries explicit
|
||||
while the generic `providers/ebs` contract is adopted; it is not the EBS
|
||||
provider abstraction and new provider integrations must not depend on it.
|
||||
|
||||
## Configuration update routes
|
||||
|
||||
`ConfigUpdateSubject` is `ebs.config`. Every `viperblockd` daemon subscribes
|
||||
to it in the `spinifex-workers` queue group. It is the fallback path for an
|
||||
encrypted volume with no live mount: the selected daemon opens the volume
|
||||
exclusively and reseals its state.
|
||||
|
||||
`VolumeConfigUpdateSubject(volume)` is `ebs.config.<volume>`. The daemon with
|
||||
the live mounted volume subscribes directly so the sole holder of the volume's
|
||||
state sequence number applies the update.
|
||||
|
||||
Both routes carry `EBSConfigUpdateRequest` and respond with
|
||||
`EBSConfigUpdateResponse`. The JSON field spelling, route shapes, and
|
||||
request/reply semantics are deployed compatibility behaviour. Incompatible
|
||||
changes require a new contract version or an explicit migration plan.
|
||||
|
||||
## State reload route
|
||||
|
||||
`SyncSubject` is `ebs.sync`. After a successful EC2 `ModifyVolume`, the daemon
|
||||
uses request/reply to ask a `spinifex-workers` queue-group member to reload the
|
||||
volume's state from its backend. The selected Viperblockd worker responds with
|
||||
`EBSSyncResponse`; it returns an error when it does not own a live mount of the
|
||||
volume. The daemon records that error as a warning after the API response has
|
||||
already been returned. This is deployed legacy behaviour, not a guarantee that
|
||||
every modification has reached its owning mount.
|
||||
|
||||
The route carries `EBSSyncRequest` and `EBSSyncResponse`. Their JSON shape and
|
||||
the queue-group route are compatibility behaviour.
|
||||
|
||||
## Volume lifecycle routes
|
||||
|
||||
`MountSubject(node)` and `UnmountSubject(node)` retain the deployed mount and
|
||||
unmount routes. With a node name they address that node's Viperblockd process
|
||||
(`ebs.<node>.mount` or `ebs.<node>.unmount`); without one they retain the
|
||||
single-node `spinifex-workers` queue-group routes. Both carry `EBSRequest`.
|
||||
`EBSMountResponse` supplies the exported NBD URI, while
|
||||
`EBSUnMountResponse` reports the seal result. The response broadcasts remain
|
||||
`ebs.mount.response` and `ebs.unmount.response` alongside ordinary
|
||||
request/reply replies.
|
||||
|
||||
`DeleteSubject` retains the `ebs.delete` queue-group route. It carries
|
||||
`EBSDeleteRequest` and responds with `EBSDeleteResponse` after Viperblockd has
|
||||
removed its local state and export. These routes and JSON shapes are deployed
|
||||
legacy compatibility behaviour, not a generic EBS-provider interface.
|
||||
|
||||
## Fence route
|
||||
|
||||
`VolumeFencedSubject(node)` is `ebs.<node>.fenced`, or `ebs.fenced` with no
|
||||
node for a single-node daemon. The Viperblock adapter publishes
|
||||
`VolumeFencedEvent` on it after tearing down an export whose volume lease
|
||||
moved to another node; the local daemon subscribes to stop the guest that
|
||||
was using it. Node-addressed, like mount and unmount: the guest is local, and
|
||||
no other node's daemon has anything to stop.
|
||||
|
||||
## Abandon route
|
||||
|
||||
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
|
||||
request/reply to ask the Viperblock adapter on that node to tear an export
|
||||
down without sealing, because the instance using it belongs to another node
|
||||
now. The route carries `VolumeAbandonRequest` and responds with
|
||||
`VolumeAbandonResponse`. `Abandoned` is false with no error when the volume
|
||||
was not exported there, which is the ordinary case: the request is
|
||||
idempotent by design, since the caller is reconciling rather than
|
||||
transacting.
|
||||
|
||||
## Dirty-marker bucket
|
||||
|
||||
`DirtyBucket` is the JetStream KV bucket `VIPERBLOCK_VOLUME_DIRTY`, keyed by
|
||||
volume name under `VolumeKeyPattern`, storing `DirtyRecord` JSON. It records
|
||||
which node holds writes for a volume that the backend may not have.
|
||||
|
||||
Owner: `providers/ebs/viperblock` owns the dirty-volume state and its
|
||||
transitions. `services/viperblockd` was only its transitional custodian.
|
||||
|
||||
Producers: only the Viperblock adapter writes, clears or changes a dirty
|
||||
record.
|
||||
|
||||
Consumers: Viperblock recovery and mount logic on any node. `spx admin` may
|
||||
obtain a read-only operational view.
|
||||
|
||||
Storage contract: the bucket name, `VolumeKeyPattern` and the `DirtyRecord`
|
||||
JSON shape are this package's legacy v1 contract. There is no TTL; a record
|
||||
persists until an explicit adapter-owned recovery or unseal transition
|
||||
removes it.
|
||||
|
||||
Compatibility: this is a deployed legacy v1 contract, and coordinated
|
||||
writers and readers support N/N-1. No renames, type changes or semantic
|
||||
reinterpretation of existing fields; a breaking change needs a successor
|
||||
contract and a migration.
|
||||
|
||||
CLI authority: the CLI is never a writer and must not perform arbitrary
|
||||
bucket operations.
|
||||
|
||||
This package does not authorize requests, validate a volume ID, store volume
|
||||
state, or define the generic storage-provider API. Those concerns remain with
|
||||
the consuming daemon, the EC2 domain, and `providers/ebs` respectively.
|
||||
@@ -0,0 +1,36 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
// VolumeAbandonSubject is where the local daemon asks for an export to be torn
|
||||
// down because the instance using it belongs to another node now.
|
||||
//
|
||||
// It exists because an unmount is the wrong request and killing the guest is not
|
||||
// enough. An unmount seals, and this node's copy is the stale one, so sealing
|
||||
// would publish an older state over the winner's. Meanwhile the export holds the
|
||||
// volume lease, and a lease this node keeps renewing is one the winner can never
|
||||
// acquire — the guest would be stopped here and unable to start anywhere.
|
||||
//
|
||||
// Node-addressed, like mount and unmount: the export is local, and no other
|
||||
// node's viperblockd has anything it could tear down.
|
||||
func VolumeAbandonSubject(node string) string {
|
||||
if node == "" {
|
||||
return "ebs.abandon"
|
||||
}
|
||||
return "ebs." + node + ".abandon"
|
||||
}
|
||||
|
||||
// VolumeAbandonRequest asks for one volume's export to go away without sealing.
|
||||
// Reason is logged, so a node that dropped an export can be told apart from one
|
||||
// that was fenced.
|
||||
type VolumeAbandonRequest struct {
|
||||
Volume string `json:"volume"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// VolumeAbandonResponse reports what happened. Abandoned is false with no error
|
||||
// when the volume was not exported here, which is the ordinary case and not a
|
||||
// failure: the request is idempotent by design, since the caller is reconciling
|
||||
// rather than transacting.
|
||||
type VolumeAbandonResponse struct {
|
||||
Abandoned bool `json:"abandoned"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package viperblocklegacyv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVolumeAbandonSubject_BothForms pins the abandon route the same way the
|
||||
// fenced route is pinned: node-addressed, with an empty-node fallback for a
|
||||
// single-node daemon.
|
||||
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
|
||||
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
|
||||
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
|
||||
}
|
||||
|
||||
// TestVolumeAbandonRequest_JSON pins the request daemon/vm_adapters.go sends
|
||||
// on the abandon route.
|
||||
func TestVolumeAbandonRequest_JSON(t *testing.T) {
|
||||
golden := `{"volume":"vol-1","reason":"instance owned by another node"}`
|
||||
req := viperblocklegacyv1.VolumeAbandonRequest{Volume: "vol-1", Reason: "instance owned by another node"}
|
||||
|
||||
marshaled, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, req, decoded)
|
||||
}
|
||||
|
||||
// TestVolumeAbandonResponse_JSON pins both response shapes: the ordinary
|
||||
// reconciling case with no error, and the failure case where Error is set.
|
||||
// Error is omitempty, so the two must not be mistaken for the same shape.
|
||||
func TestVolumeAbandonResponse_JSON(t *testing.T) {
|
||||
t.Run("abandoned, no error", func(t *testing.T) {
|
||||
golden := `{"abandoned":true}`
|
||||
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: true}
|
||||
|
||||
marshaled, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, resp, decoded)
|
||||
})
|
||||
|
||||
t.Run("not abandoned, with error", func(t *testing.T) {
|
||||
golden := `{"abandoned":false,"error":"nbdkit did not exit"}`
|
||||
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: false, Error: "nbdkit did not exit"}
|
||||
|
||||
marshaled, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeAbandonResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, resp, decoded)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package viperblocklegacyv1 contains the versioned, transitional wire
|
||||
// contracts between Spinifex's legacy control-plane routes and viperblockd.
|
||||
package viperblocklegacyv1
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
// ConfigUpdateSubject is the legacy queue-group route for an encrypted
|
||||
// volume configuration update when no mounted volume owns the request.
|
||||
ConfigUpdateSubject = "ebs.config"
|
||||
)
|
||||
|
||||
// VolumeConfigUpdateSubject returns the legacy route for an update handled by
|
||||
// the node with a live mount of volume. Volume remains unvalidated here: route
|
||||
// authorization and volume-name validation are responsibilities of the
|
||||
// consumer, as they were before this contract was extracted.
|
||||
func VolumeConfigUpdateSubject(volume string) string {
|
||||
return ConfigUpdateSubject + "." + volume
|
||||
}
|
||||
|
||||
// EBSConfigUpdateRequest carries a control-plane VolumeConfig update for an
|
||||
// encrypted volume. config.json is a sealed VBState; only the master-key holder
|
||||
// (viperblockd) can reseal it, so the EC2 edge ships the new config here instead
|
||||
// of rewriting the object directly. VolumeConfig is a marshaled
|
||||
// viperblock.VolumeConfig (RawMessage keeps this contract dependency-free).
|
||||
type EBSConfigUpdateRequest struct {
|
||||
Volume string `json:"Volume"`
|
||||
VolumeConfig json.RawMessage `json:"VolumeConfig"`
|
||||
}
|
||||
|
||||
// EBSConfigUpdateResponse is the request/reply response for either legacy
|
||||
// configuration-update route.
|
||||
type EBSConfigUpdateResponse struct {
|
||||
Volume string `json:"Volume"`
|
||||
Success bool `json:"Success"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigUpdateSubjects(t *testing.T) {
|
||||
require.Equal(t, "ebs.config", ConfigUpdateSubject)
|
||||
require.Equal(t, "ebs.config.vol-123", VolumeConfigUpdateSubject("vol-123"))
|
||||
// The deployed route builder did not validate its final token. Validation
|
||||
// remains with the handler and is not silently introduced by this move.
|
||||
require.Equal(t, "ebs.config.", VolumeConfigUpdateSubject(""))
|
||||
}
|
||||
|
||||
func TestConfigUpdateRequestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSConfigUpdateRequest{
|
||||
Volume: "vol-123",
|
||||
VolumeConfig: json.RawMessage(`{"size_gib":8}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123","VolumeConfig":{"size_gib":8}}`, string(data))
|
||||
}
|
||||
|
||||
func TestConfigUpdateResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(EBSConfigUpdateResponse{
|
||||
Volume: "vol-123",
|
||||
Success: true,
|
||||
Error: "",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"Volume":"vol-123","Success":true,"Error":""}`, string(data))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirtyBucket names the volumes whose writes are not confirmed to have reached
|
||||
// the backend, so the node holding them is still known after that node is gone.
|
||||
//
|
||||
// Deliberately no TTL. The volume lease answers "who is writing this right now"
|
||||
// and expires with its holder; this answers "whose copy is ahead of the
|
||||
// backend", which has to outlive the holder to be worth anything.
|
||||
//
|
||||
// It is a placement input, not a gate. Instance start already prefers the node
|
||||
// that last ran the instance and falls back after a window, and refusing the
|
||||
// fallback here would trade a rare data-loss risk for a volume that cannot run
|
||||
// anywhere.
|
||||
const DirtyBucket = "VIPERBLOCK_VOLUME_DIRTY"
|
||||
|
||||
// VolumeKeyPattern is what JetStream KV accepts as a key for a volume-keyed
|
||||
// bucket, including the dirty marker and the volume lease. Volume names reach
|
||||
// here from the wire, and a name carrying "." or ">" would address somebody
|
||||
// else's key.
|
||||
var VolumeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// DirtyRecord is what a node publishes about writes the backend may not have.
|
||||
// Reason says how it got that way, so an operator reading a takeover warning
|
||||
// does not have to correlate journals across nodes.
|
||||
//
|
||||
// Generation is the lease revision the writer held. It orders two nodes' claims
|
||||
// on one volume, which is the whole reason a returning node cannot quietly
|
||||
// overwrite or clear a marker that has moved on without it.
|
||||
type DirtyRecord struct {
|
||||
Owner string `json:"owner"`
|
||||
Generation uint64 `json:"generation"`
|
||||
Since time.Time `json:"since"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package viperblocklegacyv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDirtyBucket_Name pins the JetStream KV bucket name. Renaming it without
|
||||
// a migration would strand every existing dirty marker in an unread bucket.
|
||||
func TestDirtyBucket_Name(t *testing.T) {
|
||||
assert.Equal(t, "VIPERBLOCK_VOLUME_DIRTY", viperblocklegacyv1.DirtyBucket)
|
||||
}
|
||||
|
||||
// TestDirtyRecord_JSON pins the stored JSON shape read across nodes from the
|
||||
// dirty bucket, including the RFC 3339 encoding of Since.
|
||||
func TestDirtyRecord_JSON(t *testing.T) {
|
||||
since := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
golden := `{"owner":"node-a","generation":7,"since":"2024-01-02T03:04:05Z","reason":"seal volume: predastore unreachable"}`
|
||||
record := viperblocklegacyv1.DirtyRecord{
|
||||
Owner: "node-a",
|
||||
Generation: 7,
|
||||
Since: since,
|
||||
Reason: "seal volume: predastore unreachable",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(record)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.DirtyRecord
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.True(t, decoded.Since.Equal(since))
|
||||
decoded.Since = since
|
||||
assert.Equal(t, record, decoded)
|
||||
}
|
||||
|
||||
// TestVolumeKeyPattern_PinsValidAndInvalidKeys characterizes which volume
|
||||
// names are accepted as a JetStream KV key, for both the lease and the
|
||||
// dirty-marker buckets. A name carrying "." or ">" would address somebody
|
||||
// else's key, which is what the pattern exists to refuse.
|
||||
func TestVolumeKeyPattern_PinsValidAndInvalidKeys(t *testing.T) {
|
||||
valid := []string{"vol-1", "VOL_1", "abcXYZ09", "a", "A-B_c9"}
|
||||
for _, key := range valid {
|
||||
if !viperblocklegacyv1.VolumeKeyPattern.MatchString(key) {
|
||||
t.Errorf("expected %q to be accepted as a volume key", key)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{"", "vol.1", "vol/1", "vol:1", "vol 1", "vol>1", "ebs.*", "day's"}
|
||||
for _, key := range invalid {
|
||||
if viperblocklegacyv1.VolumeKeyPattern.MatchString(key) {
|
||||
t.Errorf("expected %q to be rejected as a volume key", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user