mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 20:57:02 +00:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd9c9666f2 | |||
| ae2fbef021 | |||
| fe4ce999ed | |||
| 39759e1ed2 | |||
| 8dad0ab9ab | |||
| b73ab7cfb3 | |||
| 8746b9e79d | |||
| 8ab96b29a1 | |||
| ecef5e4d2f | |||
| 772c7657d8 | |||
| 2898181837 | |||
| b784483a03 | |||
| 866392e680 | |||
| 0fd0e2ba15 | |||
| 377c573bf6 | |||
| 1426cdd072 | |||
| 2d2d2f27ed | |||
| 0c3403889c | |||
| d8cf17bf6e | |||
| 7b3ebb2ab4 | |||
| 83f351bd6d | |||
| c473365f1a | |||
| 94488dc399 | |||
| 9bd1860622 | |||
| 4abd2fd984 | |||
| b3d1e859c4 | |||
| 021f439bab | |||
| b7b1ae562a | |||
| 20b3eae728 | |||
| fcda031117 | |||
| cacd2c2c85 | |||
| adb3d9876d | |||
| 7163f7d62d | |||
| 6d4a66be90 | |||
| cb83f9b193 | |||
| 372991c304 | |||
| 28963e28af | |||
| 85dd1dcd75 | |||
| da2e6c5c7b | |||
| 71059834e7 | |||
| 5c5a2bc0b7 | |||
| 259b737f7a | |||
| 0ae2826cca | |||
| 2dee58d749 | |||
| b53ac2d148 | |||
| 7cc8f59a7d | |||
| 4fc99a5e3e | |||
| 8b5e23da70 | |||
| c2332746f3 | |||
| a74052fd6f | |||
| 32bcada227 | |||
| db5e446338 | |||
| 2ee22e0f66 | |||
| eb365eca7b | |||
| 2ba8f5e4ab | |||
| e035842503 | |||
| 0b93b1a957 | |||
| 664db686e1 | |||
| e2db95d746 | |||
| ccc7478f19 | |||
| f536b1a0cb | |||
| deff3a1e1e | |||
| 2039c05c17 | |||
| 451df1a8cd | |||
| b26478bd06 | |||
| c9460628fb | |||
| e88691e64d | |||
| c674e4b5ce | |||
| bef0780b10 | |||
| 86014ac08a | |||
| fd22c518bf | |||
| 4a9dda0b99 | |||
| c10b6732d5 | |||
| c4b380b2ed | |||
| ef9bfb4c1d | |||
| 969163c114 | |||
| ff41bcf369 | |||
| 5991b92b06 | |||
| 5ff54ea110 | |||
| 3a51646e1c | |||
| 809ec5f1fe | |||
| ead2f2fe6c | |||
| 0fd6848fbd | |||
| 208a108c27 | |||
| e2ca21a18d | |||
| ca5ff7695e | |||
| 1ab61b0dd5 | |||
| 6c86dd5f34 | |||
| d632b607ff | |||
| 68eeff069e | |||
| 0c83f7f954 | |||
| 4b35de1409 | |||
| 7474fa2073 | |||
| 95035a1754 | |||
| 9bc3ea8658 | |||
| 2dfc9e1efb | |||
| 578185f533 | |||
| 4eee02fcfd | |||
| 062880687d | |||
| bb8c639c82 | |||
| de295b3f15 |
@@ -0,0 +1,80 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="836" viewBox="0 0 1020 836" role="img" aria-label="Spinifex on a single OCI instance: the instance sits inside the VCN public subnet 10.200.0.0/23; its primary VNIC carries the default route and the cluster mesh, its secondary VNIC is the br-wan bridge holding registered secondary private IPs paired with reserved public IPs; guests reach them through the spx-nat transit veth, and an iSCSI block volume backs /var/lib/spinifex.">
|
||||
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
|
||||
<rect x="0" y="0" width="1020" height="836" rx="20" fill="#14181D" stroke="#2A2F36" stroke-width="1"/>
|
||||
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON OCI — SINGLE NODE</text>
|
||||
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">one instance · routed NAT · real public addresses</text>
|
||||
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">One OCI instance, single Spinifex node</text>
|
||||
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
|
||||
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">AWS SDKs, SSH and guest egress all arrive here</text>
|
||||
<rect x="46" y="126" width="272.7" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="182.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
|
||||
<rect x="327.7" y="126" width="177.2" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="416.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest public IP</text>
|
||||
<rect x="513.92" y="126" width="218.1" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="623.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
|
||||
<line x1="510.0" y1="170" x2="510.0" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28" y="188" width="964" height="582" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
|
||||
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23</text>
|
||||
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">virtual router 10.200.0.1 · internet gateway</text>
|
||||
<rect x="48" y="232" width="924" height="520" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="68" y="266" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">BM.Standard.E2.64</text>
|
||||
<text x="260" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">spinifex-node-01 · FAULT-DOMAIN-1</text>
|
||||
<text x="952" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one host = one Spinifex region</text>
|
||||
<line x1="68" y1="282" x2="952" y2="282" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<rect x="68" y="298" width="434.0" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="88" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">VNIC 0 — PRIMARY</text>
|
||||
<text x="482.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">enp0s9</text>
|
||||
<text x="88" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">10.200.0.252/23 · holds the default route</text>
|
||||
<text x="88" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">advertise IP · ovn-encap-ip · NATS · API :9999</text>
|
||||
<rect x="518.0" y="298" width="434.0" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="538.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">VNIC 1 — SECONDARY</text>
|
||||
<text x="932.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">br-wan (Linux bridge)</text>
|
||||
<text x="538.0" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">10.200.1.233/32 + ip rule → table 200</text>
|
||||
<text x="538.0" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">secondary private IPs ⇄ reserved public IPs</text>
|
||||
<line x1="735.0" y1="406" x2="735.0" y2="422" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="68" y="424" width="884" height="40" rx="11" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="88" y="449" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="600" text-anchor="start">spx-nat-host ⇄ spx-nat-ovs</text>
|
||||
<text x="296" y="449" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">transit 100.127.0.0/24 · nft chain spinifex-nat-egress masquerades out br-wan</text>
|
||||
<line x1="510.0" y1="464" x2="510.0" y2="480" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="68" y="482" width="884" height="100" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="88" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">SPINIFEX CONTROL PLANE</text>
|
||||
<text x="932" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">every service is one binary: spx service <name></text>
|
||||
<rect x="86" y="530" width="109.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="140.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw :9999</text>
|
||||
<rect x="204.01999999999998" y="530" width="74.9" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="241.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
|
||||
<rect x="287.94" y="530" width="61.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="318.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
|
||||
<rect x="358.22" y="530" width="61.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="388.9" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">NATS</text>
|
||||
<rect x="428.5" y="530" width="102.2" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="479.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
|
||||
<rect x="539.7" y="530" width="102.2" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="590.8" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
|
||||
<rect x="650.9000000000001" y="530" width="95.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="698.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">northstar</text>
|
||||
<rect x="755.2800000000001" y="530" width="88.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="799.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">ui :3000</text>
|
||||
<text x="86" y="568" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">SigV4 in, NATS in the middle, QEMU/KVM · OVN/OVS · Predastore · Viperblock underneath</text>
|
||||
<line x1="248" y1="582" x2="248" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<line x1="772" y1="582" x2="772" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
|
||||
<rect x="68" y="600" width="532" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="88" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">TENANT VPCs — OVN br-int</text>
|
||||
<text x="580" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">geneve-local</text>
|
||||
<rect x="86" y="644" width="496" height="34" rx="9" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="100" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-a 10.0.0.0/16</text>
|
||||
<text x="310" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 10.0.1.4 → 150.230.13.131</text>
|
||||
<rect x="86" y="686" width="496" height="34" rx="9" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="100" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-b 172.31.0.0/16</text>
|
||||
<text x="310" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 172.31.4.9 · private, egress only</text>
|
||||
<rect x="616" y="600" width="336" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="636" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">BLOCK VOLUME — iSCSI</text>
|
||||
<text x="932" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">256 GB+</text>
|
||||
<text x="636" y="658" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">169.254.2.2:3260 → /dev/sdb</text>
|
||||
<text x="636" y="680" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">/var/lib/spinifex</text>
|
||||
<text x="636" y="700" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">mounted by UUID, defaults,_netdev,nofail</text>
|
||||
<text x="636" y="718" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">viperblock · predastore · JetStream</text>
|
||||
<text x="28" y="796" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">A guest's public address is a real OCI reserved public IP, attached to a secondary private IP on br-wan.</text>
|
||||
<text x="28" y="814" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Inbound and outbound are therefore the same address, and OCI's per-VNIC source check passes.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,116 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="796" viewBox="0 0 1020 796" role="img" aria-label="Spinifex across three OCI instances: all three sit inside the VCN public subnet 10.200.0.0/23, one per fault domain. Each carries a primary VNIC for the cluster mesh and a br-wan secondary VNIC holding its reserved public addresses, its own iSCSI block volume, and its share of the guests. Geneve, NATS JetStream, predastore erasure coding and the OVN raft join them east-west.">
|
||||
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
|
||||
<rect x="0" y="0" width="1020" height="796" rx="20" fill="#14181D" stroke="#2A2F36" stroke-width="1"/>
|
||||
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON OCI — THREE NODES</text>
|
||||
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">three fault domains · one region · one API endpoint</text>
|
||||
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">Three OCI instances, redundant multi-node install</text>
|
||||
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
|
||||
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one endpoint, whichever node answers</text>
|
||||
<rect x="46" y="126" width="272.7" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="182.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
|
||||
<rect x="327.7" y="126" width="177.2" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="416.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest public IP</text>
|
||||
<rect x="513.92" y="126" width="218.1" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="623.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
|
||||
<line x1="510.0" y1="170" x2="510.0" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28" y="188" width="964" height="464" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
|
||||
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23</text>
|
||||
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">virtual router 10.200.0.1 · internet gateway</text>
|
||||
<rect x="48.0" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="66.0" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-01</text>
|
||||
<text x="327.3333333333333" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-1</text>
|
||||
<text x="66.0" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
|
||||
<line x1="66.0" y1="292" x2="327.3333333333333" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<text x="66.0" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.252</text>
|
||||
<text x="66.0" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
|
||||
<text x="66.0" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.233/32</text>
|
||||
<text x="66.0" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
|
||||
<rect x="66.0" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="91.5" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
|
||||
<rect x="123.0" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="151.6" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
|
||||
<rect x="186.2" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="208.6" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
|
||||
<rect x="66.0" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="88.4" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
|
||||
<rect x="116.8" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="157.8" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
|
||||
<rect x="204.8" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="245.8" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
|
||||
<rect x="66.0" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="76.0" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-23d2… 10.0.1.4 → 150.230.13.131</text>
|
||||
<rect x="66.0" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="76.0" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
|
||||
<line x1="345.3333333333333" y1="364" x2="361.3333333333333" y2="364" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.5" stroke-dasharray="2 4"/>
|
||||
<rect x="361.3333333333333" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="379.3333333333333" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-02</text>
|
||||
<text x="640.6666666666666" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-2</text>
|
||||
<text x="379.3333333333333" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
|
||||
<line x1="379.3333333333333" y1="292" x2="640.6666666666666" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<text x="379.3333333333333" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.118</text>
|
||||
<text x="379.3333333333333" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
|
||||
<text x="379.3333333333333" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.96/32</text>
|
||||
<text x="379.3333333333333" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
|
||||
<rect x="379.3333333333333" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="404.8" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
|
||||
<rect x="436.3333333333333" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="464.9" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
|
||||
<rect x="499.5333333333333" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="521.9" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
|
||||
<rect x="379.3333333333333" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="401.7" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
|
||||
<rect x="430.1333333333333" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="471.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
|
||||
<rect x="518.1333333333333" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="559.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
|
||||
<rect x="379.3333333333333" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="389.3333333333333" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-6b91… 10.0.1.9 → 137.23.9.125</text>
|
||||
<rect x="379.3333333333333" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="389.3333333333333" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
|
||||
<line x1="658.6666666666666" y1="364" x2="674.6666666666666" y2="364" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.5" stroke-dasharray="2 4"/>
|
||||
<rect x="674.6666666666666" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="692.6666666666666" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-03</text>
|
||||
<text x="954.0" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-3</text>
|
||||
<text x="692.6666666666666" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
|
||||
<line x1="692.6666666666666" y1="292" x2="954.0" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<text x="692.6666666666666" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.41</text>
|
||||
<text x="692.6666666666666" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
|
||||
<text x="692.6666666666666" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.12/32</text>
|
||||
<text x="692.6666666666666" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
|
||||
<rect x="692.6666666666666" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="718.2" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
|
||||
<rect x="749.6666666666666" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="778.3" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
|
||||
<rect x="812.8666666666667" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="835.3" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
|
||||
<rect x="692.6666666666666" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="715.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
|
||||
<rect x="743.4666666666666" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="784.5" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
|
||||
<rect x="831.4666666666666" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="872.5" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
|
||||
<rect x="692.6666666666666" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="702.6666666666666" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-0ae4… 10.0.2.6 → 150.230.4.88</text>
|
||||
<rect x="692.6666666666666" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="702.6666666666666" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
|
||||
<rect x="48" y="524" width="924" height="104" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="68" y="554" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">EAST–WEST, INSIDE THE VCN</text>
|
||||
<text x="952" y="554" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">no public address is involved</text>
|
||||
<rect x="66" y="574" width="136.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="134.2" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">Geneve UDP 6081</text>
|
||||
<rect x="211.3" y="574" width="149.9" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="286.3" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">NATS JetStream R3</text>
|
||||
<rect x="370.24" y="574" width="156.8" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="448.6" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore RS(2,1)</text>
|
||||
<rect x="536.0" y="574" width="163.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="617.8" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">OVN Northbound raft</text>
|
||||
<rect x="708.58" y="574" width="68.1" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="742.6" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">OVSDB</text>
|
||||
<text x="66" y="612" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Guest-to-guest traffic rides Geneve between the nodes' primary VNICs — it never leaves the subnet and never touches br-wan.</text>
|
||||
<rect x="28" y="668" width="964" height="46" rx="12" fill="#EE4B12" fill-opacity="0.07" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.55"/>
|
||||
<text x="48" y="688" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13" fill="#F5F3EE" font-weight="700" text-anchor="start">A guest that moves takes its address with it.</text>
|
||||
<text x="48" y="705" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">The reserved public IP follows the secondary private IP to whichever node's br-wan the guest lands on — the tenant sees no change.</text>
|
||||
<text x="28" y="756" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Three nodes is the smallest cluster that tolerates losing one: NATS JetStream keeps quorum, predastore RS(2,1)</text>
|
||||
<text x="28" y="774" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">keeps the data readable, and the OVN Northbound raft keeps a leader. One node per fault domain is what makes that real.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
|
||||
<rect width="760" height="470" fill="#ffffff"/>
|
||||
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Guest block storage throughput (Viperblock)</text>
|
||||
<text x="74" y="49" font-size="12.5" fill="#6b7280">fio, 4 jobs, iodepth 32, direct I/O, per-guest sequential runs — Spinifex v1.20.0</text>
|
||||
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">0</text>
|
||||
<line x1="74" y1="305.0" x2="734" y2="305.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="309.0" font-size="12" text-anchor="end" fill="#6b7280">100</text>
|
||||
<line x1="74" y1="224.0" x2="734" y2="224.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="228.0" font-size="12" text-anchor="end" fill="#6b7280">200</text>
|
||||
<line x1="74" y1="143.0" x2="734" y2="143.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="147.0" font-size="12" text-anchor="end" fill="#6b7280">300</text>
|
||||
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">400</text>
|
||||
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">MiB/s</text>
|
||||
<rect x="111.4" y="81.4" width="45.4" height="304.6" rx="2.5" fill="#2563eb"/>
|
||||
<text x="134.1" y="75.4" font-size="11" text-anchor="middle" fill="#1f2937">376</text>
|
||||
<rect x="159.8" y="198.1" width="45.4" height="187.9" rx="2.5" fill="#0d9488"/>
|
||||
<text x="182.5" y="192.1" font-size="11" text-anchor="middle" fill="#1f2937">232</text>
|
||||
<rect x="208.2" y="299.3" width="45.4" height="86.7" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="230.9" y="293.3" font-size="11" text-anchor="middle" fill="#1f2937">107</text>
|
||||
<text x="184.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">GPU</text>
|
||||
<rect x="331.4" y="85.5" width="45.4" height="300.5" rx="2.5" fill="#2563eb"/>
|
||||
<text x="354.1" y="79.5" font-size="11" text-anchor="middle" fill="#1f2937">371</text>
|
||||
<rect x="379.8" y="202.9" width="45.4" height="183.1" rx="2.5" fill="#0d9488"/>
|
||||
<text x="402.5" y="196.9" font-size="11" text-anchor="middle" fill="#1f2937">226</text>
|
||||
<rect x="428.2" y="321.2" width="45.4" height="64.8" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="450.9" y="315.2" font-size="11" text-anchor="middle" fill="#1f2937">80</text>
|
||||
<text x="404.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU1</text>
|
||||
<rect x="551.4" y="84.7" width="45.4" height="301.3" rx="2.5" fill="#2563eb"/>
|
||||
<text x="574.1" y="78.7" font-size="11" text-anchor="middle" fill="#1f2937">372</text>
|
||||
<rect x="599.8" y="201.3" width="45.4" height="184.7" rx="2.5" fill="#0d9488"/>
|
||||
<text x="622.5" y="195.3" font-size="11" text-anchor="middle" fill="#1f2937">228</text>
|
||||
<rect x="648.2" y="321.2" width="45.4" height="64.8" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="670.9" y="315.2" font-size="11" text-anchor="middle" fill="#1f2937">80</text>
|
||||
<text x="624.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU2</text>
|
||||
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
|
||||
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
|
||||
<text x="93" y="455" font-size="12.5" fill="#1f2937">128K random read</text>
|
||||
<rect x="216.8" y="444" width="13" height="13" rx="2.5" fill="#0d9488"/>
|
||||
<text x="235.8" y="455" font-size="12.5" fill="#1f2937">128K mixed read</text>
|
||||
<rect x="352.3" y="444" width="13" height="13" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="371.3" y="455" font-size="12.5" fill="#1f2937">16K random read</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
|
||||
<rect width="760" height="470" fill="#ffffff"/>
|
||||
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Network throughput: overlay vs physical fabric</text>
|
||||
<text x="74" y="49" font-size="12.5" fill="#6b7280">iperf3 TCP receiver goodput, median across node pairs — Spinifex v1.20.0</text>
|
||||
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">20</text>
|
||||
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">25</text>
|
||||
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">Gbit/s</text>
|
||||
<text x="140.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">1</text>
|
||||
<text x="272.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">2</text>
|
||||
<text x="404.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">4</text>
|
||||
<text x="536.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">8</text>
|
||||
<text x="668.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">16</text>
|
||||
<path d="M140.0,164.4 L272.0,160.5 L404.0,160.5 L536.0,160.5 L668.0,160.5" fill="none" stroke="#2563eb" stroke-width="3"/>
|
||||
<circle cx="140.0" cy="164.4" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<circle cx="272.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<circle cx="404.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<circle cx="536.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<circle cx="668.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="664.0" y="149.5" font-size="11.5" text-anchor="end" font-weight="600" fill="#2563eb">23.48</text>
|
||||
<path d="M140.0,255.1 L272.0,222.1 L404.0,222.1 L536.0,222.1 L668.0,228.5" fill="none" stroke="#0d9488" stroke-width="3"/>
|
||||
<circle cx="140.0" cy="255.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
|
||||
<circle cx="272.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
|
||||
<circle cx="404.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
|
||||
<circle cx="536.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
|
||||
<circle cx="668.0" cy="228.5" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
|
||||
<text x="664.0" y="217.5" font-size="11.5" text-anchor="end" font-weight="600" fill="#0d9488">22.43</text>
|
||||
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
|
||||
<text x="404.0" y="430.0" font-size="12.5" text-anchor="middle" fill="#6b7280">parallel TCP streams</text>
|
||||
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
|
||||
<text x="93" y="455" font-size="12.5" fill="#1f2937">Host physical fabric (25GbE)</text>
|
||||
<rect x="304.4" y="444" width="13" height="13" rx="2.5" fill="#0d9488"/>
|
||||
<text x="323.4" y="455" font-size="12.5" fill="#1f2937">Guest overlay (OVN)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
|
||||
<rect width="760" height="470" fill="#ffffff"/>
|
||||
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Predastore S3 read throughput by client</text>
|
||||
<text x="74" y="49" font-size="12.5" fill="#6b7280">1 GiB objects, same presigned-curl client on every path — Spinifex v1.20.0</text>
|
||||
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">0</text>
|
||||
<line x1="74" y1="278.0" x2="734" y2="278.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="282.0" font-size="12" text-anchor="end" fill="#6b7280">200</text>
|
||||
<line x1="74" y1="170.0" x2="734" y2="170.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="174.0" font-size="12" text-anchor="end" fill="#6b7280">400</text>
|
||||
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">600</text>
|
||||
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">MiB/s</text>
|
||||
<rect x="102.0" y="202.4" width="51.5" height="183.6" rx="2.5" fill="#2563eb"/>
|
||||
<text x="127.8" y="196.4" font-size="11" text-anchor="middle" fill="#1f2937">340</text>
|
||||
<rect x="156.5" y="136.5" width="51.5" height="249.5" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="182.2" y="130.5" font-size="11" text-anchor="middle" fill="#1f2937">462</text>
|
||||
<text x="156.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">Host</text>
|
||||
<rect x="267.1" y="170.5" width="51.5" height="215.5" rx="2.5" fill="#2563eb"/>
|
||||
<text x="292.8" y="164.5" font-size="11" text-anchor="middle" fill="#1f2937">399</text>
|
||||
<rect x="321.5" y="174.9" width="51.5" height="211.1" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="347.2" y="168.9" font-size="11" text-anchor="middle" fill="#1f2937">391</text>
|
||||
<text x="321.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">GPU guest</text>
|
||||
<rect x="432.1" y="102.5" width="51.5" height="283.5" rx="2.5" fill="#2563eb"/>
|
||||
<text x="457.8" y="96.5" font-size="11" text-anchor="middle" fill="#1f2937">525</text>
|
||||
<rect x="486.5" y="197.0" width="51.5" height="189.0" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="512.2" y="191.0" font-size="11" text-anchor="middle" fill="#1f2937">350</text>
|
||||
<text x="486.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU1 guest</text>
|
||||
<rect x="597.0" y="106.8" width="51.5" height="279.2" rx="2.5" fill="#2563eb"/>
|
||||
<text x="622.8" y="100.8" font-size="11" text-anchor="middle" fill="#1f2937">517</text>
|
||||
<rect x="651.5" y="196.5" width="51.5" height="189.5" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="677.2" y="190.5" font-size="11" text-anchor="middle" fill="#1f2937">351</text>
|
||||
<text x="651.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU2 guest</text>
|
||||
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
|
||||
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
|
||||
<text x="93" y="455" font-size="12.5" fill="#1f2937">Single-stream GET</text>
|
||||
<rect x="224.1" y="444" width="13" height="13" rx="2.5" fill="#f59e0b"/>
|
||||
<text x="243.1" y="455" font-size="12.5" fill="#1f2937">Chunked/concurrent GET</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -143,5 +143,52 @@
|
||||
"extra": "",
|
||||
"suite": "storagefault",
|
||||
"timeout_minutes": 100
|
||||
},
|
||||
{
|
||||
"cell": 30,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "nat",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "nat-multi",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 31,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": "instancerecovery",
|
||||
"timeout_minutes": 105
|
||||
},
|
||||
{
|
||||
"cell": 32,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": "lbrecovery",
|
||||
"timeout_minutes": 90
|
||||
},
|
||||
{
|
||||
"cell": 33,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "static",
|
||||
"host": "debian-13",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": "kvquorum",
|
||||
"timeout_minutes": 55
|
||||
}
|
||||
]
|
||||
|
||||
@@ -40,7 +40,24 @@
|
||||
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
|
||||
|
||||
# Suites runnable against a multi-node environment.
|
||||
E2E_SUITES_MULTI="multinode lb cert quota storagefault"
|
||||
#
|
||||
# instancerecovery is multi-only and needs three: recovery is inert below that,
|
||||
# because there is no survivor to move a guest to that is not the node that
|
||||
# failed. Like storagefault it takes a node away and freezes a cluster-wide
|
||||
# service, so it runs last and owns its environment.
|
||||
#
|
||||
# lbrecovery is multi-only and needs three for two reasons rather than one: a
|
||||
# survivor to move the load balancer to, and a node for its backends that is not
|
||||
# the load balancer's, so taking that node away does not take the backends with
|
||||
# it. It runs beside instancerecovery at the end because it also takes a node
|
||||
# away and owns its environment while it does.
|
||||
#
|
||||
# kvquorum is multi-only and needs three, for the same reason as those two: a
|
||||
# surviving majority. It is what proves the control plane's own state is
|
||||
# replicated rather than sitting on one node, so it runs before the pair that
|
||||
# take a node away — a cluster whose buckets are already wrong should report
|
||||
# that, not report a recovery failing.
|
||||
E2E_SUITES_MULTI="multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault"
|
||||
|
||||
# grep -xE alternation form, for narrowing a requested set to the eligible one.
|
||||
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
|
||||
@@ -64,6 +81,10 @@ E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
|
||||
# after a failed seal do not exist on one node, and they are the assertions
|
||||
# worth a nightly.
|
||||
E2E_SUITES_NIGHTLY_SINGLE="single cert iam"
|
||||
#
|
||||
# The multi-node nightly subset carries kvquorum's read-only half through
|
||||
# `multinode`, which sweeps every KV bucket rather than the three the daemon
|
||||
# owns. The node-loss half is too slow for a 35-minute cell and has its own.
|
||||
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
|
||||
|
||||
# How long a suite gets, for the same reason the lists above live here: the
|
||||
@@ -88,6 +109,23 @@ e2e_suite_timeout() {
|
||||
# SIGSTOPped for seven minutes to prove a guest survives it. A full pass on
|
||||
# dev-prod is ~63 minutes, so the 30m default truncated it mid-suite.
|
||||
storagefault) echo "90m" ;;
|
||||
# instancerecovery is almost entirely waiting, and none of it is avoidable:
|
||||
# a dead owner has to be seen stale twice, a guest has to boot, and the
|
||||
# storage case waits for a pause measured at 4m47s and then holds four
|
||||
# minutes to prove nothing moved. Three tests, each taking a node away and
|
||||
# bringing it back, and the partition case adds a lease validity, a lease
|
||||
# TTL and a boot on top of everything the other two wait for.
|
||||
instancerecovery) echo "90m" ;;
|
||||
# lbrecovery pays for a load balancer twice: once to stand one up with two
|
||||
# backends placed around it, and once for the recovery itself — a stale
|
||||
# owner, a lease, a boot, HAProxy's own start, and its health checks against
|
||||
# the backends before it forwards anything. Then the node comes back and has
|
||||
# to be shown not to answer for an address it no longer holds.
|
||||
lbrecovery) echo "75m" ;;
|
||||
# kvquorum audits every bucket in seconds and then spends the rest of its
|
||||
# budget on a node stopping and starting: seven units down, a raft election,
|
||||
# and the whole service stack back up with every stream caught up.
|
||||
kvquorum) echo "40m" ;;
|
||||
*) echo "30m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
name: E2E Nightly
|
||||
|
||||
# Triggered nightly by a Cloudflare Worker that dispatches this workflow once on
|
||||
# `dev` and once on `main`, rather than by a `schedule:` here. GitHub registers a
|
||||
# schedule only from the default branch, so a cron in this file can never run
|
||||
# `main` and `dev` as separate nightlies, and it would always execute `main`'s
|
||||
# copy of this file — leaving a fix to the workflow itself untestable until it
|
||||
# had already shipped. A dispatch takes both the file and the source from its ref.
|
||||
on:
|
||||
schedule:
|
||||
# 14:00 UTC is midnight AEST (01:00 AEDT — cron has no DST), when the
|
||||
# hypervisors are idle. Every day, not weeknights: a weekend gap is two runs
|
||||
# of evidence lost on a suite whose remaining failures are a rate, not a bug.
|
||||
# :17 rather than :00 because GitHub queues scheduled runs behind every other
|
||||
# top-of-hour cron and drops them under load, which is why 2026-09-14 never
|
||||
# fired and why every run in that window started 3h15m-3h45m late.
|
||||
- cron: "17 14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection"
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
@@ -33,7 +31,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
@@ -280,8 +278,31 @@ jobs:
|
||||
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
|
||||
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
|
||||
|
||||
# natuplink proves a guest with a public IP reaches the internet. The two
|
||||
# surfaces it never touches are a private subnet's isolation and its NAT
|
||||
# gateway, and TestVPCEgressPaths is the authority on both — it skipped in
|
||||
# nat mode until the fixture learned that routed NAT carries EIPs too.
|
||||
- name: Run VPC Egress Paths in NAT mode
|
||||
if: matrix.extra == 'nat-single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
|
||||
echo '::group::suite single/TestVPCEgressPaths'; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-vpcegress-nat.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./single.test -test.v -test.timeout=30m -test.run '^TestVPCEgressPaths\$' \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-vpcegress-nat.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
|
||||
if [ \$rc -ne 0 ]; then echo 'vpcegress-nat: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
|
||||
|
||||
- name: Run Multi Node E2E
|
||||
if: matrix.topo == 'real-multi' && matrix.suite == ''
|
||||
if: matrix.topo == 'real-multi' && matrix.suite == '' && matrix.extra == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -316,6 +337,40 @@ jobs:
|
||||
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
|
||||
done; exit \$FAIL"
|
||||
|
||||
# The same suite as the nat-single cell, run against three nodes: there it
|
||||
# proves routed NAT works at all, here that each node delivers its own
|
||||
# guests' public IPs and no others — which one node cannot show.
|
||||
- name: Run NAT Uplink Multi E2E
|
||||
if: matrix.extra == 'nat-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
|
||||
NODES_CSV=""
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
|
||||
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
|
||||
done
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
|
||||
if [ ! -f natuplink.test ]; then echo 'natuplink.test not in tests tar (branch predates suite) — skipping'; exit 0; fi; \
|
||||
echo '::group::suite natuplink'; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-natuplink.start'; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
|
||||
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
|
||||
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./natuplink.test -test.v -test.timeout=30m \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-natuplink.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
|
||||
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
|
||||
|
||||
# The dedicated suite cells only. Resolved once here rather than in each
|
||||
# step below, which would repeat the same three lines five times.
|
||||
- name: Resolve VM SSH details
|
||||
@@ -822,12 +877,11 @@ jobs:
|
||||
|
||||
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
|
||||
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
|
||||
# matrix cell ("cell 20"): every nightly cron, or dispatch with 20 selected
|
||||
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
|
||||
# matrix cell ("cell 20"): blank cells is the whole nightly, or name 20 to run
|
||||
# it alone. A reusable call needs its own ref, since it cannot read GIT_BRANCH.
|
||||
baremetal:
|
||||
name: cell-20 (baremetal/pxe/real-hw)
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',20,')
|
||||
permissions:
|
||||
@@ -835,7 +889,7 @@ jobs:
|
||||
statuses: write
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
ref: ${{ github.ref_name }}
|
||||
# A reusable-workflow call gets none of this workflow's context (env:
|
||||
# above, matrix.cell) for free — cell-20 needs its own attrs string.
|
||||
# There's no matrix leg here, just the one fixed baremetal cell.
|
||||
@@ -855,7 +909,6 @@ jobs:
|
||||
needs: baremetal
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',28,')
|
||||
)
|
||||
@@ -864,7 +917,7 @@ jobs:
|
||||
statuses: write
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
ref: ${{ github.ref_name }}
|
||||
cell: "28"
|
||||
suites: diskperf
|
||||
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=28,ci.matrix=baremetal/pxe/diskperf
|
||||
@@ -881,7 +934,6 @@ jobs:
|
||||
iso-single:
|
||||
name: cell-21 (iso/dhcp/single)
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',21,')
|
||||
runs-on: [self-hosted, ci-single]
|
||||
@@ -1136,7 +1188,6 @@ jobs:
|
||||
needs: [iso-single]
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',22,') )
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
@@ -1298,7 +1349,6 @@ jobs:
|
||||
needs: [matrix, baremetal, iso-single, iso-multi]
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',23,') )
|
||||
runs-on: [self-hosted, ci-single]
|
||||
@@ -1329,7 +1379,7 @@ jobs:
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
@@ -1410,7 +1460,6 @@ jobs:
|
||||
needs: [matrix, baremetal, iso-single, iso-multi, performance]
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',24,') )
|
||||
runs-on: [self-hosted, ci-single]
|
||||
@@ -1454,7 +1503,7 @@ jobs:
|
||||
- name: Resolve sibling refs
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
@@ -1493,8 +1542,16 @@ jobs:
|
||||
|
||||
# Leftovers from a run killed before its cleanup trap: a surviving node
|
||||
# holds its ports and fails this run somewhere unrelated.
|
||||
#
|
||||
# Under the host benchmark lock, because this pattern matches every
|
||||
# predastore node on the runner and the predastore repository runs the
|
||||
# same harnesses on the same machine. Holding the lock is what makes
|
||||
# "still running" mean "stale" rather than "somebody else's benchmark":
|
||||
# on 2026-09-16 this step killed a predastore run 32s into its workload.
|
||||
- name: Clear stale nodes
|
||||
run: pkill -KILL -f '/bin/s3d -config' || true
|
||||
run: |
|
||||
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
|
||||
pkill -KILL -f '/bin/s3d -config' || true
|
||||
|
||||
- name: Run fault injection
|
||||
working-directory: predastore
|
||||
@@ -1514,10 +1571,14 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 90
|
||||
|
||||
# Under the lock for the same reason as "Clear stale nodes": by the time
|
||||
# this runs the harness has released it, so another repository's benchmark
|
||||
# can already have a cluster of its own up.
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
pkill -KILL -f '/bin/s3d -config' || true
|
||||
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
|
||||
pkill -KILL -f '/bin/s3d -config' || true
|
||||
rm -rf "$STRESS_RESULTS_ROOT"
|
||||
|
||||
# Reading a nightly meant opening every cell job, and the Actions UI does not
|
||||
|
||||
+1
-2
@@ -56,7 +56,6 @@ linters:
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- usestdlibvars # detects hardcoded values that should use stdlib constants
|
||||
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
|
||||
- wastedassign # finds assignments to variables that are never subsequently read
|
||||
- whitespace # detects leading and trailing whitespace in functions
|
||||
|
||||
settings:
|
||||
@@ -151,7 +150,7 @@ linters:
|
||||
- -ST1020 # godoc on exported types — not enforcing yet
|
||||
- -ST1021 # godoc on exported type groups — not enforcing yet
|
||||
- -ST1022 # godoc on exported vars/consts — not enforcing yet
|
||||
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
|
||||
- -SA4006 # value overwritten — ineffassign catches strictly more of these
|
||||
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
|
||||
|
||||
testifylint:
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# How to Contribute
|
||||
|
||||
We welcome contributions to Spinifex!
|
||||
|
||||
Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), not as a public issue.
|
||||
|
||||
**AWS parity is the design rule.** When Spinifex's behaviour is in question, the answer is what AWS does. If you believe Spinifex diverges from AWS, state what AWS does and show it with a test or a reproduction against a running cluster.
|
||||
|
||||
## Understanding the Codebase
|
||||
|
||||
Every AWS call enters through a SigV4-authenticated gateway, is published to NATS, and is answered by the daemon that claims it. Start with these:
|
||||
|
||||
| Doc | Covers |
|
||||
|---|---|
|
||||
| [`docs/DESIGN.md`](docs/DESIGN.md) | Architecture, request flow, daemons, storage integration, file layout. |
|
||||
| [`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. |
|
||||
|
||||
User-facing docs are published at [docs.mulgadc.com](https://docs.mulgadc.com).
|
||||
|
||||
## Development Setup
|
||||
|
||||
These steps build Spinifex from source and run it as a single-node cluster, following the [Source Install](https://docs.mulgadc.com/docs/install-source) guide.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Ubuntu 26.04** or **Debian 13**, on x86_64 or aarch64
|
||||
- A host with hardware virtualisation (KVM) and `sudo` access
|
||||
- The host's WAN interface enslaved to a Linux bridge named `br-wan`, with the host IP, default route and DHCP on the bridge rather than the bare NIC (see [VPC Networking → Bridge Setup](https://docs.mulgadc.com/docs/vpc-networking#bridge-setup-physical-network-wiring))
|
||||
|
||||
Check the bridge before continuing:
|
||||
|
||||
```bash
|
||||
ip -br link show br-wan # bridge exists and is UP
|
||||
ip route # default route's dev is br-wan
|
||||
```
|
||||
|
||||
### Install and Run
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Development/mulga && cd ~/Development/mulga
|
||||
git clone https://github.com/mulgadc/spinifex.git
|
||||
sudo make -C spinifex quickinstall # system packages, Go 1.27, AWS CLI v2
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
cd spinifex
|
||||
./scripts/clone-deps.sh # sibling repos: viperblock, predastore, northstar, bluebottle
|
||||
./scripts/dev-install.sh # build, install, init a one-node cluster, start services
|
||||
|
||||
AWS_PROFILE=spinifex aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
A list of instance types means the cluster is up.
|
||||
|
||||
`make preflight` also needs `shellcheck` and `golangci-lint` (CI pins v2.13.0):
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y shellcheck
|
||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.13.0
|
||||
```
|
||||
|
||||
The web console additionally needs Node.js 24 and `pnpm`.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
| Command | Use it to |
|
||||
|---|---|
|
||||
| `make deploy` | Rebuild, install `spx`, and restart `spinifex.target` after a code change. |
|
||||
| `make reinstall` | Re-run `dev-install.sh` after changing systemd units, helper scripts, or logrotate config. |
|
||||
| `./scripts/reset-dev-env.sh` | Wipe all cluster state and reinstall from scratch, keeping your network topology. |
|
||||
| `make test` | Run unit tests (`make test-race` for the race detector). |
|
||||
| `make test-integration` | Run the real gateway router against embedded NATS JetStream. |
|
||||
| `make preflight` | Run the pre-commit gate: lint, `govulncheck`, coverage, and manifest and script checks. Must pass. |
|
||||
|
||||
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.
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
1. Fork [mulgadc/spinifex](https://github.com/mulgadc/spinifex) and clone your fork into the same parent directory as the sibling repositories, branching off `dev`:
|
||||
|
||||
```bash
|
||||
cd ~/Development/mulga
|
||||
git clone git@github.com:YOUR_USERNAME/spinifex.git && cd spinifex
|
||||
git remote add upstream git@github.com:mulgadc/spinifex.git
|
||||
git fetch upstream && git checkout -b feat/my-feature upstream/dev
|
||||
```
|
||||
|
||||
2. Make your change, then verify it:
|
||||
- `make preflight` passes.
|
||||
- `go mod tidy` leaves only intended changes in `go.mod` and `go.sum`, with none of the `clone-deps.sh` `replace` lines.
|
||||
- Runtime changes are exercised on your dev cluster with `make deploy`.
|
||||
|
||||
3. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:`, `docs:`, `chore:`), push, and open a pull request against `dev`, filling in the template.
|
||||
@@ -149,21 +149,38 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
|
||||
|
||||
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
|
||||
# integration and race tests skipped to keep quick, they run in CI.
|
||||
#
|
||||
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
|
||||
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
|
||||
# -Otarget keeps each gate's output in one block instead of interleaving them.
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-package-check test-harness test-build-scripts
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-package-check test-harness test-build-scripts
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
|
||||
# ship on every node (unlike scripts/images/, kept in preflight: a wrong
|
||||
# restart decision here is an outage, not asset churn).
|
||||
#
|
||||
# NODE_SCRIPTS are the four that setup.sh installs into
|
||||
# /usr/local/share/spinifex on every node. They are covered here for the same
|
||||
# reason: between them they install the software, wire OVN, erase the state and
|
||||
# remove the install, so a shellcheck warning in one is an outage or a wrong
|
||||
# deletion, not a style note.
|
||||
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/node-reset.sh scripts/uninstall-spx.sh
|
||||
|
||||
test-build-scripts:
|
||||
@echo -e "\n....Running build/scripts/**/*_test.sh...."
|
||||
@for t in $$(find build/scripts -name '*_test.sh' | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh...."
|
||||
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort)
|
||||
@echo -e "\n....Running scripts/*_test.sh...."
|
||||
@for t in $$(find scripts -maxdepth 1 -name '*_test.sh' | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
|
||||
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
|
||||
@echo " test-build-scripts ok"
|
||||
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
@@ -185,16 +202,18 @@ AWS_MODEL_OPERATION_COVERAGE_DIR ?= $(CURDIR)/docs/coverage
|
||||
# -count=1 is load-bearing: the conformance report is written by the test binary,
|
||||
# so a cached pass skips the run, leaves no report, and the cat below fails the
|
||||
# target. It also keeps the conformance gate honest — a cached result would mean
|
||||
# the check never ran against this commit.
|
||||
# the check never ran against this commit. The generated request sweep adds
|
||||
# about 15s, most of it EC2 fan-outs waiting out their window for instances
|
||||
# no daemon-lite owns.
|
||||
test-integration:
|
||||
@echo -e "\n....Running in-process integration tests...."
|
||||
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 120s ./tests/integration/... $(_RACEQ)
|
||||
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
|
||||
@$(MAKE) --no-print-directory generate-aws-model-coverage
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR)
|
||||
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR) -json $(AWS_MODEL_OPERATION_COVERAGE_DIR)/coverage.json
|
||||
|
||||
aws-model-coverage: generate-aws-model-coverage
|
||||
@go run ./cmd/aws-model-coverage
|
||||
|
||||
@@ -14,18 +14,26 @@ import (
|
||||
|
||||
func main() {
|
||||
outputDir := flag.String("out", "", "write one publishable page per service into this directory")
|
||||
jsonOutput := flag.String("json", "", "write a machine-readable coverage inventory to this file")
|
||||
flag.Parse()
|
||||
|
||||
coverages, err := compareAll()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if *outputDir == "" {
|
||||
if *outputDir == "" && *jsonOutput == "" {
|
||||
fmt.Print(awsmodel.RenderCoverageSummary(coverages))
|
||||
return
|
||||
}
|
||||
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
|
||||
fail(err)
|
||||
if *outputDir != "" {
|
||||
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if *jsonOutput != "" {
|
||||
if err := awsmodel.WriteCoverageJSON(*jsonOutput, coverages); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-22
@@ -30,10 +30,13 @@ type Agent struct {
|
||||
cfg config
|
||||
id identity
|
||||
cp controlPlane
|
||||
puller ctrruntime.ImagePuller
|
||||
runner ctrruntime.Runner
|
||||
resolver ctrruntime.Resolver
|
||||
|
||||
// rt holds the container runtime, which is resolved by a loop rather than
|
||||
// once at boot. Empty until the containerd socket answers, and emptied again
|
||||
// if it stops answering.
|
||||
rt *runtimeGate
|
||||
|
||||
reg *registrar
|
||||
hb *heartbeater
|
||||
|
||||
@@ -48,26 +51,34 @@ type Agent struct {
|
||||
// nvidia-smi discovery.
|
||||
gpu *gpuLedger
|
||||
|
||||
// dialRuntime connects to the container runtime. nil in unit tests, which
|
||||
// drive the gate directly.
|
||||
dialRuntime func() (ctrruntime.Runtime, error)
|
||||
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
// newAgent assembles an Agent from already-built seams. Tests use this directly
|
||||
// with fakes; New builds the production seams and delegates here. runner may be
|
||||
// nil when containerd is unavailable; the assign path then reports the task
|
||||
// STOPPED rather than crashing.
|
||||
func newAgent(cfg config, id identity, cp controlPlane, puller ctrruntime.ImagePuller, runner ctrruntime.Runner, resolver ctrruntime.Resolver) *Agent {
|
||||
return &Agent{
|
||||
// with fakes; New builds the production seams and delegates here. rt may be nil
|
||||
// when containerd is unavailable; the assign path then reports the task STOPPED
|
||||
// rather than crashing, and the watch loop keeps dialling for it.
|
||||
func newAgent(cfg config, id identity, cp controlPlane, rt ctrruntime.Runtime, resolver ctrruntime.Resolver) *Agent {
|
||||
gate := newRuntimeGate(rt)
|
||||
a := &Agent{
|
||||
cfg: cfg,
|
||||
id: id,
|
||||
cp: cp,
|
||||
puller: puller,
|
||||
runner: runner,
|
||||
resolver: resolver,
|
||||
reg: newRegistrar(cp, id),
|
||||
hb: newHeartbeater(cp, id, cfg.Heartbeat),
|
||||
rt: gate,
|
||||
netns: newTaskNetns(execNetRunner{}),
|
||||
gpu: newGPULedger(id.Capacity.GPUIDs),
|
||||
}
|
||||
// Registration reports the host's capacity only while a runtime is there to
|
||||
// use it, so an agent that cannot run a container advertises none and takes
|
||||
// no placement.
|
||||
a.reg = newRegistrar(cp, id, gate.available)
|
||||
a.hb = newHeartbeater(cp, id, cfg.Heartbeat, gate.available)
|
||||
return a
|
||||
}
|
||||
|
||||
// New builds an Agent from config: it resolves the host identity from IMDS,
|
||||
@@ -89,6 +100,7 @@ func New(cfg config) (*Agent, error) {
|
||||
ClusterName: cfg.ClusterName,
|
||||
InstanceID: meta.InstanceID,
|
||||
AZ: meta.AZ,
|
||||
InstanceType: meta.InstanceType,
|
||||
Hostname: host,
|
||||
Capacity: detectCapacity(discoverNvidiaGPUs(execCommandRunner)),
|
||||
AgentVersion: version,
|
||||
@@ -97,13 +109,12 @@ func New(cfg config) (*Agent, error) {
|
||||
creds := credentials.NewIMDSProvider(imdsClient, cfg.IMDSBase)
|
||||
resolver := newLazyECRResolver(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA)
|
||||
|
||||
var puller ctrruntime.ImagePuller
|
||||
var runner ctrruntime.Runner
|
||||
if rt, perr := ctrruntime.New(cfg.ContainerdSocket); perr != nil {
|
||||
slog.Warn("ecs-agent: containerd unavailable at boot, image pulls disabled", "err", perr)
|
||||
} else {
|
||||
puller = rt
|
||||
runner = rt
|
||||
// A dial that fails here is not fatal and not final: the node starts the
|
||||
// container daemon and the agent back to back, so the socket is often not
|
||||
// accepting yet. Run's watch loop keeps dialling.
|
||||
rt, perr := ctrruntime.New(cfg.ContainerdSocket)
|
||||
if perr != nil {
|
||||
slog.Warn("ecs-agent: containerd unavailable at boot, will keep retrying", "err", perr)
|
||||
}
|
||||
|
||||
cp, err := newGatewayControlPlane(cfg, creds)
|
||||
@@ -111,12 +122,10 @@ func New(cfg config) (*Agent, error) {
|
||||
return nil, fmt.Errorf("build gateway control-plane: %w", err)
|
||||
}
|
||||
|
||||
a := newAgent(cfg, id, cp, puller, runner, resolver)
|
||||
a := newAgent(cfg, id, cp, rt, resolver)
|
||||
a.cred = newCredEndpoint(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA,
|
||||
cfg.CredEndpointIP, cfg.CredEndpointPort, execNetRunner{})
|
||||
if puller != nil {
|
||||
a.closers = append(a.closers, puller.Close)
|
||||
}
|
||||
a.dialRuntime = func() (ctrruntime.Runtime, error) { return ctrruntime.New(cfg.ContainerdSocket) }
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -137,6 +146,12 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
|
||||
go a.hb.Run(ctx)
|
||||
|
||||
// Keeps the runtime current for the life of the process: dials while there
|
||||
// is none, and drops one that stops answering.
|
||||
if a.dialRuntime != nil {
|
||||
go a.rt.watch(ctx, a.dialRuntime, a.cfg.RuntimeRetry)
|
||||
}
|
||||
|
||||
// Re-adopt containers still running from before this restart, then poll with
|
||||
// their tasks pre-seeded so re-delivered assignments are acked, not re-run.
|
||||
go a.pollAssignments(ctx, a.reconcile(ctx))
|
||||
@@ -156,6 +171,7 @@ func (a *Agent) Stop() error {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
a.rt.clear()
|
||||
return firstErr
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestAgent_RunRegistersThenStopsOnContext(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
cfg := config{Heartbeat: 5 * time.Millisecond, PollInterval: 5 * time.Millisecond}
|
||||
puller := &ctrruntime.FakePuller{}
|
||||
a := newAgent(cfg, testIdentity(), cp, puller, puller, nil)
|
||||
a := newAgent(cfg, testIdentity(), cp, puller, nil)
|
||||
a.closers = append(a.closers, puller.Close)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -33,6 +33,7 @@ type config struct {
|
||||
ContainerdSocket string
|
||||
Heartbeat time.Duration
|
||||
PollInterval time.Duration
|
||||
RuntimeRetry time.Duration
|
||||
CredEndpointIP string
|
||||
CredEndpointPort int
|
||||
}
|
||||
@@ -75,6 +76,12 @@ func loadConfig(envFile string) config {
|
||||
cfg.PollInterval = d
|
||||
}
|
||||
}
|
||||
cfg.RuntimeRetry = defaultRuntimeRetry
|
||||
if v := get("ECS_RUNTIME_RETRY"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
cfg.RuntimeRetry = d
|
||||
}
|
||||
}
|
||||
cfg.CredEndpointIP = get("ECS_CRED_ENDPOINT_IP")
|
||||
if cfg.CredEndpointIP == "" {
|
||||
cfg.CredEndpointIP = defaultCredEndpointIP
|
||||
|
||||
@@ -76,6 +76,10 @@ func (g *gatewayControlPlane) Register(id identity) error {
|
||||
{Name: aws.String("MEMORY"), Type: aws.String("INTEGER"), IntegerValue: aws.Int64(int64(id.Capacity.MemoryMiB))},
|
||||
},
|
||||
VersionInfo: &ecs.VersionInfo{AgentVersion: aws.String(id.AgentVersion)},
|
||||
// The built-in container-instance attributes AWS's own agent reports.
|
||||
// The scheduler sizes the host's awsvpc ENI capacity from the instance
|
||||
// type, so a registration without one is not ENI-gated.
|
||||
Attributes: instanceAttributes(id),
|
||||
}
|
||||
if len(id.Capacity.GPUIDs) > 0 {
|
||||
in.TotalResources = append(in.TotalResources, &ecs.Resource{
|
||||
|
||||
@@ -14,18 +14,23 @@ type heartbeater struct {
|
||||
cp controlPlane
|
||||
id identity
|
||||
interval time.Duration
|
||||
// runtimeReady reports whether a container runtime is resolved, so a beat
|
||||
// advertises capacity only while there is something to run it with. nil
|
||||
// means always ready.
|
||||
runtimeReady func() bool
|
||||
}
|
||||
|
||||
func newHeartbeater(cp controlPlane, id identity, interval time.Duration) *heartbeater {
|
||||
func newHeartbeater(cp controlPlane, id identity, interval time.Duration, runtimeReady func() bool) *heartbeater {
|
||||
if interval <= 0 {
|
||||
interval = defaultHeartbeat
|
||||
}
|
||||
return &heartbeater{cp: cp, id: id, interval: interval}
|
||||
return &heartbeater{cp: cp, id: id, interval: interval, runtimeReady: runtimeReady}
|
||||
}
|
||||
|
||||
// beat re-registers the instance once, refreshing its LastSeen.
|
||||
func (h *heartbeater) beat() error {
|
||||
return h.cp.Register(h.id)
|
||||
ready := h.runtimeReady == nil || h.runtimeReady()
|
||||
return h.cp.Register(advertisedIdentity(h.id, ready))
|
||||
}
|
||||
|
||||
// Run beats every interval until ctx is cancelled. A failed beat is logged and
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
|
||||
// instanceMetadata is the IMDS-derived identity of the host the agent runs on.
|
||||
type instanceMetadata struct {
|
||||
AccountID string
|
||||
InstanceID string
|
||||
AZ string
|
||||
AccountID string
|
||||
InstanceID string
|
||||
AZ string
|
||||
InstanceType string
|
||||
}
|
||||
|
||||
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2
|
||||
@@ -39,10 +40,14 @@ func fetchInstanceMetadata(client *http.Client, base string) (instanceMetadata,
|
||||
return instanceMetadata{}, fmt.Errorf("identity document: %w", err)
|
||||
}
|
||||
|
||||
// The instance type comes off the identity document already in hand rather
|
||||
// than a further meta-data read; the scheduler sizes the host's awsvpc ENI
|
||||
// capacity from it.
|
||||
return instanceMetadata{
|
||||
AccountID: doc.AccountID,
|
||||
InstanceID: strings.TrimSpace(instanceID),
|
||||
AZ: strings.TrimSpace(az),
|
||||
AccountID: doc.AccountID,
|
||||
InstanceID: strings.TrimSpace(instanceID),
|
||||
AZ: strings.TrimSpace(az),
|
||||
InstanceType: strings.TrimSpace(doc.InstanceType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import (
|
||||
// agent falls back to its pre-reconcile behaviour (poll + run).
|
||||
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
adopted := map[string]bool{}
|
||||
if a.runner == nil {
|
||||
runner := a.rt.get()
|
||||
if runner == nil {
|
||||
return adopted
|
||||
}
|
||||
containers, err := a.runner.List(ctx)
|
||||
containers, err := runner.List(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ecs-agent: reconcile list failed", "err", err)
|
||||
return adopted
|
||||
@@ -96,9 +97,13 @@ func (a *Agent) reconcile(ctx context.Context) map[string]bool {
|
||||
// is STOPPED, so the scheduler stops counting a task this instance is not
|
||||
// running and its service can place a replacement.
|
||||
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
|
||||
runner := a.rt.get()
|
||||
statuses := make([]bus.ContainerStatus, 0, len(dead))
|
||||
for _, c := range dead {
|
||||
if err := a.runner.Remove(ctx, c.ID); err != nil {
|
||||
if runner == nil {
|
||||
break
|
||||
}
|
||||
if err := runner.Remove(ctx, c.ID); err != nil {
|
||||
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
|
||||
}
|
||||
statuses = append(statuses, bus.ContainerStatus{
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
|
||||
adoptedContainer("t-001", "web", "default", "t-001", testTaskRole, "52:54:00:de:ad:01", true),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
@@ -84,7 +84,7 @@ func TestReconcile_ReportsTaskWhoseContainerDied(t *testing.T) {
|
||||
adoptedContainer("t-dead", "web", "default", "t-dead", testTaskRole, "52:54:00:de:ad:02", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
@@ -116,7 +116,7 @@ func TestReconcile_PartiallyDeadTaskStaysRunning(t *testing.T) {
|
||||
adoptedContainer("t-mixed", "sidecar", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", false),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
|
||||
|
||||
a.reconcile(context.Background())
|
||||
@@ -145,7 +145,7 @@ func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
|
||||
nil,
|
||||
}}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go a.pollAssignments(ctx, map[string]bool{"t-001": true})
|
||||
@@ -197,7 +197,7 @@ func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
|
||||
adoptedContainer("t-ok", "web", "default", "", "", "", true),
|
||||
},
|
||||
}
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
|
||||
adopted := a.reconcile(context.Background())
|
||||
|
||||
@@ -213,13 +213,13 @@ func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
|
||||
|
||||
// A nil runner or a List error degrades to an empty set without panicking.
|
||||
func TestReconcile_DegradesGracefully(t *testing.T) {
|
||||
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil, nil)
|
||||
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil)
|
||||
if got := noRunner.reconcile(context.Background()); len(got) != 0 {
|
||||
t.Errorf("nil runner: adopted = %v, want empty", got)
|
||||
}
|
||||
|
||||
rt := &ctrruntime.FakePuller{ListErr: errors.New("boom")}
|
||||
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, rt, nil)
|
||||
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, nil)
|
||||
if got := listErr.reconcile(context.Background()); len(got) != 0 {
|
||||
t.Errorf("list error: adopted = %v, want empty", got)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ecs"
|
||||
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
@@ -14,25 +17,61 @@ type identity struct {
|
||||
ClusterName string
|
||||
InstanceID string
|
||||
AZ string
|
||||
InstanceType string
|
||||
Hostname string
|
||||
Capacity bus.InstanceCapacity
|
||||
AgentVersion string
|
||||
}
|
||||
|
||||
// instanceAttributes builds the built-in container-instance attributes from the
|
||||
// host's identity. Only attributes whose value was actually discovered are
|
||||
// reported, so an empty one is absent rather than present and blank.
|
||||
func instanceAttributes(id identity) []*ecs.Attribute {
|
||||
var attrs []*ecs.Attribute
|
||||
add := func(name, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
attrs = append(attrs, &ecs.Attribute{Name: aws.String(name), Value: aws.String(value)})
|
||||
}
|
||||
add(handlers_ecs.AttrInstanceType, id.InstanceType)
|
||||
add(handlers_ecs.AttrAvailabilityZone, id.AZ)
|
||||
return attrs
|
||||
}
|
||||
|
||||
// advertisedIdentity is the identity to register with, given whether a
|
||||
// container runtime is available. A host with no runtime can run nothing, so it
|
||||
// advertises no capacity: the scheduler places nothing on it, and the control
|
||||
// plane reads the zero as an involuntary drain. The identity is otherwise
|
||||
// unchanged, so the instance stays visible rather than disappearing.
|
||||
func advertisedIdentity(id identity, runtimeReady bool) identity {
|
||||
if runtimeReady {
|
||||
return id
|
||||
}
|
||||
id.Capacity = bus.InstanceCapacity{}
|
||||
return id
|
||||
}
|
||||
|
||||
// registrar registers the host as a container instance through the gateway.
|
||||
type registrar struct {
|
||||
cp controlPlane
|
||||
id identity
|
||||
// runtimeReady reports whether a container runtime is resolved. nil means
|
||||
// always ready, which is what a test with no gate wants.
|
||||
runtimeReady func() bool
|
||||
}
|
||||
|
||||
func newRegistrar(cp controlPlane, id identity) *registrar {
|
||||
return ®istrar{cp: cp, id: id}
|
||||
func newRegistrar(cp controlPlane, id identity, runtimeReady func() bool) *registrar {
|
||||
return ®istrar{cp: cp, id: id, runtimeReady: runtimeReady}
|
||||
}
|
||||
|
||||
// ready reports the runtime state, defaulting to ready when no gate is wired.
|
||||
func (r *registrar) ready() bool { return r.runtimeReady == nil || r.runtimeReady() }
|
||||
|
||||
// Register calls the gateway's RegisterContainerInstance. The scheduler records
|
||||
// (or refreshes) the container instance on receipt.
|
||||
func (r *registrar) Register() error {
|
||||
if err := r.cp.Register(r.id); err != nil {
|
||||
if err := r.cp.Register(advertisedIdentity(r.id, r.ready())); err != nil {
|
||||
return fmt.Errorf("register instance %s: %w", r.id.InstanceID, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -17,14 +18,16 @@ import (
|
||||
type fakeCP struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registers int
|
||||
states []bus.TaskState
|
||||
gpuReports []gpuReport
|
||||
pollAcks [][]string
|
||||
pollStopAcks [][]string
|
||||
pollReplies [][]bus.Assign
|
||||
stopReplies [][]bus.StopDirective
|
||||
pollCalls int
|
||||
registers int
|
||||
lastRegistered identity
|
||||
states []bus.TaskState
|
||||
gpuReports []gpuReport
|
||||
pollAcks [][]string
|
||||
pollStopAcks [][]string
|
||||
pollReplies [][]bus.Assign
|
||||
stopReplies [][]bus.StopDirective
|
||||
stopPending []bus.StopDirective
|
||||
pollCalls int
|
||||
|
||||
registerErr bool
|
||||
submitErr bool
|
||||
@@ -39,16 +42,24 @@ type gpuReport struct {
|
||||
|
||||
var _ controlPlane = (*fakeCP)(nil)
|
||||
|
||||
func (f *fakeCP) Register(_ identity) error {
|
||||
func (f *fakeCP) Register(id identity) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.registerErr {
|
||||
return errors.New("register boom")
|
||||
}
|
||||
f.registers++
|
||||
f.lastRegistered = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// registered returns the identity of the most recent Register call.
|
||||
func (f *fakeCP) registered() identity {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.lastRegistered
|
||||
}
|
||||
|
||||
func (f *fakeCP) SubmitTaskState(st bus.TaskState) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -72,6 +83,12 @@ func (f *fakeCP) PollAssignments(_, _ string, ackAssigns, ackStops []string) ([]
|
||||
if f.pollCalls < len(f.stopReplies) {
|
||||
stops = f.stopReplies[f.pollCalls]
|
||||
}
|
||||
// stopPending is the durable inbox the gateway keeps: an acked directive is
|
||||
// deleted, and everything still in it is redelivered on every poll.
|
||||
f.stopPending = slices.DeleteFunc(f.stopPending, func(sd bus.StopDirective) bool {
|
||||
return slices.Contains(ackStops, sd.TaskID)
|
||||
})
|
||||
stops = slices.Concat(stops, f.stopPending)
|
||||
f.pollCalls++
|
||||
return out, stops, nil
|
||||
}
|
||||
@@ -142,7 +159,7 @@ func testIdentityWithGPUs(uuids ...string) identity {
|
||||
|
||||
func TestRegistrar_Register(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
if err := newRegistrar(cp, testIdentity()).Register(); err != nil {
|
||||
if err := newRegistrar(cp, testIdentity(), nil).Register(); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if cp.registerCount() != 1 {
|
||||
@@ -152,14 +169,14 @@ func TestRegistrar_Register(t *testing.T) {
|
||||
|
||||
func TestRegistrar_RegisterError(t *testing.T) {
|
||||
cp := &fakeCP{registerErr: true}
|
||||
if err := newRegistrar(cp, testIdentity()).Register(); err == nil {
|
||||
if err := newRegistrar(cp, testIdentity(), nil).Register(); err == nil {
|
||||
t.Fatal("expected register error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeater_Beat(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
if err := newHeartbeater(cp, testIdentity(), time.Second).beat(); err != nil {
|
||||
if err := newHeartbeater(cp, testIdentity(), time.Second, nil).beat(); err != nil {
|
||||
t.Fatalf("beat: %v", err)
|
||||
}
|
||||
if cp.registerCount() != 1 {
|
||||
@@ -168,7 +185,7 @@ func TestHeartbeater_Beat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHeartbeater_DefaultInterval(t *testing.T) {
|
||||
h := newHeartbeater(&fakeCP{}, testIdentity(), 0)
|
||||
h := newHeartbeater(&fakeCP{}, testIdentity(), 0, nil)
|
||||
if h.interval != defaultHeartbeat {
|
||||
t.Errorf("interval = %v, want default", h.interval)
|
||||
}
|
||||
@@ -176,7 +193,7 @@ func TestHeartbeater_DefaultInterval(t *testing.T) {
|
||||
|
||||
func TestHeartbeater_RunStopsOnContext(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond)
|
||||
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
@@ -193,3 +210,48 @@ func TestHeartbeater_RunStopsOnContext(t *testing.T) {
|
||||
t.Error("expected at least one re-register before cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrar_NoRuntimeAdvertisesNoCapacity is the placement half of the boot
|
||||
// bug: an agent with no container runtime kept registering its full CPU and
|
||||
// memory, so the scheduler placed tasks it could only fail to start. Capacity is
|
||||
// advertised only while a runtime is there to use it.
|
||||
func TestRegistrar_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
ready := false
|
||||
r := newRegistrar(cp, testIdentity(), func() bool { return ready })
|
||||
|
||||
if err := r.Register(); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
|
||||
t.Fatalf("advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
|
||||
ready = true
|
||||
if err := r.Register(); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
want := testIdentity().Capacity
|
||||
if got := cp.registered().Capacity; got.CPU != want.CPU || got.MemoryMiB != want.MemoryMiB {
|
||||
t.Fatalf("capacity not restored once the runtime resolved: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeater_NoRuntimeAdvertisesNoCapacity is the same rule on the beat,
|
||||
// which is how a runtime lost after boot reaches the control plane.
|
||||
func TestHeartbeater_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
h := newHeartbeater(cp, testIdentity(), time.Second, func() bool { return false })
|
||||
|
||||
if err := h.beat(); err != nil {
|
||||
t.Fatalf("beat: %v", err)
|
||||
}
|
||||
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
|
||||
t.Fatalf("beat advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
|
||||
}
|
||||
// The instance still identifies itself, so it stays visible as drained rather
|
||||
// than vanishing from the cluster.
|
||||
if cp.registered().InstanceID != testIdentity().InstanceID {
|
||||
t.Error("beat dropped the instance identity")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
containerd "github.com/containerd/containerd/v2/client"
|
||||
@@ -22,6 +23,11 @@ const servingProbeTimeout = 3 * time.Second
|
||||
// containerdPuller drives a real containerd daemon over its unix socket.
|
||||
type containerdPuller struct {
|
||||
client *containerd.Client
|
||||
|
||||
// stdinDone holds one channel per interactive container, closed when that
|
||||
// container is stopped or removed so its stdin reader returns EOF.
|
||||
mu sync.Mutex
|
||||
stdinDone map[string]chan struct{}
|
||||
}
|
||||
|
||||
var _ ImagePuller = (*containerdPuller)(nil)
|
||||
|
||||
@@ -3,9 +3,14 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
containerd "github.com/containerd/containerd/v2/client"
|
||||
"github.com/containerd/containerd/v2/core/containers"
|
||||
"github.com/containerd/containerd/v2/pkg/cdi"
|
||||
"github.com/containerd/containerd/v2/pkg/cio"
|
||||
"github.com/containerd/containerd/v2/pkg/namespaces"
|
||||
@@ -41,6 +46,106 @@ func cdiSpecOpts(gpuIDs []string) []oci.SpecOpts {
|
||||
return []oci.SpecOpts{cdi.WithCDIDevices(devices...)}
|
||||
}
|
||||
|
||||
// ociCapName renders an ECS Linux capability (Docker's bare name, e.g.
|
||||
// "SYS_ADMIN") as the OCI runtime-spec name ("CAP_SYS_ADMIN") capabilities.Add/
|
||||
// Drop expect.
|
||||
func ociCapName(name string) string {
|
||||
if strings.HasPrefix(name, "CAP_") {
|
||||
return name
|
||||
}
|
||||
return "CAP_" + name
|
||||
}
|
||||
|
||||
// ociCapNames maps a list of ECS capability names to their OCI form.
|
||||
func ociCapNames(names []string) []string {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = ociCapName(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withSysctls sets Linux.Sysctl on the OCI spec from systemControls namespace/
|
||||
// value pairs. containerd has no built-in SpecOpts for sysctls.
|
||||
func withSysctls(ctls []SystemControl) oci.SpecOpts {
|
||||
return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error {
|
||||
if len(ctls) == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.Linux == nil {
|
||||
s.Linux = &specs.Linux{}
|
||||
}
|
||||
if s.Linux.Sysctl == nil {
|
||||
s.Linux.Sysctl = map[string]string{}
|
||||
}
|
||||
for _, c := range ctls {
|
||||
s.Linux.Sysctl[c.Namespace] = c.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// heldStdin backs an interactive container's stdin: reads block instead of
|
||||
// hitting an immediate EOF, which is the "stdin held open" behaviour
|
||||
// interactive=true asks for, until done closes and the read reports EOF.
|
||||
type heldStdin struct{ done <-chan struct{} }
|
||||
|
||||
func (h heldStdin) Read([]byte) (int, error) {
|
||||
<-h.done
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// ioStreamOpts describes the task IO for a container. A terminal container needs
|
||||
// a terminal FIFO set: oci.WithTTY alone puts Terminal in the OCI spec while the
|
||||
// shim creates no console socket, and runc refuses that pair outright. A
|
||||
// terminal carries no separate stderr, so that stream stays nil and the shim
|
||||
// merges it into the console.
|
||||
func ioStreamOpts(stdin io.Reader, terminal bool) []cio.Opt {
|
||||
if terminal {
|
||||
return []cio.Opt{cio.WithStreams(stdin, io.Discard, nil), cio.WithTerminal}
|
||||
}
|
||||
return []cio.Opt{cio.WithStreams(stdin, io.Discard, io.Discard)}
|
||||
}
|
||||
|
||||
// containerIOCreator returns cio.NullIO for a container needing neither stdin
|
||||
// nor a terminal, else a creator carrying whichever it asked for. An interactive
|
||||
// container's stdin is held open until releaseStdin; containerd copies stdin on
|
||||
// a goroutine that exits only once the read returns, so a reader that never
|
||||
// returned would park it for the life of the agent. A terminal container that is
|
||||
// not interactive gets no stdin stream at all, which is what a nil reader means.
|
||||
func (p *containerdPuller) containerIOCreator(containerID string, interactive, terminal bool) cio.Creator {
|
||||
if !interactive && !terminal {
|
||||
return cio.NullIO
|
||||
}
|
||||
var stdin io.Reader
|
||||
if interactive {
|
||||
done := make(chan struct{})
|
||||
p.mu.Lock()
|
||||
if p.stdinDone == nil {
|
||||
p.stdinDone = make(map[string]chan struct{})
|
||||
}
|
||||
p.stdinDone[containerID] = done
|
||||
p.mu.Unlock()
|
||||
stdin = heldStdin{done: done}
|
||||
}
|
||||
return cio.NewCreator(ioStreamOpts(stdin, terminal)...)
|
||||
}
|
||||
|
||||
// releaseStdin closes a held stdin so containerd's copier goroutine exits. Safe
|
||||
// for a container that never held one, and safe to call more than once.
|
||||
func (p *containerdPuller) releaseStdin(containerID string) {
|
||||
p.mu.Lock()
|
||||
done, ok := p.stdinDone[containerID]
|
||||
delete(p.stdinDone, containerID)
|
||||
p.mu.Unlock()
|
||||
if ok {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
var _ Runner = (*containerdPuller)(nil)
|
||||
|
||||
// Run creates a container from an already-pulled image and starts its task. The
|
||||
@@ -81,6 +186,26 @@ func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (st
|
||||
specOpts = append(specOpts, oci.WithEnv(envSlice(spec.Env)))
|
||||
}
|
||||
specOpts = append(specOpts, cdiSpecOpts(spec.GPUIDs)...)
|
||||
if spec.User != "" {
|
||||
specOpts = append(specOpts, oci.WithUser(spec.User))
|
||||
}
|
||||
if spec.ReadonlyRootFilesystem != nil && *spec.ReadonlyRootFilesystem {
|
||||
specOpts = append(specOpts, oci.WithRootFSReadonly())
|
||||
}
|
||||
if spec.Privileged != nil && *spec.Privileged {
|
||||
specOpts = append(specOpts, oci.WithPrivileged)
|
||||
}
|
||||
terminal := spec.PseudoTerminal != nil && *spec.PseudoTerminal
|
||||
if terminal {
|
||||
specOpts = append(specOpts, oci.WithTTY)
|
||||
}
|
||||
specOpts = append(specOpts, withSysctls(spec.SystemControls))
|
||||
if len(spec.CapAdd) > 0 {
|
||||
specOpts = append(specOpts, oci.WithAddedCapabilities(ociCapNames(spec.CapAdd)))
|
||||
}
|
||||
if len(spec.CapDrop) > 0 {
|
||||
specOpts = append(specOpts, oci.WithDroppedCapabilities(ociCapNames(spec.CapDrop)))
|
||||
}
|
||||
|
||||
container, err := p.client.NewContainer(ctx, id,
|
||||
containerd.WithNewSnapshot(id+"-snapshot", image),
|
||||
@@ -91,12 +216,15 @@ func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (st
|
||||
return "", fmt.Errorf("create container %s: %w", id, err)
|
||||
}
|
||||
|
||||
task, err := container.NewTask(ctx, cio.NullIO)
|
||||
interactive := spec.Interactive != nil && *spec.Interactive
|
||||
task, err := container.NewTask(ctx, p.containerIOCreator(id, interactive, terminal))
|
||||
if err != nil {
|
||||
p.releaseStdin(id)
|
||||
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
|
||||
return "", fmt.Errorf("create task %s: %w", id, err)
|
||||
}
|
||||
if err := task.Start(ctx); err != nil {
|
||||
p.releaseStdin(id)
|
||||
_, _ = task.Delete(ctx)
|
||||
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
|
||||
return "", fmt.Errorf("start task %s: %w", id, err)
|
||||
@@ -148,9 +276,35 @@ func (p *containerdPuller) List(ctx context.Context) ([]Container, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stop enforces a container's stopTimeout: SIGTERM the task, wait up to
|
||||
// timeout for it to exit on its own, then fall through to Remove's forced
|
||||
// kill+delete regardless of whether it exited in time. A container with no
|
||||
// live task (already gone) or that fails to accept the signal goes straight to
|
||||
// Remove, matching Remove's own already-gone tolerance.
|
||||
func (p *containerdPuller) Stop(ctx context.Context, containerID string, timeout time.Duration) error {
|
||||
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
|
||||
container, err := p.client.LoadContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // load failure means the container is already gone
|
||||
}
|
||||
task, terr := container.Task(ctx, nil)
|
||||
if terr != nil {
|
||||
return p.Remove(ctx, containerID)
|
||||
}
|
||||
statusC, werr := task.Wait(ctx)
|
||||
if werr == nil && task.Kill(ctx, syscall.SIGTERM) == nil {
|
||||
select {
|
||||
case <-statusC:
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
return p.Remove(ctx, containerID)
|
||||
}
|
||||
|
||||
// Remove kills and deletes the container's task, then the container + snapshot.
|
||||
func (p *containerdPuller) Remove(ctx context.Context, containerID string) error {
|
||||
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
|
||||
p.releaseStdin(containerID)
|
||||
container, err := p.client.LoadContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // load failure means the container is already gone
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/v2/pkg/cio"
|
||||
specs "github.com/opencontainers/runtime-spec/specs-go"
|
||||
)
|
||||
|
||||
// cdiDeviceNames maps a container's pinned GPU UUIDs to CDI device names
|
||||
@@ -45,3 +52,166 @@ func TestCDISpecOpts_Wiring(t *testing.T) {
|
||||
t.Error("GPU container: spec opt is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOCICapNames verifies ECS's bare capability names (Docker convention,
|
||||
// e.g. "SYS_PTRACE") are prefixed to the OCI runtime-spec form the process
|
||||
// capability sets expect, and a name already carrying the prefix passes
|
||||
// through unchanged.
|
||||
func TestOCICapNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{name: "empty", in: nil, want: nil},
|
||||
{name: "bare", in: []string{"SYS_PTRACE"}, want: []string{"CAP_SYS_PTRACE"}},
|
||||
{name: "already prefixed", in: []string{"CAP_NET_ADMIN"}, want: []string{"CAP_NET_ADMIN"}},
|
||||
{name: "mixed", in: []string{"SYS_PTRACE", "CAP_NET_ADMIN"}, want: []string{"CAP_SYS_PTRACE", "CAP_NET_ADMIN"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ociCapNames(tc.in)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("ociCapNames(%v) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithSysctls verifies systemControls namespace/value pairs land on
|
||||
// Linux.Sysctl, and an empty list leaves the spec's Linux section untouched
|
||||
// rather than allocating an empty map (the "requests nothing" case).
|
||||
func TestWithSysctls(t *testing.T) {
|
||||
s := &specs.Spec{}
|
||||
if err := withSysctls(nil)(context.Background(), nil, nil, s); err != nil {
|
||||
t.Fatalf("empty ctls: %v", err)
|
||||
}
|
||||
if s.Linux != nil {
|
||||
t.Errorf("empty ctls: want no Linux section, got %+v", s.Linux)
|
||||
}
|
||||
|
||||
ctls := []SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}}
|
||||
if err := withSysctls(ctls)(context.Background(), nil, nil, s); err != nil {
|
||||
t.Fatalf("with ctls: %v", err)
|
||||
}
|
||||
if s.Linux == nil || s.Linux.Sysctl["net.core.somaxconn"] != "1024" {
|
||||
t.Errorf("want sysctl net.core.somaxconn=1024, got %+v", s.Linux)
|
||||
}
|
||||
}
|
||||
|
||||
// An interactive container's stdin read blocks until the container is released,
|
||||
// then reports EOF so containerd's copier goroutine exits instead of parking
|
||||
// for the life of the agent.
|
||||
func TestHeldStdin_ReadsEOFOnceReleased(t *testing.T) {
|
||||
p := &containerdPuller{}
|
||||
p.containerIOCreator("c1", true, false)
|
||||
|
||||
p.mu.Lock()
|
||||
done := p.stdinDone["c1"]
|
||||
p.mu.Unlock()
|
||||
if done == nil {
|
||||
t.Fatal("interactive container registered no stdin channel")
|
||||
}
|
||||
|
||||
read := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := heldStdin{done: done}.Read(make([]byte, 1))
|
||||
read <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-read:
|
||||
t.Fatalf("read returned before release: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
p.releaseStdin("c1")
|
||||
|
||||
select {
|
||||
case err := <-read:
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("want io.EOF after release, got %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("read did not return after release")
|
||||
}
|
||||
}
|
||||
|
||||
// A non-interactive container uses cio.NullIO and registers nothing to release.
|
||||
func TestContainerIOCreator_NonInteractiveRegistersNothing(t *testing.T) {
|
||||
p := &containerdPuller{}
|
||||
p.containerIOCreator("c1", false, false)
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.stdinDone) != 0 {
|
||||
t.Fatalf("want no registered stdin channels, got %d", len(p.stdinDone))
|
||||
}
|
||||
}
|
||||
|
||||
// applyIOOpts resolves the cio options onto a Streams the way cio.NewCreator
|
||||
// does, which is the only way to observe what the creator was built with.
|
||||
func applyIOOpts(opts []cio.Opt) cio.Streams {
|
||||
var s cio.Streams
|
||||
for _, o := range opts {
|
||||
o(&s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// A terminal container needs the terminal flag set on its FIFO set, else the
|
||||
// shim creates no console socket and runc refuses to start the container at all.
|
||||
// The terminal carries no separate stderr stream.
|
||||
func TestIOStreamOpts_TerminalRequestsAConsole(t *testing.T) {
|
||||
s := applyIOOpts(ioStreamOpts(nil, true))
|
||||
if !s.Terminal {
|
||||
t.Error("terminal container: want Terminal set")
|
||||
}
|
||||
if s.Stderr != nil {
|
||||
t.Errorf("terminal container: want no stderr stream, got %#v", s.Stderr)
|
||||
}
|
||||
if s.Stdout == nil {
|
||||
t.Error("terminal container: want a stdout stream")
|
||||
}
|
||||
}
|
||||
|
||||
// A container with stdin but no terminal keeps both output streams and asks for
|
||||
// no console.
|
||||
func TestIOStreamOpts_NonTerminalKeepsStderr(t *testing.T) {
|
||||
s := applyIOOpts(ioStreamOpts(heldStdin{}, false))
|
||||
if s.Terminal {
|
||||
t.Error("non-terminal container: want Terminal unset")
|
||||
}
|
||||
if s.Stderr == nil {
|
||||
t.Error("non-terminal container: want a stderr stream")
|
||||
}
|
||||
if s.Stdin == nil {
|
||||
t.Error("non-terminal interactive container: want a stdin stream")
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal container that is not interactive still gets a creator rather than
|
||||
// cio.NullIO, and registers no stdin to release.
|
||||
func TestContainerIOCreator_TerminalWithoutInteractive(t *testing.T) {
|
||||
p := &containerdPuller{}
|
||||
if c := p.containerIOCreator("c1", false, true); c == nil {
|
||||
t.Fatal("terminal container: want a creator")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.stdinDone) != 0 {
|
||||
t.Fatalf("want no registered stdin channels, got %d", len(p.stdinDone))
|
||||
}
|
||||
}
|
||||
|
||||
// Remove calls releaseStdin unconditionally and Stop falls through to Remove,
|
||||
// so an unknown container and a second release must both be no-ops.
|
||||
func TestReleaseStdin_UnknownAndRepeatedAreSafe(t *testing.T) {
|
||||
p := &containerdPuller{}
|
||||
p.releaseStdin("never-registered")
|
||||
|
||||
p.containerIOCreator("c1", true, false)
|
||||
p.releaseStdin("c1")
|
||||
p.releaseStdin("c1")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// scheduler-only build run against a fake.
|
||||
package runtime
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PullSpec describes one image to pull.
|
||||
type PullSpec struct {
|
||||
@@ -35,6 +38,12 @@ type ImagePuller interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SystemControl is a sysctl namespace/value pair applied to the OCI spec.
|
||||
type SystemControl struct {
|
||||
Namespace string
|
||||
Value string
|
||||
}
|
||||
|
||||
// RunSpec describes a single container the agent must create and start. v1 runs
|
||||
// the container in the host network namespace (bridge/CNI task networking lands
|
||||
// in a later sprint); Labels carry the mulga.ecs.* task identity for the reboot
|
||||
@@ -54,6 +63,22 @@ type RunSpec struct {
|
||||
// per GPU requested. Empty for non-GPU containers or if pinning fell short.
|
||||
// The runner injects these as CDI devices into the OCI spec.
|
||||
GPUIDs []string
|
||||
// User is the OCI process user (oci.WithUser). Empty runs as the image
|
||||
// default.
|
||||
User string
|
||||
// ReadonlyRootFilesystem, Privileged, PseudoTerminal and Interactive are
|
||||
// pointers so the runner can tell "not requested" (nil) from an explicit
|
||||
// false, which still needs echoing but applies nothing.
|
||||
ReadonlyRootFilesystem *bool
|
||||
Privileged *bool
|
||||
PseudoTerminal *bool
|
||||
Interactive *bool
|
||||
// SystemControls are sysctls set on the OCI spec's Linux.Sysctl map.
|
||||
SystemControls []SystemControl
|
||||
// CapAdd / CapDrop are Linux capabilities added/dropped from the OCI
|
||||
// spec's process capability sets (linuxParameters.capabilities).
|
||||
CapAdd []string
|
||||
CapDrop []string
|
||||
}
|
||||
|
||||
// RunStatus is a finished container's outcome.
|
||||
@@ -71,12 +96,16 @@ type Container struct {
|
||||
}
|
||||
|
||||
// Runner creates and starts containers from already-pulled images. id is a
|
||||
// caller-unique container ID; Wait blocks until the container exits; Remove
|
||||
// tears down the container + its task; List enumerates known containers so the
|
||||
// reboot reconciler can re-adopt the ones still running.
|
||||
// caller-unique container ID; Wait blocks until the container exits; Stop
|
||||
// requests a graceful exit (SIGTERM, then a forced kill+remove once timeout
|
||||
// elapses) — the agent's enforcement of a container's stopTimeout; Remove
|
||||
// tears down the container + its task immediately, with no grace period; List
|
||||
// enumerates known containers so the reboot reconciler can re-adopt the ones
|
||||
// still running.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, id string, spec RunSpec) (containerID string, err error)
|
||||
Wait(ctx context.Context, containerID string) (RunStatus, error)
|
||||
Stop(ctx context.Context, containerID string, timeout time.Duration) error
|
||||
Remove(ctx context.Context, containerID string) error
|
||||
List(ctx context.Context) ([]Container, error)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FakePuller is an in-memory ImagePuller for tests and the scheduler-only build.
|
||||
@@ -26,11 +27,21 @@ type FakePuller struct {
|
||||
Removed []string
|
||||
OnRun func(string, RunSpec) (string, error)
|
||||
|
||||
// List bookkeeping: Containers is replayed by List; ListErr forces a failure;
|
||||
// Listed records that List was called.
|
||||
// Stop bookkeeping: StopTimeouts pairs the container ID with the timeout it
|
||||
// was stopped with, in call order, so a test can assert both the target and
|
||||
// the enforced stopTimeout without a fake containerd.
|
||||
Stopped []string
|
||||
StopTimeouts []time.Duration
|
||||
StopErr error
|
||||
|
||||
// List bookkeeping: Containers is replayed by List; ListErr forces a failure,
|
||||
// limited to the first ListErrFor calls when that is non-zero, so a test can
|
||||
// model a runtime that recovers. Listed and ListCalls record the calls.
|
||||
Containers []Container
|
||||
ListErr error
|
||||
ListErrFor int
|
||||
Listed bool
|
||||
ListCalls int
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -98,6 +109,15 @@ func (f *FakePuller) Wait(_ context.Context, containerID string) (RunStatus, err
|
||||
return RunStatus{ExitCode: f.WaitCode}, f.WaitErr
|
||||
}
|
||||
|
||||
// Stop records the container ID and the timeout it was stopped with.
|
||||
func (f *FakePuller) Stop(_ context.Context, containerID string, timeout time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Stopped = append(f.Stopped, containerID)
|
||||
f.StopTimeouts = append(f.StopTimeouts, timeout)
|
||||
return f.StopErr
|
||||
}
|
||||
|
||||
// Remove records the container ID.
|
||||
func (f *FakePuller) Remove(_ context.Context, containerID string) error {
|
||||
f.mu.Lock()
|
||||
@@ -113,13 +133,36 @@ func (f *FakePuller) Waits() []string {
|
||||
return append([]string(nil), f.Waited...)
|
||||
}
|
||||
|
||||
// RunCalls returns a copy of the specs passed to Run, for assertions made while
|
||||
// the agent's goroutines may still be live.
|
||||
func (f *FakePuller) RunCalls() []RunSpec {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]RunSpec(nil), f.Runs...)
|
||||
}
|
||||
|
||||
// Reaped returns a copy of the container IDs passed to Stop.
|
||||
func (f *FakePuller) Reaped() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.Stopped...)
|
||||
}
|
||||
|
||||
// List replays the programmed container set (or error).
|
||||
func (f *FakePuller) List(_ context.Context) ([]Container, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Listed = true
|
||||
if f.ListErr != nil {
|
||||
f.ListCalls++
|
||||
if f.ListErr != nil && (f.ListErrFor == 0 || f.ListCalls <= f.ListErrFor) {
|
||||
return nil, f.ListErr
|
||||
}
|
||||
return f.Containers, nil
|
||||
}
|
||||
|
||||
// Lists reports how many times List was called.
|
||||
func (f *FakePuller) Lists() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.ListCalls
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
)
|
||||
|
||||
// runtimeProbeTimeout bounds the liveness probe against a runtime the gate is
|
||||
// already holding, so a wedged socket cannot stall the watch loop.
|
||||
const runtimeProbeTimeout = 5 * time.Second
|
||||
|
||||
// defaultRuntimeRetry is how often the gate dials a runtime it does not have,
|
||||
// and probes one it does. The node starts containerd and the agent back to
|
||||
// back, so the first few dials are expected to fail.
|
||||
const defaultRuntimeRetry = 10 * time.Second
|
||||
|
||||
// runtimeGate holds the container runtime the agent has resolved, or nothing
|
||||
// when the socket has not answered. The runtime is resolved by a loop rather
|
||||
// than once at boot: the node's service manager reports the container daemon
|
||||
// started when the process spawned, not when its socket began accepting, so a
|
||||
// dial that fails in the first second of boot is a transient condition.
|
||||
//
|
||||
// Every read of the runtime goes through the gate, so a runtime that arrives
|
||||
// after boot is picked up on the next task without restarting the agent.
|
||||
type runtimeGate struct {
|
||||
mu sync.RWMutex
|
||||
rt ctrruntime.Runtime
|
||||
}
|
||||
|
||||
// newRuntimeGate returns a gate holding rt, which may be nil.
|
||||
func newRuntimeGate(rt ctrruntime.Runtime) *runtimeGate {
|
||||
return &runtimeGate{rt: rt}
|
||||
}
|
||||
|
||||
// get returns the current runtime, or nil when none is resolved.
|
||||
func (g *runtimeGate) get() ctrruntime.Runtime {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.rt
|
||||
}
|
||||
|
||||
// available reports whether a runtime is resolved. It is what registration
|
||||
// consults to decide whether the host has any capacity to advertise.
|
||||
func (g *runtimeGate) available() bool { return g.get() != nil }
|
||||
|
||||
// set replaces the held runtime, closing the one it displaces.
|
||||
func (g *runtimeGate) set(rt ctrruntime.Runtime) {
|
||||
g.mu.Lock()
|
||||
old := g.rt
|
||||
g.rt = rt
|
||||
g.mu.Unlock()
|
||||
if old != nil && old != rt {
|
||||
_ = old.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// clear drops and closes the held runtime, returning whether it held one.
|
||||
func (g *runtimeGate) clear() bool {
|
||||
g.mu.Lock()
|
||||
old := g.rt
|
||||
g.rt = nil
|
||||
g.mu.Unlock()
|
||||
if old == nil {
|
||||
return false
|
||||
}
|
||||
_ = old.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// watch keeps the gate current until ctx is cancelled: it dials while the gate
|
||||
// is empty, and probes while it is full, clearing the gate when the probe fails
|
||||
// so the next tick re-dials. A containerd stopped and restarted underneath a
|
||||
// running agent is therefore picked up the same way a slow boot is.
|
||||
func (g *runtimeGate) watch(ctx context.Context, dial func() (ctrruntime.Runtime, error), interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = defaultRuntimeRetry
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
g.step(ctx, dial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step performs one pass of the watch loop: dial when empty, probe when full.
|
||||
func (g *runtimeGate) step(ctx context.Context, dial func() (ctrruntime.Runtime, error)) {
|
||||
if rt := g.get(); rt != nil {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, runtimeProbeTimeout)
|
||||
// A container list is what reconcile already asks the runtime at boot and
|
||||
// the cheapest thing it answers, so it doubles as the liveness probe.
|
||||
_, err := rt.List(probeCtx)
|
||||
cancel()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
slog.Warn("ecs-agent: container runtime stopped answering, will re-dial", "err", err)
|
||||
g.clear()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
rt, err := dial()
|
||||
if err != nil {
|
||||
slog.Debug("ecs-agent: container runtime still unavailable", "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("ecs-agent: container runtime available")
|
||||
g.set(rt)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//test:in-package — package main cannot be imported by an external test package.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
)
|
||||
|
||||
// TestRuntimeGate_DialsUntilTheSocketAnswers is the boot case the agent used to
|
||||
// lose: the node starts containerd and the agent together, so the first dials
|
||||
// fail. Resolving once left the agent runtime-less for the life of the process.
|
||||
func TestRuntimeGate_DialsUntilTheSocketAnswers(t *testing.T) {
|
||||
g := newRuntimeGate(nil)
|
||||
if g.available() {
|
||||
t.Fatal("empty gate reported a runtime")
|
||||
}
|
||||
|
||||
rt := &ctrruntime.FakePuller{}
|
||||
dials := 0
|
||||
dial := func() (ctrruntime.Runtime, error) {
|
||||
dials++
|
||||
if dials < 3 {
|
||||
return nil, errors.New("dial containerd: no such file or directory")
|
||||
}
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
g.step(t.Context(), dial)
|
||||
if g.available() {
|
||||
t.Fatalf("gate resolved a runtime after %d failed dials", dials)
|
||||
}
|
||||
}
|
||||
|
||||
g.step(t.Context(), dial)
|
||||
if g.get() != rt {
|
||||
t.Fatalf("gate did not pick up the runtime once the socket answered (dials=%d)", dials)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeGate_DropsARuntimeThatStopsAnswering covers a containerd restarted
|
||||
// underneath a live agent: the held runtime stops answering, so the gate clears
|
||||
// it and the next pass re-dials rather than handing out a dead client forever.
|
||||
func TestRuntimeGate_DropsARuntimeThatStopsAnswering(t *testing.T) {
|
||||
dead := &ctrruntime.FakePuller{ListErr: errors.New("connection refused")}
|
||||
fresh := &ctrruntime.FakePuller{}
|
||||
g := newRuntimeGate(dead)
|
||||
|
||||
dial := func() (ctrruntime.Runtime, error) { return fresh, nil }
|
||||
|
||||
g.step(t.Context(), dial)
|
||||
if g.available() {
|
||||
t.Fatal("gate kept a runtime whose probe failed")
|
||||
}
|
||||
if !dead.Closed {
|
||||
t.Error("displaced runtime was not closed")
|
||||
}
|
||||
|
||||
g.step(t.Context(), dial)
|
||||
if g.get() != fresh {
|
||||
t.Fatal("gate did not re-dial after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeGate_KeepsAHealthyRuntime guards the other direction: a probe that
|
||||
// succeeds must not churn the runtime, or every pass would drop a working client.
|
||||
func TestRuntimeGate_KeepsAHealthyRuntime(t *testing.T) {
|
||||
rt := &ctrruntime.FakePuller{}
|
||||
g := newRuntimeGate(rt)
|
||||
|
||||
dial := func() (ctrruntime.Runtime, error) {
|
||||
t.Error("dialled while holding a healthy runtime")
|
||||
return nil, errors.New("unexpected dial")
|
||||
}
|
||||
g.step(t.Context(), dial)
|
||||
|
||||
if g.get() != rt {
|
||||
t.Fatal("gate dropped a runtime whose probe succeeded")
|
||||
}
|
||||
if rt.Closed {
|
||||
t.Error("healthy runtime was closed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeGate_ClearIsIdempotent keeps Stop safe to call on an agent that
|
||||
// never resolved a runtime.
|
||||
func TestRuntimeGate_ClearIsIdempotent(t *testing.T) {
|
||||
g := newRuntimeGate(nil)
|
||||
if g.clear() {
|
||||
t.Error("clear reported dropping a runtime the gate never held")
|
||||
}
|
||||
|
||||
rt := &ctrruntime.FakePuller{}
|
||||
g.set(rt)
|
||||
if !g.clear() {
|
||||
t.Error("clear did not report dropping the held runtime")
|
||||
}
|
||||
if !rt.Closed {
|
||||
t.Error("cleared runtime was not closed")
|
||||
}
|
||||
if g.clear() {
|
||||
t.Error("second clear reported dropping a runtime")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeGate_WatchStopsWithContext keeps the watch loop from outliving the
|
||||
// agent it belongs to.
|
||||
func TestRuntimeGate_WatchStopsWithContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
g := newRuntimeGate(nil)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
g.watch(ctx, func() (ctrruntime.Runtime, error) { return nil, errors.New("no socket") }, 0)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
+159
-30
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
|
||||
@@ -12,6 +14,10 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
|
||||
)
|
||||
|
||||
// defaultStopTimeout is applied when a container carries no explicit
|
||||
// stopTimeout, matching the EC2 launch-type default ECS itself uses.
|
||||
const defaultStopTimeout = 30 * time.Second
|
||||
|
||||
// pollAssignments drains the instance's assignment inbox on a ticker until ctx is
|
||||
// cancelled. Each assign is dispatched to runTask exactly once; the taskIDs seen
|
||||
// on a poll are acked on the next poll so the gateway can drop them — a crash
|
||||
@@ -23,7 +29,7 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
|
||||
ticker := time.NewTicker(a.cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
stopping := map[string]bool{}
|
||||
reaps := newStopLedger()
|
||||
var ackAssigns, ackStops []string
|
||||
for {
|
||||
select {
|
||||
@@ -40,17 +46,18 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
|
||||
ackStops = ackStops[:0]
|
||||
for i := range stops {
|
||||
sd := stops[i]
|
||||
if !stopping[sd.TaskID] {
|
||||
stopping[sd.TaskID] = true
|
||||
switch {
|
||||
case reaps.done(sd.TaskID):
|
||||
ackStops = append(ackStops, sd.TaskID)
|
||||
case reaps.begin(sd.TaskID):
|
||||
slog.Info("ecs-agent: task stop requested", "task", sd.TaskID, "reason", sd.Reason)
|
||||
go a.stopTask(ctx, sd)
|
||||
go func() { reaps.end(sd.TaskID, a.stopTask(ctx, sd)) }()
|
||||
}
|
||||
ackStops = append(ackStops, sd.TaskID)
|
||||
}
|
||||
ackAssigns = ackAssigns[:0]
|
||||
for i := range assigns {
|
||||
as := assigns[i]
|
||||
if !dispatched[as.TaskID] && !stopping[as.TaskID] {
|
||||
if !dispatched[as.TaskID] && !reaps.seen(as.TaskID) {
|
||||
dispatched[as.TaskID] = true
|
||||
slog.Info("ecs-agent: task assigned", "task", as.TaskID, "containers", len(as.Containers))
|
||||
go a.runTask(ctx, &as)
|
||||
@@ -61,27 +68,83 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
|
||||
}
|
||||
}
|
||||
|
||||
// stopTask reaps a task's containers on a control-plane stop directive: it lists
|
||||
// the runtime's containers, removes the ones labeled with this task (kill +
|
||||
// delete, releasing host ports/netns), then reports the task STOPPED with the
|
||||
// directive's reason. Idempotent — a task with no live containers still reports
|
||||
// STOPPED so the scheduler releases its capacity.
|
||||
func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) {
|
||||
if a.runner == nil {
|
||||
return
|
||||
// stopLedger tracks reaps in flight and reaps that reported STOPPED. The two
|
||||
// are separate because only the second may be acked: acking the first deletes a
|
||||
// directive that was never performed, and nothing re-posts it.
|
||||
type stopLedger struct {
|
||||
mu sync.Mutex
|
||||
inflight map[string]bool
|
||||
stopped map[string]bool
|
||||
}
|
||||
|
||||
func newStopLedger() *stopLedger {
|
||||
return &stopLedger{inflight: map[string]bool{}, stopped: map[string]bool{}}
|
||||
}
|
||||
|
||||
// begin claims a task for a reap, reporting false when one is already running.
|
||||
func (l *stopLedger) begin(taskID string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.inflight[taskID] {
|
||||
return false
|
||||
}
|
||||
containers, err := a.runner.List(ctx)
|
||||
l.inflight[taskID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// end releases the claim, recording the task as stopped only if the reap got
|
||||
// that far. A failed reap leaves nothing behind, so the next poll redelivers
|
||||
// the directive and dispatches it again.
|
||||
func (l *stopLedger) end(taskID string, reported bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.inflight, taskID)
|
||||
if reported {
|
||||
l.stopped[taskID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// done reports whether the task's reap reported STOPPED, which is the only
|
||||
// condition under which its directive may be acked.
|
||||
func (l *stopLedger) done(taskID string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.stopped[taskID]
|
||||
}
|
||||
|
||||
// seen reports whether a stop for the task is in flight or complete, so an
|
||||
// assignment arriving alongside its own stop is never started.
|
||||
func (l *stopLedger) seen(taskID string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.inflight[taskID] || l.stopped[taskID]
|
||||
}
|
||||
|
||||
// stopTask reaps a task's containers on a control-plane stop directive: it
|
||||
// lists the runtime's containers, gracefully stops (SIGTERM, wait up to the
|
||||
// container's stopTimeout, then force kill + delete) the ones labeled with
|
||||
// this task, then reports the task STOPPED with the directive's reason.
|
||||
// Idempotent — a task with no live containers still reports STOPPED so the
|
||||
// scheduler releases its capacity. Reports whether it got as far as saying
|
||||
// STOPPED: a caller may only ack the directive when it did.
|
||||
func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) bool {
|
||||
runner := a.rt.get()
|
||||
if runner == nil {
|
||||
slog.Warn("ecs-agent: stop deferred, no container runtime", "task", sd.TaskID)
|
||||
return false
|
||||
}
|
||||
containers, err := runner.List(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("ecs-agent: stop list failed", "task", sd.TaskID, "err", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
statuses := make([]bus.ContainerStatus, 0)
|
||||
for _, c := range containers {
|
||||
if c.Labels[labelTaskID] != sd.TaskID {
|
||||
continue
|
||||
}
|
||||
if rerr := a.runner.Remove(ctx, c.ID); rerr != nil {
|
||||
slog.Warn("ecs-agent: stop remove failed", "task", sd.TaskID, "container", c.ID, "err", rerr)
|
||||
if rerr := runner.Stop(ctx, c.ID, containerStopTimeout(c.Labels)); rerr != nil {
|
||||
slog.Warn("ecs-agent: stop failed", "task", sd.TaskID, "container", c.ID, "err", rerr)
|
||||
}
|
||||
statuses = append(statuses, bus.ContainerStatus{
|
||||
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
|
||||
@@ -93,13 +156,15 @@ func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) {
|
||||
}
|
||||
a.reportTaskState(&bus.Assign{TaskID: sd.TaskID}, bus.TaskStatusStopped, reason, statuses)
|
||||
slog.Info("ecs-agent: task reaped", "task", sd.TaskID, "containers", len(statuses))
|
||||
return true
|
||||
}
|
||||
|
||||
// runTask pulls each container image, starts the containers, and reports task
|
||||
// state on the bus. A missing runtime or any per-container failure reports the
|
||||
// task STOPPED with a reason; success reports RUNNING and waits for exit.
|
||||
func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
|
||||
if a.puller == nil || a.runner == nil {
|
||||
rt := a.rt.get()
|
||||
if rt == nil {
|
||||
a.reportTaskState(as, bus.TaskStatusStopped, "containerd unavailable on agent", nil)
|
||||
return
|
||||
}
|
||||
@@ -117,7 +182,12 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
|
||||
|
||||
statuses := make([]bus.ContainerStatus, 0, len(as.Containers))
|
||||
for _, c := range as.Containers {
|
||||
if _, err := a.puller.Pull(ctx, ctrruntime.PullSpec{Ref: c.Image}, resolver); err != nil {
|
||||
// startTimeout bounds pull-through-start as a unit: real ECS measures a
|
||||
// container's time-to-RUNNING from the same starting point.
|
||||
runCtx, cancel := containerStartCtx(ctx, c.StartTimeout)
|
||||
|
||||
if _, err := rt.Pull(runCtx, ctrruntime.PullSpec{Ref: c.Image}, resolver); err != nil {
|
||||
cancel()
|
||||
slog.Error("ecs-agent: pull failed", "task", as.TaskID, "image", c.Image, "err", err)
|
||||
a.teardownTaskNetns(as)
|
||||
a.reportTaskState(as, bus.TaskStatusStopped, "image pull failed: "+err.Error(), statuses)
|
||||
@@ -126,16 +196,29 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
|
||||
|
||||
cid := containerID(as.TaskID, c.Name)
|
||||
gpuIDs := a.pinContainerGPUs(as.TaskID, c.Name, c.GPU)
|
||||
spec := ctrruntime.RunSpec{
|
||||
Image: c.Image,
|
||||
Command: c.Command,
|
||||
Env: withCredEnv(c.Environment, credID),
|
||||
Labels: taskLabels(as, c.Name),
|
||||
NetnsPath: netnsPath,
|
||||
GPU: c.GPU,
|
||||
GPUIDs: gpuIDs,
|
||||
labels := taskLabels(as, c.Name)
|
||||
if c.StopTimeout != nil {
|
||||
labels[labelStopTimeout] = strconv.FormatInt(*c.StopTimeout, 10)
|
||||
}
|
||||
id, err := a.runner.Run(ctx, cid, spec)
|
||||
spec := ctrruntime.RunSpec{
|
||||
Image: c.Image,
|
||||
Command: c.Command,
|
||||
Env: withCredEnv(c.Environment, credID),
|
||||
Labels: labels,
|
||||
NetnsPath: netnsPath,
|
||||
GPU: c.GPU,
|
||||
GPUIDs: gpuIDs,
|
||||
User: c.User,
|
||||
ReadonlyRootFilesystem: c.ReadonlyRootFilesystem,
|
||||
Privileged: c.Privileged,
|
||||
PseudoTerminal: c.PseudoTerminal,
|
||||
Interactive: c.Interactive,
|
||||
SystemControls: toRuntimeSystemControls(c.SystemControls),
|
||||
CapAdd: c.CapAdd,
|
||||
CapDrop: c.CapDrop,
|
||||
}
|
||||
id, err := rt.Run(runCtx, cid, spec)
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Error("ecs-agent: run failed", "task", as.TaskID, "container", c.Name, "err", err)
|
||||
a.teardownTaskNetns(as)
|
||||
@@ -155,7 +238,12 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
|
||||
// waitContainer blocks until a container exits, then reports the task STOPPED
|
||||
// with the exit code. v1 stops the whole task when its first container exits.
|
||||
func (a *Agent) waitContainer(ctx context.Context, as *bus.Assign, name, containerID string) {
|
||||
status, err := a.runner.Wait(ctx, containerID)
|
||||
runner := a.rt.get()
|
||||
if runner == nil {
|
||||
slog.Warn("ecs-agent: cannot wait on container, no container runtime", "task", as.TaskID, "container", name)
|
||||
return
|
||||
}
|
||||
status, err := runner.Wait(ctx, containerID)
|
||||
if err != nil {
|
||||
slog.Warn("ecs-agent: wait container failed", "task", as.TaskID, "container", name, "err", err)
|
||||
return
|
||||
@@ -240,6 +328,44 @@ func withCredEnv(env map[string]string, credID string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// containerStartCtx bounds pull+run by a container's startTimeout, if set. A
|
||||
// nil startTimeout (the common case) returns ctx unchanged with a no-op
|
||||
// cancel, matching today's unbounded behaviour.
|
||||
func containerStartCtx(ctx context.Context, startTimeout *int64) (context.Context, context.CancelFunc) {
|
||||
if startTimeout == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithTimeout(ctx, time.Duration(*startTimeout)*time.Second)
|
||||
}
|
||||
|
||||
// containerStopTimeout reads the stopTimeout stamped on a container's labels
|
||||
// by runTask, falling back to defaultStopTimeout when absent or unparseable.
|
||||
func containerStopTimeout(labels map[string]string) time.Duration {
|
||||
v, ok := labels[labelStopTimeout]
|
||||
if !ok {
|
||||
return defaultStopTimeout
|
||||
}
|
||||
secs, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return defaultStopTimeout
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// toRuntimeSystemControls maps the assign's sysctl pairs to the runtime
|
||||
// package's own type, keeping cmd/ecs-agent/runtime decoupled from the ecs
|
||||
// handlers' bus package.
|
||||
func toRuntimeSystemControls(in []bus.SystemControl) []ctrruntime.SystemControl {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ctrruntime.SystemControl, len(in))
|
||||
for i, sc := range in {
|
||||
out[i] = ctrruntime.SystemControl{Namespace: sc.Namespace, Value: sc.Value}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pinContainerGPUs reserves n device UUIDs for a container from the local
|
||||
// ledger, before the container is started so the runner can inject them as
|
||||
// CDI devices. A short ledger (fewer free devices than requested, e.g.
|
||||
@@ -317,6 +443,9 @@ const (
|
||||
labelCredID = "mulga.ecs.credID"
|
||||
labelTaskRoleARN = "mulga.ecs.taskRoleArn"
|
||||
labelENIMac = "mulga.ecs.eniMac"
|
||||
// labelStopTimeout carries a container's stopTimeout (seconds) so stopTask
|
||||
// can read it back from List's labels; it has no assign to consult there.
|
||||
labelStopTimeout = "mulga.ecs.stopTimeout"
|
||||
)
|
||||
|
||||
// taskLabels are the mulga.ecs.* labels stamped on a container. The cred/role/MAC
|
||||
|
||||
+282
-14
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,13 +25,13 @@ func testAssign() *bus.Assign {
|
||||
}
|
||||
|
||||
func newRunAgent(cp controlPlane, rt *ctrruntime.FakePuller) *Agent {
|
||||
return newAgent(config{}, testIdentity(), cp, rt, rt, nil)
|
||||
return newAgent(config{}, testIdentity(), cp, rt, nil)
|
||||
}
|
||||
|
||||
// runTask with no runtime reports the task STOPPED instead of crashing.
|
||||
func TestRunTask_NoRuntimeReportsStopped(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, nil, nil)
|
||||
a.runTask(context.Background(), testAssign())
|
||||
|
||||
st := cp.taskStates()
|
||||
@@ -70,6 +71,157 @@ func TestRunTask_PullRunReportsRunning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each enforced field maps onto RunSpec exactly, asserted through the fake
|
||||
// runtime — the mapping this change exists to wire, without a real containerd.
|
||||
func TestRunTask_RuntimeFieldsCarriedToRunSpec(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newRunAgent(cp, rt)
|
||||
as := testAssign()
|
||||
ro := true
|
||||
priv := false
|
||||
tty := true
|
||||
interactive := true
|
||||
as.Containers[0].User = "1000:1000"
|
||||
as.Containers[0].ReadonlyRootFilesystem = &ro
|
||||
as.Containers[0].Privileged = &priv
|
||||
as.Containers[0].PseudoTerminal = &tty
|
||||
as.Containers[0].Interactive = &interactive
|
||||
as.Containers[0].SystemControls = []bus.SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}}
|
||||
as.Containers[0].CapAdd = []string{"SYS_PTRACE"}
|
||||
as.Containers[0].CapDrop = []string{"NET_RAW"}
|
||||
a.runTask(context.Background(), as)
|
||||
|
||||
if len(rt.Runs) != 1 {
|
||||
t.Fatalf("expected one run, got %+v", rt.Runs)
|
||||
}
|
||||
got := rt.Runs[0]
|
||||
if got.User != "1000:1000" {
|
||||
t.Errorf("User = %q, want 1000:1000", got.User)
|
||||
}
|
||||
if got.ReadonlyRootFilesystem == nil || !*got.ReadonlyRootFilesystem {
|
||||
t.Errorf("ReadonlyRootFilesystem = %v, want true", got.ReadonlyRootFilesystem)
|
||||
}
|
||||
if got.Privileged == nil || *got.Privileged {
|
||||
t.Errorf("Privileged = %v, want false (present, not applied)", got.Privileged)
|
||||
}
|
||||
if got.PseudoTerminal == nil || !*got.PseudoTerminal {
|
||||
t.Errorf("PseudoTerminal = %v, want true", got.PseudoTerminal)
|
||||
}
|
||||
if got.Interactive == nil || !*got.Interactive {
|
||||
t.Errorf("Interactive = %v, want true", got.Interactive)
|
||||
}
|
||||
if len(got.SystemControls) != 1 || got.SystemControls[0].Namespace != "net.core.somaxconn" {
|
||||
t.Errorf("SystemControls = %+v, want one net.core.somaxconn entry", got.SystemControls)
|
||||
}
|
||||
if len(got.CapAdd) != 1 || got.CapAdd[0] != "SYS_PTRACE" {
|
||||
t.Errorf("CapAdd = %+v, want [SYS_PTRACE]", got.CapAdd)
|
||||
}
|
||||
if len(got.CapDrop) != 1 || got.CapDrop[0] != "NET_RAW" {
|
||||
t.Errorf("CapDrop = %+v, want [NET_RAW]", got.CapDrop)
|
||||
}
|
||||
}
|
||||
|
||||
// An assign carrying none of the new fields leaves RunSpec's pointer fields
|
||||
// nil rather than defaulting to a false indistinguishable from "explicitly
|
||||
// not requested".
|
||||
func TestRunTask_NilRuntimeFieldsLeaveRunSpecUnset(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newRunAgent(cp, rt)
|
||||
a.runTask(context.Background(), testAssign())
|
||||
|
||||
if len(rt.Runs) != 1 {
|
||||
t.Fatalf("expected one run, got %+v", rt.Runs)
|
||||
}
|
||||
got := rt.Runs[0]
|
||||
if got.ReadonlyRootFilesystem != nil || got.Privileged != nil || got.PseudoTerminal != nil || got.Interactive != nil {
|
||||
t.Errorf("want all runtime-field pointers nil, got %+v", got)
|
||||
}
|
||||
if len(got.SystemControls) != 0 || len(got.CapAdd) != 0 || len(got.CapDrop) != 0 {
|
||||
t.Errorf("want no sysctls/capabilities, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTask_StampsStopTimeoutLabel verifies runTask stamps a container's
|
||||
// stopTimeout as a label — the only way stopTask (which only has List's
|
||||
// labels, not the original assign) can recover it later.
|
||||
func TestRunTask_StampsStopTimeoutLabel(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newRunAgent(cp, rt)
|
||||
as := testAssign()
|
||||
stop := int64(45)
|
||||
as.Containers[0].StopTimeout = &stop
|
||||
a.runTask(context.Background(), as)
|
||||
|
||||
if len(rt.Runs) != 1 {
|
||||
t.Fatalf("expected one run, got %+v", rt.Runs)
|
||||
}
|
||||
if got := rt.Runs[0].Labels[labelStopTimeout]; got != "45" {
|
||||
t.Errorf("stopTimeout label = %q, want 45", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTask_NoStopTimeoutLabelWhenUnset verifies a container with no
|
||||
// stopTimeout carries no label, so stopTask falls back to the default rather
|
||||
// than reading a stray "0".
|
||||
func TestRunTask_NoStopTimeoutLabelWhenUnset(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newRunAgent(cp, rt)
|
||||
a.runTask(context.Background(), testAssign())
|
||||
|
||||
if len(rt.Runs) != 1 {
|
||||
t.Fatalf("expected one run, got %+v", rt.Runs)
|
||||
}
|
||||
if _, ok := rt.Runs[0].Labels[labelStopTimeout]; ok {
|
||||
t.Errorf("want no stopTimeout label, got %+v", rt.Runs[0].Labels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopTask_UsesContainerStopTimeout verifies that stopTask reads a
|
||||
// container's stopTimeout label and calls the runner's graceful Stop (SIGTERM
|
||||
// + bounded wait) with it, instead of the hard kill+delete used before this
|
||||
// fix.
|
||||
func TestStopTask_UsesContainerStopTimeout(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{{
|
||||
ID: "t-001-web",
|
||||
Labels: map[string]string{
|
||||
labelTaskID: "t-001", labelContainerName: "web", labelStopTimeout: "45",
|
||||
},
|
||||
}}}
|
||||
a := newRunAgent(cp, rt)
|
||||
|
||||
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
|
||||
|
||||
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
|
||||
t.Fatalf("want t-001-web stopped, got %+v", rt.Stopped)
|
||||
}
|
||||
if len(rt.StopTimeouts) != 1 || rt.StopTimeouts[0] != 45*time.Second {
|
||||
t.Fatalf("want stopTimeout 45s, got %+v", rt.StopTimeouts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopTask_DefaultStopTimeoutWhenUnset verifies a container with no
|
||||
// stopTimeout label gets the EC2 launch-type default rather than an unbounded
|
||||
// or zero grace period.
|
||||
func TestStopTask_DefaultStopTimeoutWhenUnset(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{{
|
||||
ID: "t-001-web",
|
||||
Labels: map[string]string{labelTaskID: "t-001", labelContainerName: "web"},
|
||||
}}}
|
||||
a := newRunAgent(cp, rt)
|
||||
|
||||
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
|
||||
|
||||
if len(rt.StopTimeouts) != 1 || rt.StopTimeouts[0] != defaultStopTimeout {
|
||||
t.Fatalf("want default stopTimeout %v, got %+v", defaultStopTimeout, rt.StopTimeouts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTask_GPUCarriedToRunSpec verifies that the AssignContainer's GPU count
|
||||
// reaches the runtime RunSpec unchanged; a
|
||||
// container with no GPU request keeps RunSpec.GPU at zero (regression).
|
||||
@@ -100,7 +252,7 @@ func TestRunTask_GPUCarriedToRunSpec(t *testing.T) {
|
||||
func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, nil)
|
||||
as := testAssign()
|
||||
as.Containers = append(as.Containers, bus.AssignContainer{
|
||||
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
|
||||
@@ -144,7 +296,7 @@ func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
|
||||
func TestRunTask_GPUIDsReachRunSpec(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, nil)
|
||||
as := testAssign()
|
||||
as.Containers = append(as.Containers, bus.AssignContainer{
|
||||
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
|
||||
@@ -209,7 +361,7 @@ func TestStopTask_ReleasesGPUsBackToLedger(t *testing.T) {
|
||||
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "trainer",
|
||||
}},
|
||||
}}
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, rt, nil)
|
||||
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, nil)
|
||||
if _, err := a.gpu.Pin(gpuKey("t-001", "trainer"), 1); err != nil {
|
||||
t.Fatalf("seed pin: %v", err)
|
||||
}
|
||||
@@ -287,7 +439,7 @@ func TestPollAssignments_DispatchesOnceAndAcks(t *testing.T) {
|
||||
nil, // poll 3: gateway dropped it after the ack
|
||||
}}
|
||||
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go a.pollAssignments(ctx, map[string]bool{})
|
||||
@@ -342,8 +494,8 @@ func TestStopTask_ReapsLabeledContainersAndReportsStopped(t *testing.T) {
|
||||
|
||||
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
|
||||
|
||||
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
|
||||
t.Fatalf("want only t-001-web removed, got %+v", rt.Removed)
|
||||
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
|
||||
t.Fatalf("want only t-001-web stopped, got %+v", rt.Stopped)
|
||||
}
|
||||
st := cp.taskStates()
|
||||
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
|
||||
@@ -381,12 +533,12 @@ func TestPollAssignments_StopReapsAndSuppressesAssign(t *testing.T) {
|
||||
as := testAssign()
|
||||
cp := &fakeCP{
|
||||
pollReplies: [][]bus.Assign{{*as}, {*as}, nil},
|
||||
stopReplies: [][]bus.StopDirective{{{TaskID: as.TaskID, Reason: "bye"}}},
|
||||
stopPending: []bus.StopDirective{{TaskID: as.TaskID, Reason: "bye"}},
|
||||
}
|
||||
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
|
||||
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
|
||||
}}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go a.pollAssignments(ctx, map[string]bool{})
|
||||
@@ -425,10 +577,126 @@ func TestPollAssignments_StopReapsAndSuppressesAssign(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// countStopAcks reports how many polls carried taskID in their stop-ack list.
|
||||
func countStopAcks(acks [][]string, taskID string) int {
|
||||
n := 0
|
||||
for _, ack := range acks {
|
||||
if slices.Contains(ack, taskID) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A reap that cannot run is never acked, so the directive stays in the gateway's
|
||||
// inbox and is redelivered and retried until the runtime comes back.
|
||||
func TestPollAssignments_UnperformableStopIsNotAckedAndRetried(t *testing.T) {
|
||||
cp := &fakeCP{stopPending: []bus.StopDirective{{TaskID: "t-001", Reason: "bye"}}}
|
||||
rt := &ctrruntime.FakePuller{ListErr: errors.New("containerd is down")}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.pollAssignments(ctx, map[string]bool{})
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for rt.Lists() < 3 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("failed reap was not retried; List calls=%d", rt.Lists())
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
if n := countStopAcks(cp.stopAcks(), "t-001"); n != 0 {
|
||||
t.Errorf("a reap that never ran was acked %d times; stopAcks=%v", n, cp.stopAcks())
|
||||
}
|
||||
if stopped := countStatus(cp.taskStates(), "t-001", bus.TaskStatusStopped); stopped != 0 {
|
||||
t.Errorf("a reap that never ran reported STOPPED %d times", stopped)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the runtime recovers, the redelivered directive is reaped, reported
|
||||
// STOPPED and acked exactly once, which deletes it from the inbox.
|
||||
func TestPollAssignments_RecoveredStopIsAckedExactlyOnce(t *testing.T) {
|
||||
cp := &fakeCP{stopPending: []bus.StopDirective{{TaskID: "t-001", Reason: "bye"}}}
|
||||
rt := &ctrruntime.FakePuller{
|
||||
ListErr: errors.New("containerd is down"),
|
||||
ListErrFor: 1,
|
||||
Containers: []ctrruntime.Container{
|
||||
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
|
||||
},
|
||||
}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.pollAssignments(ctx, map[string]bool{})
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for countStopAcks(cp.stopAcks(), "t-001") == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("recovered reap was never acked; states=%+v stopAcks=%v", cp.taskStates(), cp.stopAcks())
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
if n := countStopAcks(cp.stopAcks(), "t-001"); n != 1 {
|
||||
t.Errorf("stop acked %d times, want exactly 1; stopAcks=%v", n, cp.stopAcks())
|
||||
}
|
||||
if stopped := countStatus(cp.taskStates(), "t-001", bus.TaskStatusStopped); stopped != 1 {
|
||||
t.Errorf("task reported STOPPED %d times, want exactly 1", stopped)
|
||||
}
|
||||
if reaped := rt.Reaped(); len(reaped) != 1 || reaped[0] != "t-001-web" {
|
||||
t.Errorf("want t-001-web reaped once, got %+v", reaped)
|
||||
}
|
||||
}
|
||||
|
||||
// A task whose reap completed is never started again by an assign the gateway
|
||||
// is still redelivering.
|
||||
func TestPollAssignments_StoppedTaskIsNotRerunByRedeliveredAssign(t *testing.T) {
|
||||
as := testAssign()
|
||||
cp := &fakeCP{
|
||||
pollReplies: [][]bus.Assign{{*as}, {*as}, {*as}, {*as}, {*as}, {*as}},
|
||||
stopPending: []bus.StopDirective{{TaskID: as.TaskID, Reason: "bye"}},
|
||||
}
|
||||
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
|
||||
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
|
||||
}}
|
||||
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.pollAssignments(ctx, map[string]bool{})
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for countStopAcks(cp.stopAcks(), as.TaskID) == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("stop never completed; states=%+v", cp.taskStates())
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
// Let the remaining scripted assigns arrive after the reap has finished.
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
if runs := rt.RunCalls(); len(runs) != 0 {
|
||||
t.Errorf("stopped task was started by a redelivered assign: %+v", runs)
|
||||
}
|
||||
if running := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusRunning); running != 0 {
|
||||
t.Errorf("stopped task reported RUNNING %d times", running)
|
||||
}
|
||||
}
|
||||
|
||||
// stopTask with no runtime is a no-op: nothing to reap, no state reported.
|
||||
func TestStopTask_NilRunnerNoOp(t *testing.T) {
|
||||
cp := &fakeCP{}
|
||||
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
|
||||
a := newAgent(config{}, testIdentity(), cp, nil, nil)
|
||||
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "x"})
|
||||
if len(cp.taskStates()) != 0 {
|
||||
t.Fatalf("nil runner should report no state, got %+v", cp.taskStates())
|
||||
@@ -459,8 +727,8 @@ func TestStopTask_EmptyReasonReapsWithDefault(t *testing.T) {
|
||||
a := newRunAgent(cp, rt)
|
||||
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001"})
|
||||
|
||||
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
|
||||
t.Fatalf("want container t-001-web reaped, got %+v", rt.Removed)
|
||||
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
|
||||
t.Fatalf("want container t-001-web reaped, got %+v", rt.Stopped)
|
||||
}
|
||||
st := cp.taskStates()
|
||||
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
|
||||
@@ -539,7 +807,7 @@ func (sentinelResolver) Authorize(context.Context, string) (string, string, stri
|
||||
// exec-role resolver is built.
|
||||
func TestPullResolver(t *testing.T) {
|
||||
fake := sentinelResolver{}
|
||||
a := newAgent(config{Region: "us-east-1"}, testIdentity(), &fakeCP{}, nil, nil, fake)
|
||||
a := newAgent(config{Region: "us-east-1"}, testIdentity(), &fakeCP{}, nil, fake)
|
||||
|
||||
if got := a.pullResolver(&bus.Assign{TaskID: "t1"}); got != fake {
|
||||
t.Fatalf("no execution role: want instance resolver fallback")
|
||||
|
||||
@@ -33,12 +33,12 @@ func serverTLSConfig(cert tls.Certificate) *tls.Config {
|
||||
// valid even if the webhook process is restarted. Returns the parsed cert and
|
||||
// the certificate PEM (for embedding as the kubeconfig CA bundle).
|
||||
func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error) {
|
||||
if certPEM, keyPEM, ok := readCertPair(certPath, keyPath); ok {
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err == nil {
|
||||
// Fall through to regenerate on a missing/corrupt/incompatible persisted
|
||||
// pair. The cert PEM is re-read because tls.Certificate keeps only DER.
|
||||
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
if certPEM, err := os.ReadFile(certPath); err == nil {
|
||||
return cert, certPEM, nil
|
||||
}
|
||||
// Fall through to regenerate on a corrupt/incompatible persisted pair.
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err := generateSelfSigned()
|
||||
@@ -58,18 +58,6 @@ func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error
|
||||
return cert, certPEM, nil
|
||||
}
|
||||
|
||||
func readCertPair(certPath, keyPath string) (certPEM, keyPEM []byte, ok bool) {
|
||||
c, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
k, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return c, k, true
|
||||
}
|
||||
|
||||
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
|
||||
+260
-50
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/masterkey"
|
||||
"github.com/mulgadc/bluebottle/pkg/safecast"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/clustersize"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
|
||||
@@ -200,6 +201,15 @@ takes effect immediately. Prompts for confirmation unless --yes is passed.`,
|
||||
Run: runimagesPromoteCmd,
|
||||
}
|
||||
|
||||
var imagesDescribeCmd = &cobra.Command{
|
||||
Use: "describe",
|
||||
Short: "Show an AMI's metadata and source digest",
|
||||
Long: `Print an AMI's control-plane metadata, including the digest of the
|
||||
artifact it was imported from and who verified it. The digest describes the
|
||||
imported source file, not the volume's current contents.`,
|
||||
Run: runimagesDescribeCmd,
|
||||
}
|
||||
|
||||
var volumesCmd = &cobra.Command{
|
||||
Use: "volumes",
|
||||
Short: "Inspect block storage volumes",
|
||||
@@ -294,7 +304,7 @@ spx admin images import --name debian-13-x86_64
|
||||
spx admin images list
|
||||
|
||||
// Manually import a path
|
||||
spx admin images import --file /path/to/image --distro debian --version 13 --arch x86_64
|
||||
spx admin images import --file /path/to/image --checksum /path/to/SHA512SUMS --distro debian --version 13 --arch x86_64
|
||||
|
||||
-> x <-
|
||||
*/
|
||||
@@ -328,6 +338,7 @@ func init() {
|
||||
imagesCmd.AddCommand(imagesListCmd)
|
||||
imagesCmd.AddCommand(imagesRemoveCmd)
|
||||
imagesCmd.AddCommand(imagesPromoteCmd)
|
||||
imagesCmd.AddCommand(imagesDescribeCmd)
|
||||
|
||||
adminCmd.AddCommand(volumesCmd)
|
||||
volumesCmd.AddCommand(volumesOrphansCmd)
|
||||
@@ -362,7 +373,7 @@ func init() {
|
||||
adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create")
|
||||
adminInitCmd.Flags().String("az", "ap-southeast-2a", "Mulga AZ to create")
|
||||
adminInitCmd.Flags().String("node", "node1", "Node name, increment for additional nodes (default, node1)")
|
||||
adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster")
|
||||
adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster (1, or 3 and up)")
|
||||
adminInitCmd.Flags().String("host", "", "Leader node to join (if not specified, tries multicast discovery)")
|
||||
adminInitCmd.Flags().Int("port", 4432, "Port to bind cluster services on")
|
||||
adminInitCmd.Flags().String("bind", "0.0.0.0", "IP address to bind services to (e.g., 10.11.12.1 for multi-node). Default 0.0.0.0 listens on all interfaces.")
|
||||
@@ -427,6 +438,7 @@ func init() {
|
||||
imagesImportCmd.Flags().StringSlice("tag", nil, "Tag to apply to the imported AMI as key=value (repeatable; e.g. --tag spinifex:managed-by=elbv2)")
|
||||
imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)")
|
||||
imagesImportCmd.Flags().Bool("skip-verify", false, "Skip catalog-image checksum verification (INSECURE; operator assumes integrity responsibility)")
|
||||
imagesImportCmd.Flags().String("checksum", "", "Sums file to verify a --file image against (SHA256SUMS, SHA512SUMS, <image>.sha256/.sha512)")
|
||||
|
||||
imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)")
|
||||
imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)")
|
||||
@@ -436,6 +448,9 @@ func init() {
|
||||
imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)")
|
||||
imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt")
|
||||
_ = imagesPromoteCmd.MarkFlagRequired("image-id")
|
||||
|
||||
imagesDescribeCmd.Flags().String("image-id", "", "AMI ID to describe (required)")
|
||||
_ = imagesDescribeCmd.MarkFlagRequired("image-id")
|
||||
}
|
||||
|
||||
const bytesPerGiB = 1024 * 1024 * 1024
|
||||
@@ -464,6 +479,108 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 {
|
||||
return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
|
||||
}
|
||||
|
||||
// validateChecksumFlags rejects --checksum combinations that would otherwise be silently ignored.
|
||||
// An explicitly empty --checksum (e.g. an unset shell variable) must not fall through to an unverified import.
|
||||
func validateChecksumFlags(checksumPath string, checksumSet bool, localFile string, skipVerify bool) error {
|
||||
if !checksumSet {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(checksumPath) == "" {
|
||||
return errors.New("--checksum requires a sums file path")
|
||||
}
|
||||
if localFile == "" {
|
||||
return errors.New("--checksum requires --file")
|
||||
}
|
||||
if skipVerify {
|
||||
return errors.New("--checksum and --skip-verify are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// catalogPinnedDigestSource records that the expected digest came from this
|
||||
// binary's catalog, so a recorded digest never reads as a fetched sums file.
|
||||
const catalogPinnedDigestSource = "catalog:pinned"
|
||||
|
||||
// importSource is the file an import is about to extract, and what it is to be verified against.
|
||||
type importSource struct {
|
||||
imageFile, imageName, localFile string
|
||||
image utils.Images
|
||||
checksumPath, checksumAlgo, expectedDigest string
|
||||
skipVerify bool
|
||||
}
|
||||
|
||||
// resolveDigest verifies or hashes the source before extract and returns the digest to record.
|
||||
// Upstream digests are of the artifact as supplied. Failures are reported to errOut before
|
||||
// returning; the file is left in place, and a catalog download recovers with --force.
|
||||
func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageDigest, error) {
|
||||
digest := ebsmetadata.ImageDigest{Filename: filepath.Base(src.imageFile)}
|
||||
switch sourceDigestMode(src.localFile, src.checksumPath, src.skipVerify) {
|
||||
case ebsmetadata.DigestCatalog:
|
||||
if src.image.ChecksumType == "" || (src.image.Checksum == "" && src.image.ChecksumDigest == "") {
|
||||
fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumDigest/ChecksumType; refusing import.\n", src.imageName)
|
||||
return digest, errors.New("catalog entry has no checksum")
|
||||
}
|
||||
// A pinned digest is checked against this file rather than a sums file
|
||||
// fetched from the same host that served the image.
|
||||
if src.image.ChecksumDigest != "" {
|
||||
actual, err := utils.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest)
|
||||
if err != nil {
|
||||
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
|
||||
return digest, err
|
||||
}
|
||||
fmt.Fprintf(out, "✅ Verified image checksum (%s) against the pinned catalog digest\n", src.image.ChecksumType)
|
||||
digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, catalogPinnedDigestSource
|
||||
digest.Verification = ebsmetadata.DigestCatalog
|
||||
return digest, nil
|
||||
}
|
||||
actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType)
|
||||
if err != nil {
|
||||
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
|
||||
return digest, err
|
||||
}
|
||||
fmt.Fprintf(out, "✅ Verified image checksum (%s)\n", src.image.ChecksumType)
|
||||
digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, src.image.Checksum
|
||||
digest.Verification = ebsmetadata.DigestCatalog
|
||||
case ebsmetadata.DigestOperator:
|
||||
actual, err := utils.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err)
|
||||
fmt.Fprintf(errOut, " file: %s\n", src.localFile)
|
||||
fmt.Fprintf(errOut, " checksum: %s\n", src.checksumPath)
|
||||
return digest, err
|
||||
}
|
||||
fmt.Fprintf(out, "✅ Verified image checksum (%s) against %s\n", src.checksumAlgo, src.checksumPath)
|
||||
digest.Algorithm, digest.Value, digest.Source = src.checksumAlgo, actual, filepath.Base(src.checksumPath)
|
||||
digest.Verification = ebsmetadata.DigestOperator
|
||||
default:
|
||||
if src.localFile != "" {
|
||||
fmt.Fprintf(errOut, "Importing %s without checksum validation (pass --checksum <sums-file> to verify)\n", src.localFile)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName)
|
||||
}
|
||||
actual, err := utils.HashImageFile(src.imageFile, "sha256")
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Could not hash image: %v\n", err)
|
||||
return digest, err
|
||||
}
|
||||
digest.Algorithm, digest.Value = "sha256", actual
|
||||
digest.Verification = ebsmetadata.DigestUnverified
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// sourceDigestMode picks how an import's source digest is obtained.
|
||||
func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string {
|
||||
switch {
|
||||
case localFile != "" && checksumPath != "":
|
||||
return ebsmetadata.DigestOperator
|
||||
case localFile != "" || skipVerify:
|
||||
return ebsmetadata.DigestUnverified
|
||||
default:
|
||||
return ebsmetadata.DigestCatalog
|
||||
}
|
||||
}
|
||||
|
||||
func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
var image utils.Images
|
||||
|
||||
@@ -503,6 +620,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
checksumPath, _ := cmd.Flags().GetString("checksum")
|
||||
if err := validateChecksumFlags(checksumPath, cmd.Flags().Changed("checksum"), localFile, skipVerify); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var checksumAlgo, expectedDigest string
|
||||
if checksumPath != "" {
|
||||
checksumAlgo, expectedDigest, err = utils.ReadExpectedDigest(checksumPath, filepath.Base(localFile))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not read checksum from %s: %v\n", checksumPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if imageName != "" {
|
||||
var exists bool
|
||||
image, exists = utils.AvailableImages[imageName]
|
||||
@@ -589,8 +720,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Next, if the file is selected to download, fetch it, extract disk image, and save to path
|
||||
if imageName != "" && localFile == "" {
|
||||
// Next, if the file is selected to download, fetch it and save to path
|
||||
if localFile == "" {
|
||||
// Download the file to the image path
|
||||
filename := path.Base(image.URL)
|
||||
imageFile = fmt.Sprintf("%s/%s", imagePath, filename)
|
||||
@@ -606,24 +737,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify before extract: the catalog digest is of the artifact as it
|
||||
// sits on the mirror (.tar.xz/.img/.raw). Also catches a poisoned
|
||||
// cache on the re-run path — failure leaves the file on disk so the
|
||||
// operator can inspect; recover with --force.
|
||||
if skipVerify {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ --skip-verify set: checksum verification skipped for %s\n", imageName)
|
||||
} else {
|
||||
if image.Checksum == "" || image.ChecksumType == "" {
|
||||
fmt.Fprintf(os.Stderr, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", imageName)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := utils.VerifyImageChecksum(imageFile, image.Checksum, image.ChecksumType); err != nil {
|
||||
printChecksumError(os.Stderr, imageFile, imageName, image, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Verified image checksum (%s)\n", image.ChecksumType)
|
||||
}
|
||||
sourceDigest, err := importSource{
|
||||
imageFile: imageFile,
|
||||
imageName: imageName,
|
||||
localFile: localFile,
|
||||
image: image,
|
||||
checksumPath: checksumPath,
|
||||
checksumAlgo: checksumAlgo,
|
||||
expectedDigest: expectedDigest,
|
||||
skipVerify: skipVerify,
|
||||
}.resolveDigest(os.Stdout, os.Stderr)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
|
||||
@@ -660,7 +787,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
Description: fmt.Sprintf("%s cloud image prepared for Spinifex", amiName),
|
||||
Architecture: image.Arch,
|
||||
PlatformDetails: image.Platform,
|
||||
CreationDate: time.Now(),
|
||||
CreationDate: time.Now().UTC(),
|
||||
RootDeviceType: "ebs",
|
||||
Virtualization: "hvm",
|
||||
ImageOwnerAlias: "system",
|
||||
@@ -669,6 +796,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
BootMode: image.BootMode,
|
||||
Distro: image.Distro,
|
||||
DistroFamily: utils.DistroFamily(image.Distro),
|
||||
SourceDigest: &sourceDigest,
|
||||
}
|
||||
|
||||
// Copy catalog-provided tags (e.g. spinifex:managed-by for system AMIs)
|
||||
@@ -739,6 +867,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
|
||||
fmt.Printf("✅ Source digest: %s:%s (%s)\n", sourceDigest.Algorithm, sourceDigest.Value, sourceDigest.Verification)
|
||||
}
|
||||
|
||||
// registerImportedAMISnapshot writes the EC2 control plane's snapshot document
|
||||
@@ -1001,6 +1130,62 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias)
|
||||
}
|
||||
|
||||
func runimagesDescribeCmd(cmd *cobra.Command, args []string) {
|
||||
imageID, _ := cmd.Flags().GetString("image-id")
|
||||
|
||||
cfgFile, _ := cmd.Flags().GetString("config")
|
||||
if cfgFile == "" {
|
||||
cfgFile = DefaultConfigFile()
|
||||
}
|
||||
|
||||
appConfig, err := config.LoadConfig(cfgFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error loading config file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
node := appConfig.Nodes[appConfig.Node]
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(
|
||||
node.Predastore.Host,
|
||||
node.Predastore.Region,
|
||||
node.Predastore.AccessKey,
|
||||
node.Predastore.SecretKey,
|
||||
)
|
||||
|
||||
meta, err := admin.GetAMIMetadata(store, node.Predastore.Bucket, imageID)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to describe AMI:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
printAMIDescription(os.Stdout, meta)
|
||||
}
|
||||
|
||||
// printAMIDescription writes an AMI's metadata and source digest for an operator.
|
||||
func printAMIDescription(w io.Writer, meta ebsmetadata.AMI) {
|
||||
fmt.Fprintf(w, "Image ID: %s\n", meta.ImageID)
|
||||
fmt.Fprintf(w, "Name: %s\n", meta.Name)
|
||||
fmt.Fprintf(w, "Owner: %s\n", meta.ImageOwnerAlias)
|
||||
if !meta.CreationDate.IsZero() {
|
||||
fmt.Fprintf(w, "Created: %s\n", meta.CreationDate.UTC().Format("2006-01-02T15:04:05Z"))
|
||||
}
|
||||
fmt.Fprintf(w, "Boot mode: %s\n", meta.BootMode)
|
||||
fmt.Fprintf(w, "Architecture: %s\n", meta.Architecture)
|
||||
|
||||
d := meta.SourceDigest
|
||||
if d == nil {
|
||||
fmt.Fprintln(w, "Source digest: not recorded (imported before digest recording)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Source digest: %s:%s\n", d.Algorithm, d.Value)
|
||||
if d.Source != "" {
|
||||
fmt.Fprintf(w, "Verification: %s (%s)\n", d.Verification, d.Source)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Verification: %s\n", d.Verification)
|
||||
}
|
||||
fmt.Fprintf(w, "Source file: %s\n", d.Filename)
|
||||
}
|
||||
|
||||
// List remote images available.
|
||||
func runimagesListCmd(cmd *cobra.Command, args []string) {
|
||||
//fmt.Println(availableImages)
|
||||
@@ -1028,6 +1213,26 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
|
||||
pterm.Println("spx admin images import --name <image-name>")
|
||||
}
|
||||
|
||||
// validateNodeCount accepts the cluster sizes Spinifex supports: one server, or
|
||||
// three and up.
|
||||
//
|
||||
// Two is rejected rather than allowed and warned about. On two servers OVN runs
|
||||
// standalone, the storage metadata quorum has no majority to lose, and the
|
||||
// control plane's own KV state has no replica count that works — one replica
|
||||
// disappears with its node, and two stop accepting writes when either goes. A
|
||||
// cluster that cannot survive the event it was built for is worth refusing at the
|
||||
// point someone asks for it.
|
||||
func validateNodeCount(nodes int) error {
|
||||
if nodes < 1 {
|
||||
return fmt.Errorf("a cluster needs at least one node, got %d", nodes)
|
||||
}
|
||||
if nodes > 1 && nodes < clustersize.MinHANodes {
|
||||
return fmt.Errorf("%d nodes is not a supported cluster size: use 1 for a single server, or %d and up for a cluster that can survive losing one",
|
||||
nodes, clustersize.MinHANodes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Move all logic to a module, use minimal application logic in viper commands.
|
||||
func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
if os.Getuid() != 0 {
|
||||
@@ -1059,6 +1264,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
clusterName, _ := cmd.Flags().GetString("cluster-name")
|
||||
services, _ := cmd.Flags().GetStringSlice("services")
|
||||
|
||||
// Rejected before anything is written, because a two-node cluster has no
|
||||
// correct answer rather than a worse one: no layer of the stack has a
|
||||
// majority to lose, and no replica count for its state both survives a node
|
||||
// going away and still accepts writes.
|
||||
if err := validateNodeCount(nodes); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "--nodes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Optional operator email — validated up-front so a bad address fails
|
||||
// before we touch any config state. Empty is allowed here; reset / repeat
|
||||
// inits on a box that already has an email in /etc/spinifex/spinifex.toml
|
||||
@@ -1116,7 +1330,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
detected, err := admin.DetectNetwork()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, " Use --external-mode=nat for outbound-only VMs on a non-bridgeable uplink, or specify --external-* flags manually.\n")
|
||||
fmt.Fprintf(os.Stderr, " Use --external-mode=nat on a non-bridgeable uplink, or specify --external-* flags manually.\n")
|
||||
} else {
|
||||
detectedNet = detected
|
||||
|
||||
@@ -1154,7 +1368,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
|
||||
if isNonBridgeableUplink(detected.WAN.Name) {
|
||||
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
|
||||
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
|
||||
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead:\n")
|
||||
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
|
||||
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
|
||||
os.Exit(1)
|
||||
@@ -1176,9 +1390,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
// the transit segment claims externalGateway below.
|
||||
natPublicGateway := externalGateway
|
||||
if externalMode == "nat" {
|
||||
// Verified on three OCI nodes 2026-09-26: each node delivers its own
|
||||
// instances' public IPs, inbound and outbound. Still flagged because no
|
||||
// nightly cell covers multi-node nat yet.
|
||||
if nodes >= 2 {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: --external-mode=nat is single-node only (v1); use --nodes=1\n")
|
||||
os.Exit(1)
|
||||
fmt.Fprintf(os.Stderr, "⚠️ --external-mode=nat on %d nodes is NEW.\n", nodes)
|
||||
fmt.Fprintf(os.Stderr, " Public IPs are delivered by the node running the instance; default egress stays on the VPC's gateway chassis.\n")
|
||||
fmt.Fprintf(os.Stderr, " Verified by hand on three nodes, but not yet covered by a nightly cell.\n")
|
||||
fmt.Fprintf(os.Stderr, " Check with: ip route show dev spx-nat-host — exactly one node should hold each public address.\n")
|
||||
}
|
||||
if !natPublicPool && (externalBindBridge != "" || gatewayIP != "") {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: --external-bind-bridge/--gateway-ip require --external-pool or --external-source in --external-mode=nat\n")
|
||||
@@ -1439,7 +1658,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
// Generate SSL certificates (with bind IP in SANs for multi-node support)
|
||||
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSInternalSuffix)
|
||||
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSServicesDomain)
|
||||
|
||||
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
|
||||
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
|
||||
@@ -1508,14 +1727,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
bootstrapSubnetId := utils.GenerateResourceID("subnet")
|
||||
bootstrapIgwId := utils.GenerateResourceID("igw")
|
||||
networkConfig.ExternalMode = externalMode
|
||||
networkConfig.PoolName = "wan"
|
||||
networkConfig.PoolSource = externalSource
|
||||
networkConfig.PoolBindBridge = externalBindBridge
|
||||
networkConfig.PoolStart = poolStart
|
||||
networkConfig.PoolEnd = poolEnd
|
||||
networkConfig.PoolGateway = externalGateway
|
||||
networkConfig.PoolGatewayIP = gatewayIP
|
||||
networkConfig.PoolPrefixLen = externalPrefixLen
|
||||
networkConfig.Pools = externalPools
|
||||
networkConfig.PoolDNSServers = dnsServers
|
||||
networkConfig.BootstrapAccountId = admin.DefaultAccountID()
|
||||
networkConfig.BootstrapVpcId = bootstrapVpcId
|
||||
@@ -1668,7 +1880,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf(" Public pool: dhcp via %s\n", externalBindBridge)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("\n📡 External networking: nat (routed, outbound-only — no public IPs/EIPs)\n")
|
||||
fmt.Printf("\n📡 External networking: nat (routed; no public pool configured yet)\n")
|
||||
}
|
||||
fmt.Printf(" Transit: %s via %s (host masquerades out any uplink)\n", host.NATTransitCIDR, host.NATTransitHostEnd)
|
||||
fmt.Printf(" Host setup: ./scripts/setup-ovn.sh --nat-uplink (run before starting services)\n")
|
||||
@@ -2423,7 +2635,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
// Generate server cert signed by CA with this node's bind IP
|
||||
if err := admin.GenerateServerCertOnly(configDir, bindIP, region, config.DefaultAWSInternalSuffix); err != nil {
|
||||
if err := admin.GenerateServerCertOnly(configDir, bindIP, region, config.DefaultAWSServicesDomain); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -2642,7 +2854,7 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
|
||||
|
||||
// Background: this runs at CLI top level, where there is no request to
|
||||
// inherit a deadline from and the process exits after the one command.
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey, len(cfg.Nodes))
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
|
||||
@@ -3032,18 +3244,11 @@ func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkCon
|
||||
settings.IPSecEnabled = nc.IPSecEnabled
|
||||
settings.ExternalMode = nc.ExternalMode
|
||||
settings.PoolDNSServers = nc.PoolDNSServers
|
||||
// Derived from the mode rather than carried in NetworkConfig, so the leader
|
||||
// and every joiner pin the same value from the same rule as single-node init.
|
||||
settings.BridgeMode = bridgeModeFor(nc.ExternalMode)
|
||||
if nc.ExternalMode != "" {
|
||||
settings.Pools = []admin.PoolData{{
|
||||
Name: nc.PoolName,
|
||||
Source: nc.PoolSource,
|
||||
BindBridge: nc.PoolBindBridge,
|
||||
Start: nc.PoolStart,
|
||||
End: nc.PoolEnd,
|
||||
Gateway: nc.PoolGateway,
|
||||
GatewayIP: nc.PoolGatewayIP,
|
||||
PrefixLen: nc.PoolPrefixLen,
|
||||
DNSServers: nc.PoolDNSServers,
|
||||
}}
|
||||
settings.Pools = nc.Pools
|
||||
}
|
||||
|
||||
settings.BootstrapAccountId = nc.BootstrapAccountId
|
||||
@@ -3744,7 +3949,12 @@ func resolveIfaceIP(iface string) string {
|
||||
func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) {
|
||||
fmt.Fprintf(w, "Image integrity verification failed: %v\n", err)
|
||||
fmt.Fprintf(w, " file: %s\n", imageFile)
|
||||
fmt.Fprintf(w, " source: %s\n", image.Checksum)
|
||||
if image.ChecksumDigest != "" {
|
||||
fmt.Fprintf(w, " source: %s (%s)\n", catalogPinnedDigestSource, image.ChecksumDigest)
|
||||
fmt.Fprintf(w, " url: %s\n", image.URL)
|
||||
} else {
|
||||
fmt.Fprintf(w, " source: %s\n", image.Checksum)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:")
|
||||
fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var kvReplicasCmd = &cobra.Command{
|
||||
Use: "replicas",
|
||||
Short: "Report how many nodes each KV bucket is replicated across",
|
||||
Long: `Print one line per JetStream KV bucket: how many replicas it has, how many the
|
||||
cluster's size entitles it to, and which nodes currently hold it.
|
||||
|
||||
A bucket with fewer replicas than the cluster has nodes lives on fewer nodes
|
||||
than exist, and every node depends on those. Losing one of them makes the
|
||||
bucket unreadable and unwritable from every node at once, which is a
|
||||
cluster-wide outage caused by a single-node event.
|
||||
|
||||
A bucket also has to be able to serve, which is a different question from how
|
||||
many replicas it was configured for. ONLINE counts the replicas that are caught
|
||||
up and reachable; a bucket whose ONLINE is not a majority of REPLICAS cannot
|
||||
accept a write no matter what its replica count says.
|
||||
|
||||
--repair raises any under-replicated bucket to the cluster's replica count,
|
||||
capped at JetStream's maximum of five. It changes nothing else about a bucket
|
||||
and never lowers a replica count, so it is safe to run on a healthy cluster and
|
||||
safe to run twice.
|
||||
|
||||
Exit status separates the two problems, so this can gate a deployment without
|
||||
parsing the output:
|
||||
|
||||
0 every bucket is at the cluster's replica count and can serve
|
||||
1 at least one bucket is under-replicated — --repair is the answer
|
||||
2 at least one bucket has no quorum, or the report could not be produced —
|
||||
a node needs looking at, and --repair will not help`,
|
||||
Run: runKVReplicas,
|
||||
}
|
||||
|
||||
func init() {
|
||||
kvCmd.AddCommand(kvReplicasCmd)
|
||||
kvReplicasCmd.Flags().Bool("repair", false, "raise every under-replicated bucket to the cluster's node count")
|
||||
kvReplicasCmd.Flags().Bool("json", false, "emit the report as JSON")
|
||||
}
|
||||
|
||||
func runKVReplicas(cmd *cobra.Command, _ []string) {
|
||||
repair, _ := cmd.Flags().GetBool("repair")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
// Anything that stops the report being produced exits unreachable rather
|
||||
// than under-replicated: an empty answer is not a cluster with no buckets,
|
||||
// and --repair is not the response to it.
|
||||
_, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect to cluster: %v\n", err)
|
||||
os.Exit(exitUnreachable)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "get JetStream context: %v\n", err)
|
||||
os.Exit(exitUnreachable)
|
||||
}
|
||||
|
||||
// Background: this runs at CLI top level, where there is no request to
|
||||
// inherit a deadline from and the process exits after the one command.
|
||||
ctx := context.Background()
|
||||
reports, err := kvutil.AuditBucketReplicas(ctx, js)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "audit KV bucket replicas: %v\n", err)
|
||||
os.Exit(exitUnreachable)
|
||||
}
|
||||
|
||||
if repair {
|
||||
repairReplicaReports(ctx, js, reports, os.Stderr)
|
||||
}
|
||||
|
||||
if err := writeReplicaReport(os.Stdout, reports, asJSON); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write report: %v\n", err)
|
||||
os.Exit(exitUnreachable)
|
||||
}
|
||||
|
||||
os.Exit(replicaReportExit(reports))
|
||||
}
|
||||
|
||||
// Exit statuses, documented in the command's own help because a deployment gate
|
||||
// reads them rather than the output.
|
||||
const (
|
||||
exitHealthy = 0
|
||||
// exitUnderReplicated is the repairable problem: the buckets serve, but on
|
||||
// fewer nodes than the cluster has.
|
||||
exitUnderReplicated = 1
|
||||
// exitUnreachable is the problem repair cannot touch — a bucket that cannot
|
||||
// accept a write, or a report that could not be produced at all.
|
||||
exitUnreachable = 2
|
||||
)
|
||||
|
||||
// replicaReportExit is the exit status for a set of reports.
|
||||
//
|
||||
// No quorum outranks under-replicated because they need different responses: one
|
||||
// is fixed by raising a count, the other by finding out why a node is not
|
||||
// answering. A bucket that is both should send the operator to the node.
|
||||
func replicaReportExit(reports []kvutil.BucketReport) int {
|
||||
status := exitHealthy
|
||||
for _, r := range reports {
|
||||
if !r.HasQuorum() {
|
||||
return exitUnreachable
|
||||
}
|
||||
if r.UnderReplicated() {
|
||||
status = exitUnderReplicated
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// repairReplicaReports raises every under-replicated bucket and updates reports
|
||||
// in place for the ones that were raised.
|
||||
//
|
||||
// One bucket that cannot be placed must not stop the others being repaired, so a
|
||||
// failure is named on errOut and the sweep continues. The bucket it failed on
|
||||
// stays UNDER-REPLICATED in the report, which is already what makes the exit
|
||||
// status non-zero, so nothing is lost by not returning an error here.
|
||||
func repairReplicaReports(ctx context.Context, js jetstream.JetStream, reports []kvutil.BucketReport, errOut io.Writer) {
|
||||
for i, r := range reports {
|
||||
if !r.UnderReplicated() {
|
||||
continue
|
||||
}
|
||||
if err := kvutil.RaiseBucketReplicas(ctx, js, r.Bucket, r.Want); err != nil {
|
||||
fmt.Fprintf(errOut, "repair %s: %v\n", r.Bucket, err)
|
||||
continue
|
||||
}
|
||||
reports[i].Replicas = r.Want
|
||||
}
|
||||
}
|
||||
|
||||
// underReplicatedCount is how many buckets are below the cluster's replica count.
|
||||
func underReplicatedCount(reports []kvutil.BucketReport) int {
|
||||
under := 0
|
||||
for _, r := range reports {
|
||||
if r.UnderReplicated() {
|
||||
under++
|
||||
}
|
||||
}
|
||||
return under
|
||||
}
|
||||
|
||||
// noQuorumCount is how many buckets cannot currently accept a write.
|
||||
func noQuorumCount(reports []kvutil.BucketReport) int {
|
||||
stuck := 0
|
||||
for _, r := range reports {
|
||||
if !r.HasQuorum() {
|
||||
stuck++
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
// replicaStatus is the one word for a bucket's condition.
|
||||
//
|
||||
// No quorum wins over under-replicated when both are true, matching the exit
|
||||
// status: a bucket that cannot serve is the more urgent of the two and is not
|
||||
// what --repair is for.
|
||||
func replicaStatus(r kvutil.BucketReport) string {
|
||||
switch {
|
||||
case !r.HasQuorum():
|
||||
return "NO-QUORUM"
|
||||
case r.UnderReplicated():
|
||||
return "UNDER-REPLICATED"
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
// writeReplicaReport renders reports as a table, or as JSON when asJSON.
|
||||
func writeReplicaReport(out io.Writer, reports []kvutil.BucketReport, asJSON bool) error {
|
||||
if asJSON {
|
||||
return json.NewEncoder(out).Encode(reports)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "BUCKET\tREPLICAS\tWANT\tONLINE\tSTATUS\tLEADER\tHELD BY")
|
||||
for _, r := range reports {
|
||||
fmt.Fprintf(w, "%s\t%d\t%d\t%d\t%s\t%s\t%s\n",
|
||||
r.Bucket, r.Replicas, r.Want, r.Online, replicaStatus(r), r.Leader, strings.Join(r.Peers, ","))
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(out, "\n%d buckets, %d under-replicated, %d without quorum\n",
|
||||
len(reports), underReplicatedCount(reports), noQuorumCount(reports))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//test:in-package — it tests this command's unexported rendering and repair
|
||||
// helpers, which exist only to be exercised without a live cluster.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/clustersize"
|
||||
"github.com/mulgadc/spinifex/spinifex/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWriteReplicaReport_NamesEveryUnderReplicatedBucket(t *testing.T) {
|
||||
reports := []kvutil.BucketReport{
|
||||
{Bucket: "healthy", Replicas: 3, Want: 3, Online: 3, Leader: "a", Peers: []string{"a", "b", "c"}},
|
||||
{Bucket: "short", Replicas: 1, Want: 3, Online: 1, Leader: "a", Peers: []string{"a"}},
|
||||
{Bucket: "stuck", Replicas: 3, Want: 3, Online: 1, Peers: []string{"a", "b", "c"}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, writeReplicaReport(&out, reports, false))
|
||||
got := out.String()
|
||||
|
||||
assert.Contains(t, got, "BUCKET")
|
||||
assert.Contains(t, got, "HELD BY")
|
||||
assert.Regexp(t, `healthy\s+3\s+3\s+3\s+ok\s+a\s+a,b,c`, got)
|
||||
assert.Regexp(t, `short\s+1\s+3\s+1\s+UNDER-REPLICATED\s+a\s+a`, got)
|
||||
// A bucket that cannot serve reads as NO-QUORUM even though its configured
|
||||
// count is right, because raising a count is not the response to it.
|
||||
assert.Regexp(t, `stuck\s+3\s+3\s+1\s+NO-QUORUM`, got)
|
||||
assert.Contains(t, got, "3 buckets, 1 under-replicated, 1 without quorum")
|
||||
}
|
||||
|
||||
// TestReplicaReportExit pins the three exit classes a deployment gate reads. They
|
||||
// are separate because the responses are: a raise fixes an under-replicated
|
||||
// bucket, and nothing the command can do fixes one that will not answer.
|
||||
func TestReplicaReportExit(t *testing.T) {
|
||||
healthy := kvutil.BucketReport{Bucket: "a", Replicas: 3, Want: 3, Online: 3}
|
||||
short := kvutil.BucketReport{Bucket: "b", Replicas: 1, Want: 3, Online: 1}
|
||||
stuck := kvutil.BucketReport{Bucket: "c", Replicas: 3, Want: 3, Online: 1}
|
||||
|
||||
assert.Equal(t, exitHealthy, replicaReportExit(nil))
|
||||
assert.Equal(t, exitHealthy, replicaReportExit([]kvutil.BucketReport{healthy}))
|
||||
assert.Equal(t, exitUnderReplicated, replicaReportExit([]kvutil.BucketReport{healthy, short}))
|
||||
assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{healthy, stuck}))
|
||||
// Both problems at once sends the operator to the node rather than to --repair.
|
||||
assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{short, stuck}))
|
||||
}
|
||||
|
||||
// TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes keeps the machine-readable
|
||||
// form honest: the e2e suite and anything gating a deployment parse these field
|
||||
// names, so a rename here breaks them silently.
|
||||
func TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
require.NoError(t, writeReplicaReport(&out, []kvutil.BucketReport{
|
||||
{Bucket: "short", Replicas: 1, Want: 3, Cluster: "spinifex", Online: 1, Leader: "a", Peers: []string{"a"}},
|
||||
}, true))
|
||||
|
||||
assert.Contains(t, out.String(), `"bucket":"short"`)
|
||||
|
||||
var decoded []kvutil.BucketReport
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
|
||||
require.Len(t, decoded, 1)
|
||||
assert.Equal(t, "short", decoded[0].Bucket)
|
||||
assert.Equal(t, 1, decoded[0].Replicas)
|
||||
assert.Equal(t, 3, decoded[0].Want)
|
||||
assert.Equal(t, 1, decoded[0].Online)
|
||||
assert.Equal(t, "a", decoded[0].Leader)
|
||||
assert.True(t, decoded[0].UnderReplicated())
|
||||
assert.True(t, decoded[0].HasQuorum())
|
||||
}
|
||||
|
||||
func TestUnderReplicatedCount(t *testing.T) {
|
||||
assert.Equal(t, 0, underReplicatedCount(nil))
|
||||
assert.Equal(t, 2, underReplicatedCount([]kvutil.BucketReport{
|
||||
{Bucket: "a", Replicas: 1, Want: 3},
|
||||
{Bucket: "b", Replicas: 3, Want: 3},
|
||||
{Bucket: "c", Replicas: 2, Want: 3},
|
||||
}))
|
||||
}
|
||||
|
||||
// TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace is the behaviour
|
||||
// that makes --repair usable mid-growth: the buckets it can raise are raised,
|
||||
// and the one it cannot is named rather than aborting the run.
|
||||
func TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
for _, b := range []string{"repair-alpha", "repair-beta"} {
|
||||
_, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: b, History: 1})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// One embedded server cannot hold two replicas, so every raise fails — and
|
||||
// every bucket must still have been attempted and named.
|
||||
reports := []kvutil.BucketReport{
|
||||
{Bucket: "repair-alpha", Replicas: 1, Want: 2},
|
||||
{Bucket: "repair-beta", Replicas: 1, Want: 2},
|
||||
}
|
||||
var errOut bytes.Buffer
|
||||
repairReplicaReports(t.Context(), js, reports, &errOut)
|
||||
|
||||
assert.Contains(t, errOut.String(), "repair repair-alpha")
|
||||
assert.Contains(t, errOut.String(), "repair repair-beta")
|
||||
assert.Equal(t, 2, underReplicatedCount(reports), "a failed raise must not be reported as repaired")
|
||||
}
|
||||
|
||||
// TestRepairReplicaReports_RaisesWhatItCan covers the success side against a real
|
||||
// server, so the in-place report update is proved rather than assumed.
|
||||
func TestRepairReplicaReports_RaisesWhatItCan(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
clustersize.DeclareForTest(t, 1)
|
||||
|
||||
_, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "repair-ok", History: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Want 1 against a bucket already at 1: the raise is a no-op that must still
|
||||
// leave the report reading as healthy rather than as repaired-and-wrong.
|
||||
reports := []kvutil.BucketReport{{Bucket: "repair-ok", Replicas: 1, Want: 1}}
|
||||
var errOut bytes.Buffer
|
||||
repairReplicaReports(t.Context(), js, reports, &errOut)
|
||||
|
||||
assert.Empty(t, strings.TrimSpace(errOut.String()))
|
||||
assert.Equal(t, 0, underReplicatedCount(reports))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//test:in-package — validateNodeCount is an unexported guard on this command's
|
||||
// flags, and exporting it would widen the package's API for a test's convenience.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateNodeCount pins the supported cluster sizes. Two is rejected rather
|
||||
// than warned about: on two servers no layer of the stack has a majority to lose,
|
||||
// and the control plane's own state has no replica count that both survives a node
|
||||
// going away and still accepts writes.
|
||||
func TestValidateNodeCount(t *testing.T) {
|
||||
for _, nodes := range []int{1, 3, 4, 5, 9} {
|
||||
if err := validateNodeCount(nodes); err != nil {
|
||||
t.Errorf("validateNodeCount(%d) = %v, want nil", nodes, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, nodes := range []int{0, -1} {
|
||||
if err := validateNodeCount(nodes); err == nil {
|
||||
t.Errorf("validateNodeCount(%d) returned no error", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
err := validateNodeCount(2)
|
||||
if err == nil {
|
||||
t.Fatal("validateNodeCount(2) returned no error")
|
||||
}
|
||||
// The message has to say what to do instead, because the operator typed a
|
||||
// number and needs to know which one to type next.
|
||||
for _, want := range []string{"2", "1", "3"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %s", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,7 +854,7 @@ func runOchreWeightsStage(cmd *cobra.Command, _ []string) {
|
||||
ochreExit(1)
|
||||
return
|
||||
}
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js)
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
|
||||
|
||||
provider := ebsprovider.NewNATSProvider(nc, imageImportTimeout)
|
||||
@@ -930,7 +930,7 @@ func runOchreWeightsList(_ *cobra.Command, _ []string) {
|
||||
ochreExit(1)
|
||||
return
|
||||
}
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js)
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
|
||||
checkSnapshotLive := newWeightsSnapshotChecker(store, node.Predastore.Bucket)
|
||||
|
||||
@@ -967,7 +967,7 @@ func removeWeights(ctx context.Context, weightsStore *gateway_bedrock.WeightsSto
|
||||
func runOchreWeightsRemove(cmd *cobra.Command, _ []string) {
|
||||
modelID, _ := cmd.Flags().GetString("model-id")
|
||||
|
||||
appConfig, nc, err := loadConfigAndConnectFn()
|
||||
_, nc, err := loadConfigAndConnectFn()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
ochreExit(1)
|
||||
@@ -981,7 +981,7 @@ func runOchreWeightsRemove(cmd *cobra.Command, _ []string) {
|
||||
ochreExit(1)
|
||||
return
|
||||
}
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
|
||||
weightsStore := gateway_bedrock.NewWeightsStore(js)
|
||||
|
||||
entry, err := removeWeights(context.Background(), weightsStore, modelID)
|
||||
if err != nil {
|
||||
@@ -1014,7 +1014,7 @@ func resolveHFToken(ctx context.Context, cmd *cobra.Command, cfg *config.Cluster
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil)
|
||||
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil)
|
||||
token, ok, err := credStore.Resolve(ctx, utils.GlobalAccountID, vendorHuggingFace)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
@@ -1088,7 +1088,7 @@ func ochreCredentialsStore() (*gateway_bedrock.CredentialStore, func(), error) {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("jetstream context: %w", err)
|
||||
}
|
||||
return gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil), func() { nc.Close() }, nil
|
||||
return gateway_bedrock.NewCredentialStore(js, masterKey, nil), func() { nc.Close() }, nil
|
||||
}
|
||||
|
||||
func runOchreCredentialsSet(cmd *cobra.Command, _ []string) {
|
||||
@@ -1186,7 +1186,7 @@ func init() {
|
||||
// ochreAccessStore connects to the cluster and returns the grant store along
|
||||
// with a cleanup that closes the connection.
|
||||
func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) {
|
||||
cfg, nc, err := loadConfigAndConnectFn()
|
||||
_, nc, err := loadConfigAndConnectFn()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
@@ -1195,7 +1195,7 @@ func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("jetstream context: %w", err)
|
||||
}
|
||||
return gateway_bedrock.NewModelAccessStore(js, len(cfg.Nodes)), func() { nc.Close() }, nil
|
||||
return gateway_bedrock.NewModelAccessStore(js), func() { nc.Close() }, nil
|
||||
}
|
||||
|
||||
// ochreTargetModels resolves the --model-id / --all-models pair into the model
|
||||
|
||||
@@ -470,7 +470,7 @@ func TestResolveHFToken_FallsBackToStoredPlatformCredential(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(baseDir, "config"), 0750))
|
||||
require.NoError(t, handlers_iam.SaveMasterKey(filepath.Join(baseDir, "config", "master.key"), masterKey))
|
||||
|
||||
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, 1, nil)
|
||||
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil)
|
||||
require.NoError(t, credStore.PutCredential(context.Background(), utils.GlobalAccountID, vendorHuggingFace, "hf_stored_token"))
|
||||
|
||||
cmd := newOchreWeightsPullTestCmd(t, selfHostModelID, testHFRepo, "main", "", "")
|
||||
@@ -621,7 +621,7 @@ func TestRunOchreCredentialsSet_RoundTripsAndDefaultsToPlatformAccount(t *testin
|
||||
defer verifyConn.Close()
|
||||
verifyJS := testutil.NewJetStream(t, verifyConn)
|
||||
|
||||
credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, 1, nil)
|
||||
credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, nil)
|
||||
token, ok, err := credStore.Resolve(context.Background(), utils.GlobalAccountID, vendorHuggingFace)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -42,7 +42,7 @@ const (
|
||||
func newWeightsStoreForTest(t *testing.T) *gateway_bedrock.WeightsStore {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc), 1)
|
||||
return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc))
|
||||
}
|
||||
|
||||
// explodingObjectStore fails the test immediately if any of its methods is
|
||||
@@ -775,7 +775,7 @@ func TestRunOchreWeightsList_PrintsStagedModels(t *testing.T) {
|
||||
}
|
||||
stubConnect(t, cfg, nc, nil)
|
||||
|
||||
seedStore := gateway_bedrock.NewWeightsStore(js, 1)
|
||||
seedStore := gateway_bedrock.NewWeightsStore(js)
|
||||
require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001"))
|
||||
|
||||
out := captureStdout(t, func() { runOchreWeightsList(nil, nil) })
|
||||
@@ -822,7 +822,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T
|
||||
ns, nc, js := testutil.StartTestJetStream(t)
|
||||
stubConnect(t, fakeClusterConfig(), nc, nil)
|
||||
|
||||
seedStore := gateway_bedrock.NewWeightsStore(js, 1)
|
||||
seedStore := gateway_bedrock.NewWeightsStore(js)
|
||||
require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001"))
|
||||
|
||||
cmd := newOchreWeightsRemoveTestCmd(t, selfHostModelID)
|
||||
@@ -835,7 +835,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T
|
||||
verifyNC, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { verifyNC.Close() })
|
||||
verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC), 1)
|
||||
verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC))
|
||||
|
||||
_, ok, err := verifyStore.GetWeights(context.Background(), selfHostModelID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -2,6 +2,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -9,11 +11,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/northstar/pkg/backend"
|
||||
nsconfig "github.com/mulgadc/northstar/pkg/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
@@ -734,21 +738,45 @@ func TestSpinifexTomlTemplate_ExternalPoolStaticSource(t *testing.T) {
|
||||
assert.Contains(t, content, `gateway = "192.168.1.1"`)
|
||||
}
|
||||
|
||||
// Formation joiners must receive PoolBindBridge from the init node so the
|
||||
// Formation joiners must receive BindBridge from the init node so the
|
||||
// cluster-wide config reaches every chassis intact.
|
||||
func TestApplyNetworkConfig_PropagatesPoolBindBridge(t *testing.T) {
|
||||
settings := &admin.ConfigSettings{}
|
||||
nc := &formation.NetworkConfig{
|
||||
ExternalMode: "pool",
|
||||
PoolName: "wan",
|
||||
PoolSource: "dhcp",
|
||||
PoolBindBridge: "br-wan",
|
||||
PoolPrefixLen: 24,
|
||||
ExternalMode: "pool",
|
||||
Pools: []admin.PoolData{{
|
||||
Name: "wan", Source: "dhcp", BindBridge: "br-wan", PrefixLen: 24,
|
||||
}},
|
||||
}
|
||||
applyNetworkConfig(settings, nc)
|
||||
require.Len(t, settings.Pools, 1)
|
||||
assert.Equal(t, "dhcp", settings.Pools[0].Source)
|
||||
assert.Equal(t, "br-wan", settings.Pools[0].BindBridge)
|
||||
assert.Empty(t, settings.BridgeMode, "bridged modes stay auto-detected, only nat is pinned")
|
||||
}
|
||||
|
||||
// Routed NAT renders two pools, and the transit one carries a gateway-LRP range
|
||||
// that pool mode has no equivalent for. A joiner that received only the first,
|
||||
// or lost the range, would allocate VPC gateway addresses from nowhere.
|
||||
func TestApplyNetworkConfig_PropagatesEveryPool(t *testing.T) {
|
||||
settings := &admin.ConfigSettings{}
|
||||
nc := &formation.NetworkConfig{
|
||||
ExternalMode: "nat",
|
||||
Pools: []admin.PoolData{
|
||||
{Name: "nat-transit", Gateway: "100.127.0.1", PrefixLen: 24,
|
||||
GwLrpRangeStart: "100.127.0.16", GwLrpRangeEnd: "100.127.0.254"},
|
||||
{Name: "wan", Source: "static", Start: "192.168.1.150", End: "192.168.1.200",
|
||||
Gateway: "192.168.1.1", PrefixLen: 24},
|
||||
},
|
||||
}
|
||||
applyNetworkConfig(settings, nc)
|
||||
require.Len(t, settings.Pools, 2)
|
||||
assert.Equal(t, "nat", settings.BridgeMode,
|
||||
"a joiner that loses bridge_mode falls back to autodetection for a mode init pinned")
|
||||
assert.Equal(t, "nat-transit", settings.Pools[0].Name)
|
||||
assert.Equal(t, "100.127.0.16", settings.Pools[0].GwLrpRangeStart)
|
||||
assert.Equal(t, "100.127.0.254", settings.Pools[0].GwLrpRangeEnd)
|
||||
assert.Equal(t, "wan", settings.Pools[1].Name)
|
||||
}
|
||||
|
||||
func renderSingleNodePredastore(t *testing.T, settings admin.ConfigSettings) string {
|
||||
@@ -1288,6 +1316,214 @@ func TestAMIVolumeSizeGiB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChecksumFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
unset bool
|
||||
file string
|
||||
skipVerify bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "no checksum", unset: true, file: "img.raw"},
|
||||
{name: "checksum with file", checksum: "SHA256SUMS", file: "img.raw"},
|
||||
{name: "empty checksum value", checksum: "", file: "img.raw", wantErr: "--checksum requires a sums file path"},
|
||||
{name: "blank checksum value", checksum: " ", file: "img.raw", wantErr: "--checksum requires a sums file path"},
|
||||
{name: "checksum without file", checksum: "SHA256SUMS", wantErr: "--checksum requires --file"},
|
||||
{name: "checksum with skip-verify", checksum: "SHA256SUMS", file: "img.raw", skipVerify: true,
|
||||
wantErr: "mutually exclusive"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateChecksumFlags(tt.checksum, !tt.unset, tt.file, tt.skipVerify)
|
||||
if tt.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceDigestMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
localFile string
|
||||
checksum string
|
||||
skipVerify bool
|
||||
want string
|
||||
}{
|
||||
{name: "file with checksum", localFile: "img.raw", checksum: "SHA256SUMS", want: ebsmetadata.DigestOperator},
|
||||
{name: "catalog download verified", want: ebsmetadata.DigestCatalog},
|
||||
{name: "catalog download skip-verify", skipVerify: true, want: ebsmetadata.DigestUnverified},
|
||||
{name: "file without checksum", localFile: "img.raw", want: ebsmetadata.DigestUnverified},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, sourceDigestMode(tt.localFile, tt.checksum, tt.skipVerify))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSourceResolveDigest(t *testing.T) {
|
||||
imgBytes := []byte("import-source-fixture")
|
||||
sum := sha256.Sum256(imgBytes)
|
||||
imgHex := hex.EncodeToString(sum[:])
|
||||
dir := t.TempDir()
|
||||
img := filepath.Join(dir, "image.raw")
|
||||
require.NoError(t, os.WriteFile(img, imgBytes, 0o600))
|
||||
sums := filepath.Join(dir, "SHA256SUMS")
|
||||
|
||||
t.Run("operator verified", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
got, err := importSource{
|
||||
imageFile: img, localFile: img,
|
||||
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: imgHex,
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ebsmetadata.ImageDigest{
|
||||
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestOperator,
|
||||
Source: "SHA256SUMS", Filename: "image.raw",
|
||||
}, got)
|
||||
assert.Contains(t, out.String(), "Verified image checksum (sha256)")
|
||||
assert.Empty(t, errOut.String())
|
||||
})
|
||||
|
||||
t.Run("operator mismatch names the file and sums file", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
_, err := importSource{
|
||||
imageFile: img, localFile: img,
|
||||
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64),
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
|
||||
assert.Contains(t, errOut.String(), "file: "+img)
|
||||
assert.Contains(t, errOut.String(), "checksum: "+sums)
|
||||
assert.Contains(t, errOut.String(), imgHex)
|
||||
assert.FileExists(t, img, "the operator's file must be left in place")
|
||||
})
|
||||
|
||||
t.Run("local file without checksum is recorded unverified", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
got, err := importSource{imageFile: img, localFile: img}.resolveDigest(&out, &errOut)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ebsmetadata.ImageDigest{
|
||||
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestUnverified, Filename: "image.raw",
|
||||
}, got)
|
||||
assert.Equal(t, "Importing "+img+" without checksum validation (pass --checksum <sums-file> to verify)\n",
|
||||
errOut.String())
|
||||
})
|
||||
|
||||
t.Run("catalog skip-verify is recorded unverified", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
got, err := importSource{imageFile: img, imageName: "debian-13-x86_64", skipVerify: true}.resolveDigest(&out, &errOut)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ebsmetadata.DigestUnverified, got.Verification)
|
||||
assert.Equal(t, imgHex, got.Value)
|
||||
assert.Contains(t, errOut.String(), "--skip-verify set")
|
||||
})
|
||||
|
||||
t.Run("catalog entry without checksum is refused", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
_, err := importSource{imageFile: img, imageName: "debian-13-x86_64"}.resolveDigest(&out, &errOut)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, errOut.String(), "missing Checksum/ChecksumDigest/ChecksumType")
|
||||
})
|
||||
|
||||
// Oracle publishes its digests inline on a web page and ships no sums file,
|
||||
// so a pinned digest is the only way its entries can import verified.
|
||||
t.Run("catalog pinned digest verifies without fetching a sums file", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
got, err := importSource{
|
||||
imageFile: img, imageName: "oracle-10.1-x86_64",
|
||||
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: imgHex},
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ebsmetadata.ImageDigest{
|
||||
Algorithm: "sha256", Value: imgHex, Source: catalogPinnedDigestSource,
|
||||
Verification: ebsmetadata.DigestCatalog, Filename: "image.raw",
|
||||
}, got)
|
||||
assert.Contains(t, out.String(), "pinned catalog digest")
|
||||
})
|
||||
|
||||
t.Run("catalog pinned digest mismatch is refused and names the url", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
wrong := strings.Repeat("a", 64)
|
||||
_, err := importSource{
|
||||
imageFile: img, imageName: "oracle-10.1-x86_64",
|
||||
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"},
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
|
||||
assert.Contains(t, errOut.String(), catalogPinnedDigestSource)
|
||||
assert.Contains(t, errOut.String(), "https://example.invalid/ol.qcow2")
|
||||
assert.FileExists(t, img, "the downloaded file must be left in place")
|
||||
})
|
||||
|
||||
t.Run("unreadable image fails the unverified hash", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
missing := filepath.Join(dir, "missing.raw")
|
||||
_, err := importSource{imageFile: missing, localFile: missing}.resolveDigest(&out, &errOut)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, errOut.String(), "Could not hash image")
|
||||
})
|
||||
}
|
||||
|
||||
func TestImagesDescribeCmd_FlagSchema(t *testing.T) {
|
||||
imageIDFlag := imagesDescribeCmd.Flags().Lookup("image-id")
|
||||
require.NotNil(t, imageIDFlag, "--image-id must be defined")
|
||||
assert.Equal(t, []string{"true"}, imageIDFlag.Annotations[cobraRequiredAnnotation],
|
||||
"--image-id must be marked required")
|
||||
}
|
||||
|
||||
func TestPrintAMIDescription(t *testing.T) {
|
||||
base := ebsmetadata.AMI{
|
||||
ImageID: "ami-1",
|
||||
Name: "ami-debian-13-x86_64",
|
||||
ImageOwnerAlias: "system",
|
||||
CreationDate: time.Date(2026, 9, 23, 1, 2, 3, 0, time.UTC),
|
||||
BootMode: "uefi",
|
||||
Architecture: "x86_64",
|
||||
}
|
||||
|
||||
t.Run("digest recorded", func(t *testing.T) {
|
||||
meta := base
|
||||
meta.SourceDigest = &ebsmetadata.ImageDigest{
|
||||
Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestOperator,
|
||||
Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
printAMIDescription(&buf, meta)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "Image ID: ami-1")
|
||||
assert.Contains(t, out, "Created: 2026-09-23T01:02:03Z")
|
||||
assert.Contains(t, out, "Boot mode: uefi")
|
||||
assert.Contains(t, out, "Source digest: sha512:abc123")
|
||||
assert.Contains(t, out, "Verification: operator (SHA512SUMS)")
|
||||
assert.Contains(t, out, "Source file: debian-13-generic-amd64.tar.xz")
|
||||
})
|
||||
|
||||
t.Run("unverified digest has no source", func(t *testing.T) {
|
||||
meta := base
|
||||
meta.SourceDigest = &ebsmetadata.ImageDigest{
|
||||
Algorithm: "sha256", Value: "def456", Verification: ebsmetadata.DigestUnverified,
|
||||
Filename: "image.raw",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
printAMIDescription(&buf, meta)
|
||||
assert.Contains(t, buf.String(), "Verification: unverified\n")
|
||||
})
|
||||
|
||||
t.Run("digest not recorded", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
printAMIDescription(&buf, base)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, "Source digest: not recorded (imported before digest recording)")
|
||||
assert.NotContains(t, out, "Verification:")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckJoinPreconditions(t *testing.T) {
|
||||
withFakeProc(t, nil)
|
||||
noStore := filepath.Join(t.TempDir(), "nats", "jetstream")
|
||||
|
||||
@@ -167,7 +167,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Timeout per phase: %s\n\n", timeout)
|
||||
|
||||
// Write cluster shutdown marker to KV
|
||||
jsm, err := daemon.NewJetStreamManager(nc, 1)
|
||||
jsm, err := daemon.NewJetStreamManager(nc)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to create JetStream manager: %v\n", err)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//test:in-package — the embedded nats.conf template it checks is unexported,
|
||||
// and exporting it would widen the package's API for a test's convenience.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
natssvc "github.com/mulgadc/spinifex/spinifex/services/nats"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNATSConfServerNameMatchesTheHelper ties the rendered server_name to
|
||||
// nats.ServerName. JetStream reports stream peers by server name, so anything
|
||||
// mapping a replica back to the node holding it reads the prefix from that
|
||||
// helper — and a template that drifted from it would break the mapping silently,
|
||||
// reporting a healthy cluster as one whose buckets name no node at all.
|
||||
func TestNATSConfServerNameMatchesTheHelper(t *testing.T) {
|
||||
var line string
|
||||
for l := range strings.SplitSeq(natsConfTemplate, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "server_name:") {
|
||||
line = strings.TrimSpace(l)
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, line, "nats.conf no longer sets server_name")
|
||||
|
||||
got := strings.TrimSpace(strings.TrimPrefix(line, "server_name:"))
|
||||
assert.Equal(t, natssvc.ServerName("{{.Node}}"), got)
|
||||
|
||||
node, ok := natssvc.NodeFromServerName(natssvc.ServerName("node2"))
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "node2", node)
|
||||
|
||||
_, ok = natssvc.NodeFromServerName("node2")
|
||||
assert.False(t, ok, "a bare node name is not a server name")
|
||||
|
||||
// The bare prefix is a name we could not have generated, and mapping it to the
|
||||
// empty node would put a phantom peer into anything counting nodes.
|
||||
_, ok = natssvc.NodeFromServerName(natssvc.ServerNamePrefix)
|
||||
assert.False(t, ok, "the prefix with no node after it is not a server name")
|
||||
}
|
||||
@@ -1025,12 +1025,15 @@ var vpcdStartCmd = &cobra.Command{
|
||||
AZ: nodeConfig.AZ,
|
||||
NorthstarBaseDomain: handlers_dns.ResolveBaseDomain(&nodeConfig),
|
||||
NorthstarInternalDomain: handlers_dns.ResolveInternalDomain(&nodeConfig),
|
||||
ServicesDomain: clusterConfig.AWS.ServicesDomain,
|
||||
ResolverNameservers: handlers_dns.ResolverNameserverIPs(clusterConfig),
|
||||
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
|
||||
IPSecEnabled: clusterConfig.Network.IPSecEnabled,
|
||||
UnderlayMTU: clusterConfig.Network.UnderlayMTU,
|
||||
BlockedWANPorts: clusterConfig.Network.ResolvedBlockedWANPorts(),
|
||||
EgressBlockExemptVPCs: clusterConfig.Network.EgressBlockExemptVPCs,
|
||||
IMDSHostMetaIP: clusterConfig.Network.IMDSHostMetaIP,
|
||||
IMDSHostDNSIP: clusterConfig.Network.IMDSHostDNSIP,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create vpcd service: %w", err)
|
||||
|
||||
@@ -10,7 +10,7 @@ email = "{{.OperatorEmail}}"
|
||||
|
||||
[aws]
|
||||
region = "{{.Region}}"
|
||||
internal_suffix = "spinifex.internal"
|
||||
services_domain = "services.internal"
|
||||
|
||||
[network]
|
||||
ipsec_enabled = {{.IPSecEnabled}}
|
||||
|
||||
+2
-1
@@ -166,8 +166,9 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
|
||||
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
|
||||
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
|
||||
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
|
||||
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
|
||||
|
||||
|
||||
+18
-24
@@ -289,31 +289,25 @@ Configs are generated by `spx admin init` (leader) or `spx admin join` (follower
|
||||
|
||||
```
|
||||
spinifex/
|
||||
├── cmd/
|
||||
│ ├── spinifex/ # spx CLI: service entry points and `spx admin`
|
||||
│ ├── installer/ # Platform installer
|
||||
│ └── ... # In-guest agents (lb, ecs, rds, eks helpers) and dev tools
|
||||
├── spinifex/
|
||||
│ ├── services/ # Long-running services (awsgw, spinifex, viperblockd,
|
||||
│ │ # predastore, nats, spinifexui)
|
||||
│ ├── gateway/ # AWS API surface
|
||||
│ │ ├── gateway.go # chi router, SigV4 auth, throttling
|
||||
│ │ ├── auth.go # SigV4 verification + IAM lookup
|
||||
│ │ ├── ec2.go # EC2 action dispatcher
|
||||
│ │ ├── ec2/ # Per-resource handlers (instance, volume, vpc, ...)
|
||||
│ │ ├── elbv2/, iam/, policy/
|
||||
│ ├── handlers/ # NATS-side service implementations (ec2, elbv2, iam, ...)
|
||||
│ ├── daemon/
|
||||
│ │ ├── daemon.go # Daemon entry, NATS subscription table
|
||||
│ │ └── daemon_handlers_*.go # One file per resource family
|
||||
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
|
||||
│ ├── config/ # Configuration structures
|
||||
│ ├── instancetypes/ # t3.*, m5.*, sys.* definitions
|
||||
│ ├── lbagent/ # In-VM agent for ELB target health
|
||||
│ ├── vm/ # VM instance representation
|
||||
│ ├── vpcd/ # VPC daemon (OVN/OVS network programming)
|
||||
│ └── qmp/ # QEMU Machine Protocol client
|
||||
└── cmd/
|
||||
├── spinifex/ # spx CLI
|
||||
├── lb-agent/ # ELB target agent binary
|
||||
├── installer/ # Platform installer
|
||||
└── ... # ECS agent, ECR credential provider, EKS helper binaries
|
||||
│ ├── services/ # Long-running services (awsgw, spinifex, nats, predastore, ...)
|
||||
│ ├── gateway/ # AWS API surface: routing, SigV4 auth, per-service dispatch
|
||||
│ ├── handlers/ # NATS-side service implementations (ec2, iam, eks, rds, ...)
|
||||
│ ├── daemon/ # Per-node daemon and its NATS subscription table
|
||||
│ ├── vm/, qmp/ # VM lifecycle and the QEMU Machine Protocol client
|
||||
│ ├── vpcd/, network/ # VPC daemon and the layered OVN/OVS network stack
|
||||
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
|
||||
│ └── config/ # Configuration structures
|
||||
├── internal/ # In-guest SigV4 clients and the AWS Smithy models
|
||||
├── build/ # systemd units, node helper scripts, microVM build
|
||||
├── scripts/ # Install, dev-environment, image-build scripts
|
||||
└── tests/
|
||||
├── integration/ # Gateway router against embedded NATS JetStream
|
||||
└── e2e/ # Live-cluster suites (see docs/testing/e2e-coverage.md)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -4,6 +4,8 @@ This directory contains the Spinifex documentation. This is available in individ
|
||||
|
||||
The [AWS API coverage](coverage/README.md) pages are generated from Spinifex's dispatch tables and the pinned AWS API models, one page per service. Run `make aws-model-coverage` to regenerate them and print a summary. Only `pages.json` and the `intro.md` files under `coverage/` are hand-written; every `README.md` there is generated and will be overwritten.
|
||||
|
||||
The same run writes [`coverage/coverage.json`](coverage/coverage.json), a versioned machine-readable model-versus-dispatch inventory. It is generated and must not be edited. Its operation states are structural: `implemented` means a registered action has neither a stub nor an explicit refusal. It is not a behavioural compatibility claim; conformance evidence and capability profiles establish that separately.
|
||||
|
||||
A published page names the operations Spinifex serves, and nothing else. It carries no percentage, no unimplemented operation and no operation the platform will never offer: the same run writes those to [`coverage/internal-report.md`](coverage/internal-report.md), which no slug in the docs site's configuration points at, so it stays internal. A `notApplicable` declaration in `pages.json` therefore publishes nothing; it keeps a permanent absence out of the report's candidate work.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
title: "Host Firewall"
|
||||
seoTitle: "Host Firewall and Cluster Membership — Spinifex Docs"
|
||||
description: "Arm the Spinifex host firewall, narrow SSH to your management networks, and learn how the peer-scoped cluster plane tracks membership changes on its own."
|
||||
category: "Admin"
|
||||
tags:
|
||||
- security
|
||||
- firewall
|
||||
- nftables
|
||||
- hardening
|
||||
resources:
|
||||
- title: "Network Connections"
|
||||
url: "/docs/network-connections"
|
||||
- title: "Multi-Node Install"
|
||||
url: "/docs/install-multi-node"
|
||||
- title: "Setting Up Your Cluster"
|
||||
url: "/docs/setting-up-your-cluster"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
---
|
||||
|
||||
# Host Firewall
|
||||
|
||||
> Arm the host firewall, narrow SSH, and keep the cluster plane off the public internet.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What the policy allows](#what-the-policy-allows)
|
||||
- [Cluster membership](#cluster-membership)
|
||||
- [Is it on?](#is-it-on)
|
||||
- [Narrowing SSH access](#narrowing-ssh-access)
|
||||
- [Turning it off and on around cluster changes](#turning-it-off-and-on-around-cluster-changes)
|
||||
- [The files](#the-files)
|
||||
- [Checking it](#checking-it)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex ships an optional host firewall — an nftables input policy that defaults to drop and divides the node's ports into two groups:
|
||||
|
||||
| Group | Ports | Who can reach them |
|
||||
|---|---|---|
|
||||
| **Public** | SSH, 443, 3000 (console), 8443 (S3), 9999 (AWS gateway), 53 (DNS) | anyone |
|
||||
| **Internal** | OVN, NATS, formation, Geneve and the rest of the cluster plane | **cluster members only** |
|
||||
|
||||
The internal group is the point. Before this existed, OVN and NATS were reachable from the public internet on a WAN-facing node.
|
||||
|
||||
**"Cluster members" is not a list you maintain.** Each node works it out from the cluster it belongs to and rewrites its own rules whenever membership changes — you never edit the peer list by hand.
|
||||
|
||||
The policy governs inbound traffic only. Outbound is untouched, because the metadata reply path egresses under per-tap policy routing and a filter there would break it.
|
||||
|
||||
## Instructions
|
||||
|
||||
## What the Policy Allows
|
||||
|
||||
The public plane is open to any source, IPv4 only — an accept with no source scope would otherwise also serve on any global IPv6 address the node picks up from an upstream router advertisement:
|
||||
|
||||
| Ports | Serving |
|
||||
|---|---|
|
||||
| TCP 443 | Held for the TLS edge; no listener yet, so the rollout needs no firewall change |
|
||||
| TCP 3000 | Web console |
|
||||
| TCP 8443 | Predastore S3 gate |
|
||||
| TCP 9999 | AWS gateway — also how every guest agent (load balancer, EKS, ECS, RDS) reaches its control plane, since they speak SigV4 rather than NATS |
|
||||
| TCP and UDP 53 | Northstar authoritative DNS. Open on every node deliberately: a node serving no public zone still answers here, which is the accepted cost of not templating the policy per node |
|
||||
|
||||
SSH is public too, but scoped by a variable you own — see [Narrowing SSH access](#narrowing-ssh-access).
|
||||
|
||||
The cluster plane is peer-scoped. On a single-NIC node the planes collapse onto one address, which is why these rules match peer addresses rather than an interface or a CIDR:
|
||||
|
||||
| Ports | Serving |
|
||||
|---|---|
|
||||
| TCP 4222, 4248 | NATS client and cluster routes |
|
||||
| TCP 4432 | Formation, then the daemon cluster manager |
|
||||
| TCP and UDP 5300 | Internal DNS |
|
||||
| TCP 6641, 6642 | OVN northbound and southbound |
|
||||
| TCP 6643, 6644 | OVN database clustering |
|
||||
| TCP 8660 | Predastore's admin listener |
|
||||
| UDP 6660, 7660 | Object shards and metadata consensus |
|
||||
| UDP 6081, 500, 4500, and ESP | Geneve, IKE, NAT-T and IPsec |
|
||||
|
||||
Two of those are peer-scoped for reasons worth knowing. Outside a formation window, 4432 is the daemon cluster manager, whose routes report node topology, service inventory and running instances to anyone who asks. And 8660 is Predastore's unauthenticated `/healthz` and `/readyz`, which name the peers a gate cannot reach.
|
||||
|
||||
**The formation port needs nothing from you.** `spx admin init` opens 4432 to any source for the length of the formation window and closes it again afterwards, because a node dialling in to join is not a peer yet. It is one slot rather than a list, so it cannot be grown into a general-purpose hole. The handshake behind it is TLS 1.3 with a bearer token.
|
||||
|
||||
Beyond those groups the policy accepts what the platform cannot work without: established and related connections, loopback, the ICMP types PMTUD needs under a Geneve overlay, broadcast DHCP replies for the uplink and external-pool leases, and guest traffic to the metadata and VPC DNS endpoints on `169.254.169.254` and `169.254.169.253`. The guest rules are scoped to those two addresses and their ports rather than accepting everything arriving on a guest port, which would expose every wildcard-bound host service to any tenant.
|
||||
|
||||
## Cluster Membership
|
||||
|
||||
The daemon resolves who the node's peers are from the cluster it belongs to, writes them into `peers.nft`, and reloads the policy. A node that has not resolved membership yet is left reachable rather than cut off from a cluster it cannot name: the apply helper fails without touching the ruleset when the peer file is missing.
|
||||
|
||||
One consequence matters in practice. **A node only recognises the members of the cluster it currently belongs to.** During formation the nodes do not yet know each other, so internal traffic between them is dropped — which is why forming or expanding a cluster starts by turning the policy off.
|
||||
|
||||
## Is It On?
|
||||
|
||||
| How the node was installed | Firewall |
|
||||
|---|---|
|
||||
| From the ISO | **on** |
|
||||
| Binary installer (`curl \| bash`) or `setup.sh` | **off** |
|
||||
| `setup.sh --firewall=on` | **on** |
|
||||
|
||||
The binary installer defaults to off deliberately: it runs on servers that already have an operating system and services on them, and switching on a default-deny policy uninvited could cut off something Spinifex knows nothing about. The ISO is an appliance on hardware we define, so there the policy is the product.
|
||||
|
||||
**For production, turn it on** — either at install time:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on
|
||||
```
|
||||
|
||||
or afterwards, by setting it in `/etc/spinifex/spinifex.toml` and restarting the daemon:
|
||||
|
||||
```toml
|
||||
[network]
|
||||
firewall_enabled = true
|
||||
```
|
||||
|
||||
Before you do, check what else the machine is serving. Anything listening on a port outside the public group above stops accepting new connections.
|
||||
|
||||
## Narrowing SSH Access
|
||||
|
||||
SSH is accepted from **every source** by default. That is deliberate — arming the policy should not lock an operator out of the node they are arming it from — but it is the first thing to change on a production cluster.
|
||||
|
||||
The sources live in `/etc/spinifex/firewall/custom.nft`, which the installer creates once and never rewrites, so an edit here survives upgrades, re-installs and node resets:
|
||||
|
||||
```
|
||||
define trusted_ssh_peers = { 10.0.0.0/8, 192.168.1.0/24 }
|
||||
```
|
||||
|
||||
Check the change before applying it, and check the policy file rather than this one — on its own `custom.nft` parses as a bare define and proves nothing, because the rule that uses the variable lives elsewhere:
|
||||
|
||||
```bash
|
||||
sudo nft -c -f /etc/spinifex/firewall/spinifex.nft
|
||||
sudo nft -f /etc/spinifex/firewall/spinifex.nft
|
||||
```
|
||||
|
||||
Never leave the variable empty. An undefined or empty set fails the whole ruleset, not just its own rule.
|
||||
|
||||
The ports come from sshd's own configuration, re-read on every `setup.sh` run, so moving sshd to a non-standard port is picked up by an upgrade rather than needing an edit here. IPv4 only: sshd reached over IPv6 is not matched by this rule.
|
||||
|
||||
## Turning It Off and On Around Cluster Changes
|
||||
|
||||
Turn the firewall off while you form or expand a cluster, and on again once it is up:
|
||||
|
||||
```bash
|
||||
# Off — before forming or expanding a cluster. Run on every node.
|
||||
sudo /usr/local/lib/spinifex/spinifex-firewall-apply disable
|
||||
|
||||
# On — once the cluster is formed and verified. Run on every node.
|
||||
sudo systemctl restart spinifex-daemon
|
||||
```
|
||||
|
||||
Restarting the daemon is what re-arms it: the node rebuilds its peer list from the cluster it is now part of, reloads the rules, and re-enables the boot-time unit so the policy survives a reboot. It also happens on its own within five minutes if you would rather wait.
|
||||
|
||||
`disable` removes Spinifex's own table and the peer file and leaves every other nftables table alone. It is idempotent, and it is the recovery path — it runs even on a node with no policy installed, so it cannot leave you with a node you can neither arm nor disarm.
|
||||
|
||||
> [!WARNING]
|
||||
> Do not flush the whole nftables ruleset to clear the policy. The VPC daemon writes SNAT and per-elastic-IP forward rules into the `ip nat` and `ip filter` tables and reinstalls them only when it starts, so `nft flush ruleset` breaks elastic IPs silently until the next `systemctl restart spinifex-vpcd`. Use `disable`, which only ever touches `inet spinifex_filter`.
|
||||
|
||||
## The Files
|
||||
|
||||
Everything lives under `/etc/spinifex/firewall/`. Which files are safe to edit follows from who writes them:
|
||||
|
||||
| File | Written by | Survives an upgrade |
|
||||
|---|---|---|
|
||||
| `spinifex.nft` | `setup.sh` — the policy itself | No. Edits are overwritten |
|
||||
| `local.nft` | `setup.sh` — the sshd ports it detected | No. Refreshed on every run |
|
||||
| `peers.nft` | The daemon — cluster and encap peer addresses | No. Rewritten on every membership change |
|
||||
| `open-ports.nft` | The apply helper — the formation window only | No. Reset closed on install and upgrade |
|
||||
| `custom.nft` | **You** — `trusted_ssh_peers` and anything else you add | **Yes.** Created once, never rewritten |
|
||||
| `mode` | `setup.sh` — whether the install path armed the policy | No |
|
||||
|
||||
The daemon cannot write `/etc` itself, and a blanket `nft` grant would be root-equivalent, so changes go through `/usr/local/lib/spinifex/spinifex-firewall-apply` — a root helper that accepts a fixed set of verbs and re-validates every address handed to it.
|
||||
|
||||
## Checking It
|
||||
|
||||
```bash
|
||||
sudo nft list table inet spinifex_filter
|
||||
```
|
||||
|
||||
The peer list is an nft variable, expanded when the rules load, so it does not appear under a name of its own. Look instead at the `ip saddr { ... }` addresses on the cluster-plane rules — the ones accepting 4222, 6641, 6642 and the rest.
|
||||
|
||||
Every node's addresses should be there. On a multi-NIC node that means its WAN, LAN and VPC addresses, so expect several entries per node. A missing node means its cluster traffic is being dropped.
|
||||
|
||||
Dropped packets are logged, rate-limited to five a minute, so this tells you whether a connection problem is the firewall or something else:
|
||||
|
||||
```bash
|
||||
sudo journalctl -k | grep 'spinifex-fw drop'
|
||||
```
|
||||
|
||||
An empty log with a connection still failing means the policy is not the cause. A node with no table loaded at all is not armed:
|
||||
|
||||
```bash
|
||||
systemctl is-enabled spinifex-firewall.service
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### A Join Hangs Instead of Failing
|
||||
|
||||
A hang rather than a quick refusal means packets are being dropped. Check the drop log on the node running init, and confirm the formation port opened — `spx admin init` prints `⚠️ Could not open port 4432 in the host firewall` when it could not:
|
||||
|
||||
```bash
|
||||
sudo journalctl -k | grep 'spinifex-fw drop'
|
||||
```
|
||||
|
||||
On ISO-installed nodes the usual cause is that each node's policy still trusts only itself, because that is the whole cluster as far as it knows. Turn the firewall off on **every** node, retry, and re-arm once the cluster is verified.
|
||||
|
||||
### A Node Is Missing from the Peer List
|
||||
|
||||
The daemon rewrites `peers.nft` from the cluster it belongs to, so a missing node means that node is not in the membership this one resolved. Confirm what the cluster thinks first, then restart the daemon to force a rebuild:
|
||||
|
||||
```bash
|
||||
spx get nodes
|
||||
sudo systemctl restart spinifex-daemon
|
||||
```
|
||||
|
||||
### One of My Own Services Stopped Answering
|
||||
|
||||
Arming the policy closes every port outside the public group. Add what you need to `custom.nft`, which is the only file an upgrade leaves alone, and check the policy before applying it:
|
||||
|
||||
```bash
|
||||
sudo nft -c -f /etc/spinifex/firewall/spinifex.nft
|
||||
```
|
||||
|
||||
### Elastic IPs Stopped Working
|
||||
|
||||
Something flushed the whole nftables ruleset, taking the VPC daemon's SNAT and forward rules with it. They are reinstalled on start:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart spinifex-vpcd
|
||||
```
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "Predastore Distributed Storage"
|
||||
seoTitle: "How Predastore Distributed Storage Works — Spinifex Docs"
|
||||
description: "How Spinifex stores objects across a cluster: gate, blob and meta nodes, Reed-Solomon erasure coding, placement, degraded writes, repair and encryption at rest."
|
||||
category: "Admin"
|
||||
tags:
|
||||
- storage
|
||||
- s3
|
||||
- predastore
|
||||
- erasure coding
|
||||
- durability
|
||||
resources:
|
||||
- title: "Predastore Repository"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
- title: "Multi-Node Install"
|
||||
url: "/docs/install-multi-node"
|
||||
- title: "Network Connections"
|
||||
url: "/docs/network-connections"
|
||||
---
|
||||
|
||||
# Predastore Distributed Storage
|
||||
|
||||
> How Spinifex stores objects across a cluster, and what a server failure costs.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Hosts and nodes](#hosts-and-nodes)
|
||||
- [The configuration file](#the-configuration-file)
|
||||
- [Erasure coding and durability](#erasure-coding-and-durability)
|
||||
- [Placement](#placement)
|
||||
- [The write path](#the-write-path)
|
||||
- [The read path](#the-read-path)
|
||||
- [Repair](#repair)
|
||||
- [Encryption at rest](#encryption-at-rest)
|
||||
- [What a server failure costs](#what-a-server-failure-costs)
|
||||
- [Checking it](#checking-it)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Predastore is the S3-compatible object store underneath a Spinifex cluster, and almost everything durable ends up in it:
|
||||
|
||||
- **Machine images** — the AMIs instances launch from
|
||||
- **Block storage** — EBS volume data and snapshots, written through [Viperblock](https://github.com/mulgadc/viperblock), which uses Predastore as its S3 backend
|
||||
- **User data** — cloud-init configuration and system artifacts
|
||||
- **DNS zone files** — the zones Northstar serves
|
||||
- **Anything your own workloads put in a bucket** through the S3 API
|
||||
|
||||
This is background reading. You do not configure any of it by hand: `spx admin init` and `spx admin join` build the topology from the servers that actually form the cluster, and every machine gets the same file with its own host ID recorded in `spinifex.toml`.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Hosts and Nodes
|
||||
|
||||
A cluster is two levels.
|
||||
|
||||
A **host** is one machine: one Predastore process owning that machine's data directory, TLS identity and encryption key. A **node** is a role pinned to a host, running inside that process on its own port. Every server in a Spinifex cluster is one host carrying three nodes:
|
||||
|
||||
| Role | Port | Purpose |
|
||||
|---|---|---|
|
||||
| `gate` | TCP 8443 | Serves the S3 API — SigV4, IAM, erasure coding, placement. Every server runs one, so any of them answers an S3 request. |
|
||||
| `blob` | UDP 6660 | Holds erasure-coded object shards. One per machine. |
|
||||
| `meta` | UDP 7660 | Member of the Raft quorum over global state — buckets and the object index. |
|
||||
|
||||
Ports have to be unique within a host but not across the cluster, so every machine uses the same three. That is why the port surface does not widen as the cluster grows — see [Network requirements](/docs/install-multi-node#network-requirements).
|
||||
|
||||
Which transport a pair of nodes uses follows from where they sit, not from a mode flag. Nodes on the same machine talk over an in-process pipe and open no socket at all; nodes on different machines talk over QUIC, TLS 1.3, authenticated by the cluster CA. A single-server install is therefore one process listening on 8443 and nothing else.
|
||||
|
||||
Nothing dials a gate, so a gate binds no cluster-plane listener. Its port is the S3 port and nothing more.
|
||||
|
||||
## The Configuration File
|
||||
|
||||
The whole cluster's storage topology lives in `/etc/spinifex/predastore/predastore.toml`, identical on every server apart from which host the local process is told to be.
|
||||
|
||||
Each server is one `[[host]]` block carrying three `[[host.node]]` blocks:
|
||||
|
||||
```toml
|
||||
[[host]]
|
||||
id = 1
|
||||
bind_addr = "10.2.0.2" # cluster plane: raft and blob
|
||||
addr = "10.2.0.2" # what the other hosts dial
|
||||
data_dir = "/var/lib/spinifex/predastore"
|
||||
tls_cert = "/etc/spinifex/server.pem"
|
||||
tls_key = "/etc/spinifex/server.key"
|
||||
encryption_key = "/etc/spinifex/predastore/encryption.key"
|
||||
admin_port = 8660 # /healthz, /readyz, /repair
|
||||
|
||||
[[host.node]]
|
||||
id = 1
|
||||
role = "gate"
|
||||
port = 8443
|
||||
bind_addr = "0.0.0.0" # public plane; only a gate may set this
|
||||
```
|
||||
|
||||
The two bind addresses are the point. `bind_addr` on the host is the cluster plane — Raft consensus and shard traffic listen there and nowhere else, at the address peers dial rather than a wildcard, so on a multi-homed machine replication never reaches the public interface. The gate binds the public plane separately, because S3 is meant to be reachable.
|
||||
|
||||
## Erasure Coding and Durability
|
||||
|
||||
The erasure code is cluster configuration, not a per-request choice, and it is chosen at formation from the number of servers — each machine contributes exactly one blob node:
|
||||
|
||||
| Servers | Code | What it survives |
|
||||
|---|---|---|
|
||||
| 1 | `RS(1,0)` | Nothing at this layer. One shard on one node; redundancy is the pool's job — ZFS raidz or a mirror under the data directory. |
|
||||
| 2 | `RS(1,1)` | One server's shards, but the metadata quorum has no majority to lose. |
|
||||
| 3 or more | `RS(2,1)` | Any one server's shards, with the metadata quorum intact. |
|
||||
|
||||
An object is encoded end to end into `K` data and `M` parity shards with no intermediate chunking. Each shard is roughly `⌈size / K⌉` bytes, and any `K` of the `K+M` shards rebuild the object.
|
||||
|
||||
`data + parity` must not exceed the number of blob nodes, because a stripe is spread over distinct nodes and a narrower cluster would fail every write. `RS(2,1)` surviving the loss of any one server is the reason [three servers is the recommended minimum](/docs/install-multi-node).
|
||||
|
||||
## Placement
|
||||
|
||||
Which nodes hold an object's shards comes from a consistent hash ring over the blob nodes, keyed by the object hash. `Nodes(hash, K+M)` returns the placement in shard order — shard `i` goes to the node at position `i` — so every gate in the cluster derives the same placement from the same object without consulting anything.
|
||||
|
||||
The gate then **records** that placement in the metadata plane rather than trusting the ring at read time. A ring that has since changed shape does not lose objects placed under the old one.
|
||||
|
||||
## The Write Path
|
||||
|
||||
The gate authenticates the request, resolves the bucket, hashes the object name, places it on the ring, encodes the body into data and parity shards, streams each shard to its blob node in parallel, and records the placement.
|
||||
|
||||
Three properties matter operationally:
|
||||
|
||||
- **The write is two-phase.** Each shard put makes the bytes durable and invisible. The placement record landing in Raft is the commit point, and the shards are published only afterwards — so an interrupted writer costs a round trip rather than the object. Shards are always durable before metadata: a crash between the two leaves shards nothing points at, rather than metadata pointing at data that was never written.
|
||||
- **Every write mints an epoch**, stamped on every shard of the stripe and into the record. The record names one generation, and a shard either belongs to it or does not. That is what makes a half-completed overwrite a reconstruction rather than an object spliced from two generations.
|
||||
- **One server down does not refuse writes.** The write is acknowledged once `K` shards are durable, and the response carries `X-Spx-Degraded-Write: <n>`. `K` is the floor rather than `K+1` because any `K` of the `K+M` reconstruct — at `RS(2,1)` one more buys no redundancy and would refuse three quarters of writes with a single server down.
|
||||
|
||||
A shard its owner will not take is written to the next node along the ring instead of being given up on — **hinted handoff** — and the response carries `X-Spx-Handoff: <n>`. The stripe is then complete and the object is as redundant as a full-width write; what is outstanding is only that the shards are not all where the record says. The holder is derived rather than recorded, so there is no hint to store, replicate or lose. Handoff is skipped when the cluster has no node to spare, since a second shard of one object on a node that already holds one is not redundancy.
|
||||
|
||||
If any node reported storage pressure the response also carries `X-Predastore-Pool-Pressure: nearfull`; a node that refused outright surfaces as `507 InsufficientStorage`.
|
||||
|
||||
## The Read Path
|
||||
|
||||
The gate reads the recorded placement, fetches the data shards, and joins them. Parity is never touched on the happy path. Only if the join fails does it refetch including parity and reconstruct, and a read that had to reconstruct says so in `X-Spx-Degraded: <n>` — a cost report, not an error.
|
||||
|
||||
Every shard read names the record's epoch, and a node holding another generation answers `epoch-mismatch`. **The gate counts that exactly as a missing shard**, so the read either rebuilds the object the record names or fails. It never returns plausible nonsense.
|
||||
|
||||
A shard its owner cannot serve is looked for on the handoff node before it is counted as failed, since a write that could not reach the owner will have put it there.
|
||||
|
||||
A `Range` request that lands entirely inside one data shard takes a fast path — a single ranged shard read — because Reed-Solomon splits data sequentially. Anything wider reconstructs the object and slices it.
|
||||
|
||||
## Repair
|
||||
|
||||
Degraded writes trade redundancy for availability, and repair is what pays that back. It restores shards a blob node owns but does not hold at the generation its object's record names, and it runs on every server with a local blob node.
|
||||
|
||||
Two things are worth knowing about it:
|
||||
|
||||
- **It carries no correctness weight.** A read compares each shard's epoch against the record it has already loaded and discards a stale one whether or not repair has ever run. What repair restores is redundancy — the number of further losses an object survives — and never the object.
|
||||
- **It rebuilds onto the owner**, so it closes the gap only once the missing server returns. At `RS(2,1)` an object written while a server is down holds no parity for the duration of the outage. That is the accepted position: refusing writes meanwhile is the worse failure.
|
||||
|
||||
Every pass scans the placement records from the beginning, asks each holder which generation it has, and rebuilds the ones that disagree. A shard that was handed off is returned rather than recomputed — cheaper by a decode — and the holder's copy is released only after the owner's is published. If fewer than `K` peers hold the record's generation the rebuild fails and says so, rather than inventing a shard from a mixture of generations.
|
||||
|
||||
## Encryption at Rest
|
||||
|
||||
Every fragment is sealed independently under AES-256-GCM, with a deterministic nonce and additional data reconstructed at read time from the on-disk header plus per-data-directory state. There is no unencrypted code path: the process refuses to start without an encryption key.
|
||||
|
||||
| Property | What it means |
|
||||
|---|---|
|
||||
| **Confidentiality** | Disk-level access yields ciphertext and tags. Plaintext is never written. |
|
||||
| **Authenticated integrity** | GCM is the sole integrity authority; there is no separate checksum. Corruption, tampering and a wrong key all surface as one integrity error. |
|
||||
| **Position binding** | The additional data binds each fragment to its slot, so swapping fragments between objects fails the tag. |
|
||||
| **Cross-directory defence** | A per-data-directory store ID enters the nonce, so splicing a fragment from one data directory into another fails the tag. |
|
||||
|
||||
The key is one 32-byte AES-256 key per cluster, and **every server must be given the same file** — fragments sealed under one key cannot be opened under another. `spx admin init` generates it and `spx admin join` distributes it, which is also why joining a server that has already been in service discards its own key and orphans everything sealed under it. The loader is fail-closed on permissions: a group- or other-readable key file is rejected outright, with no override.
|
||||
|
||||
## What a Server Failure Costs
|
||||
|
||||
| What fails | Reads | Writes |
|
||||
|---|---|---|
|
||||
| One gate (of three or more) | Unaffected — any gate answers for the cluster | Unaffected |
|
||||
| One blob node, `RS(2,1)` | Served by reconstruction, reported in `X-Spx-Degraded` | Accepted at the `K` floor, reported in `X-Spx-Degraded-Write`, or placed complete via handoff |
|
||||
| More than `M` blob nodes | Objects placed on them are lost | Refused |
|
||||
| Metadata quorum | Serve whatever the surviving replicas last applied | Stopped entirely |
|
||||
|
||||
Reads try the cached Raft leader first, then every replica, and report not-found only once every replica has answered not-found — a replica that has not applied a key yet must not be mistaken for absence. Writes go through the leader, and a replica that cannot commit redirects the client to the one that can.
|
||||
|
||||
Replacing a server restores capacity, not the shards that were on the old one: repair rebuilds onto the owner it expects, so plan a replacement as a return of that node rather than as re-replication onto a new one.
|
||||
|
||||
## Checking It
|
||||
|
||||
Each host runs an unauthenticated admin listener on port 8660, bound to the cluster plane rather than the S3 address. It serves counters and readiness, never addresses or keys:
|
||||
|
||||
```bash
|
||||
curl -s http://$(hostname -I | awk '{print $1}'):8660/readyz
|
||||
curl -s http://$(hostname -I | awk '{print $1}'):8660/repair
|
||||
```
|
||||
|
||||
`/readyz` reports the most recent probe cycle for the nodes in that process, and answers `503` until one has completed — reporting ready on no information would send traffic to a process that has never answered the question. `/healthz` answers for the process itself: reaching the handler is the whole check.
|
||||
|
||||
That the gate is serving is an S3 request like any other:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws s3 ls
|
||||
```
|
||||
|
||||
To see whether a write or read was degraded, ask for the response headers:
|
||||
|
||||
```bash
|
||||
aws s3api put-object --bucket predastore --key probe --body /etc/hostname --debug 2>&1 | grep -i 'x-spx-\|x-predastore-'
|
||||
```
|
||||
|
||||
And `spx get nodes` should list `predastore` in `SERVICES` on every node — a short service list on one server means something failed to start there.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### S3 Answers but Every Write Fails
|
||||
|
||||
The gate is up and the metadata quorum is not. Check that a majority of servers are running `spinifex-predastore`:
|
||||
|
||||
```bash
|
||||
spx get nodes
|
||||
sudo systemctl is-active spinifex-predastore
|
||||
```
|
||||
|
||||
Reads keep serving what the surviving replicas last applied, which is why this presents as a write-only outage.
|
||||
|
||||
### 507 InsufficientStorage, or `nearfull` on Every Response
|
||||
|
||||
A blob node is out of space, or close enough that compaction has been kicked. Free space in the data directory on each server:
|
||||
|
||||
```bash
|
||||
df -h /var/lib/spinifex/predastore
|
||||
```
|
||||
|
||||
Capacity here is per server, not pooled: a stripe needs its shard on a specific node, so one full server refuses writes for the objects the ring places on it.
|
||||
|
||||
### Reads Fail with an Integrity Error
|
||||
|
||||
GCM is the only integrity authority, so corruption, tampering and a wrong encryption key are indistinguishable. Confirm the key first, since it is by far the most likely of the three — every server must hold the identical file:
|
||||
|
||||
```bash
|
||||
sudo sha256sum /etc/spinifex/predastore/encryption.key
|
||||
```
|
||||
|
||||
Compare the digest across servers. A mismatch means that server was initialized separately rather than joined, and the fragments it sealed cannot be opened by the cluster.
|
||||
|
||||
### Degraded Reads That Never Clear
|
||||
|
||||
`X-Spx-Degraded` on reads long after an outage means repair has not restored the shards. Repair rebuilds onto the node that owns the shard, so it cannot finish while that node is absent:
|
||||
|
||||
```bash
|
||||
curl -s http://$(hostname -I | awk '{print $1}'):8660/repair
|
||||
```
|
||||
|
||||
`"enabled": false` means the sweep is off on that host. Otherwise bring the missing server back and let a pass complete — the sweep runs every five minutes by default.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Setting Up Your Cluster"
|
||||
seoTitle: "Set Up Your First Spinifex Cluster — Spinifex Docs"
|
||||
description: "Import an AMI, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance and connect to it on a fresh Spinifex cluster."
|
||||
description: "Import an AMI and the RDS, ECS and EKS base images, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance on a fresh Spinifex cluster."
|
||||
category: "Admin"
|
||||
tags:
|
||||
- setup
|
||||
@@ -17,24 +17,26 @@ resources:
|
||||
|
||||
# Setting Up Your Cluster
|
||||
|
||||
> Import an AMI, create a VPC with a public subnet, and launch your first instance.
|
||||
> Import an AMI and the system base images, create a VPC with a public subnet, and launch your first instance.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Import an AMI](#1-import-an-ami)
|
||||
- [Option C: Import the system base images](#option-c-import-the-system-base-images)
|
||||
- [2. Create an SSH Key](#2-create-an-ssh-key)
|
||||
- [3. Create a VPC and Public Subnet](#3-create-a-vpc-and-public-subnet)
|
||||
- [4. Launch an Instance](#4-launch-an-instance)
|
||||
- [5. Connect via SSH](#5-connect-via-ssh)
|
||||
- [6. Managing Instances](#6-managing-instances)
|
||||
- [7. Launching the Web UI](#7-launching-the-web-ui)
|
||||
- [8. Secure the Install](#8-secure-the-install)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide walks through the first steps on a freshly installed cluster: importing an AMI, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it.
|
||||
This guide walks through the first steps on a freshly installed cluster: importing an AMI and the system base images RDS, ECS and EKS need, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it.
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -72,7 +74,7 @@ spx admin images import --name ubuntu-26.04-x86_64
|
||||
### Option B: Import a local image file
|
||||
|
||||
```bash
|
||||
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
|
||||
spx admin images import --file ~/images/ubuntu-26.04-server-cloudimg-amd64.img --checksum ~/images/SHA256SUMS --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
|
||||
```
|
||||
|
||||
Verify the import and note the AMI ID:
|
||||
@@ -81,6 +83,41 @@ Verify the import and note the AMI ID:
|
||||
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
|
||||
```
|
||||
|
||||
### Option C: Import the system base images
|
||||
|
||||
**RDS, ECS and EKS each launch from an image of their own, and none of them is imported for you.** Until these are in the catalog, `CreateDBInstance`, `CreateCluster` and `CreateNodegroup` all fail at the point they go looking for a node image — so a cluster without them has those three services present in the API and unusable in practice. Import them once, now, and the install is functional from the start.
|
||||
|
||||
Run these one at a time rather than in parallel, so predastore is not taking four concurrent uploads:
|
||||
|
||||
```bash
|
||||
for img in spinifex-rds-postgres spinifex-rds-mariadb spinifex-ecs-node spinifex-eks-node; do
|
||||
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml
|
||||
done
|
||||
```
|
||||
|
||||
| Image | Serves | Contents |
|
||||
| --- | --- | --- |
|
||||
| `spinifex-rds-postgres` | RDS, `--engine postgres` | Alpine 3.24.1 + PostgreSQL 18 + `rds-init` |
|
||||
| `spinifex-rds-mariadb` | RDS, `--engine mariadb` | Alpine 3.24.1 + MariaDB 11.8 + `rds-init` |
|
||||
| `spinifex-ecs-node` | ECS container instances | Alpine 3.21.7 + containerd + ecs-agent |
|
||||
| `spinifex-eks-node` | EKS control plane and nodegroups | Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook |
|
||||
|
||||
All four boot **BIOS**, unlike the distro images, which boot UEFI. That is a property of how they are built and nothing you need to pass.
|
||||
|
||||
If you run GPU workloads, add `spinifex-ecs-node-gpu` and `spinifex-eks-node-gpu` as well — Ubuntu 26.04 with the NVIDIA driver, selected automatically for a GPU instance type. They boot UEFI and they are large, so import them only if you have GPUs.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **These register under generic names, and that is correct.** `describe-images` shows `spinifex-eks-node` and `spinifex-ecs-node` both as `ami-alpine-3.21.7-x86_64`, and the two RDS images both as `ami-alpine-3.24.1-x86_64`. Nothing resolves them by name: ECS and EKS find theirs by the `spinifex:managed-by=ecs|eks` tag, and RDS by `spinifex:managed-by=rds` plus the `engine` and `engine-version` tags. **Verify the tags, not the names** — four rows with duplicate-looking names is what success looks like:
|
||||
>
|
||||
> ```bash
|
||||
> aws ec2 describe-images \
|
||||
> --filters 'Name=tag-key,Values=spinifex:managed-by' \
|
||||
> --query 'Images[].[ImageId,State,Tags[?Key==`spinifex:managed-by`].Value|[0],Tags[?Key==`engine`].Value|[0]]' \
|
||||
> --output text
|
||||
> ```
|
||||
|
||||
`spx admin images list --config /etc/spinifex/spinifex.toml` on a node shows the whole catalog, including the images not imported here.
|
||||
|
||||
## 2. Create an SSH Key
|
||||
|
||||
### Option A: Import an existing key
|
||||
@@ -340,6 +377,22 @@ At the Spinifex login screen, paste the **Access Key ID** and **Secret Access Ke
|
||||
|
||||
Once logged in, you have browser-based access to every Spinifex feature: launch and manage instances, attach EBS volumes, browse S3 buckets, configure VPCs and security groups, and manage IAM users and keys — all backed by the same AWS-compatible control plane the CLI uses.
|
||||
|
||||
## 8. Secure the Install
|
||||
|
||||
Your cluster works. Before it carries anything real, close the host down.
|
||||
|
||||
**Arm the host firewall.** A binary install leaves it off, because it lands on a machine that was already doing something. Armed, it keeps OVN, NATS and the rest of the cluster plane reachable by cluster members only, and everything outside the public group closed:
|
||||
|
||||
```toml
|
||||
# /etc/spinifex/spinifex.toml
|
||||
[network]
|
||||
firewall_enabled = true
|
||||
```
|
||||
|
||||
**Narrow SSH.** It is accepted from every source by default, so that arming the policy cannot lock you out of the node you are arming it from. Replace `0.0.0.0/0` with your management networks in `/etc/spinifex/firewall/custom.nft` — the one file an upgrade never overwrites.
|
||||
|
||||
[Host Firewall](/docs/host-firewall) covers both, what the policy allows, and how each node tracks cluster membership without a peer list you maintain.
|
||||
|
||||
## Additional Options
|
||||
|
||||
### Private Subnets (No Public IP)
|
||||
|
||||
@@ -109,7 +109,7 @@ spx admin images list
|
||||
spx admin images import --name debian-13-arm64
|
||||
```
|
||||
|
||||
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
|
||||
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` with `--checksum <sums-file>` to import operator-supplied media verified against the sums file it was published with, or `--force` to re-download after a checksum mismatch. A `--file` import without `--checksum` proceeds unverified and prints a notice. Every import records the source file's digest on the AMI; `spx admin images describe --image-id <ami>` shows it.
|
||||
|
||||
### EKS node image
|
||||
|
||||
|
||||
@@ -136,6 +136,21 @@ aws ec2 run-instances \
|
||||
--key-name spinifex-key
|
||||
```
|
||||
|
||||
### Multi-GPU Instances
|
||||
|
||||
The GPU count comes from the instance type, exactly as it does in EC2. AWS multi-GPU types pair eight GPUs with a very large host — `p4d.24xlarge` is 96 vCPU and 1152 GiB, — so an edge rig with eight cards and 256 GiB cannot admit them even though it has the GPUs. For those hosts Spinifex offers its own `gpu.*` family, which is not an EC2 family:
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $GPU_AMI \
|
||||
--instance-type gpu.8x4c \
|
||||
--key-name spinifex-key
|
||||
```
|
||||
|
||||
`gpu.<count>x<vcpu>c` is that many GPUs at that many vCPU each, so `gpu.8x4c` is eight GPUs on 32 vCPU and 128 GiB. Counts run in three tiers — `2c`, `4c` and `8c` — all at 4 GiB of memory per vCPU. The GPU model and VRAM reported in `DescribeInstanceTypes` are whatever the node discovered, so a `gpu.8x4c` on an 8×RTX 3090 box advertises eight RTX 3090s at 24 GiB each.
|
||||
|
||||
A node holding more than one distinct GPU model offers single-GPU types only, and logs each withheld type by name. Two devices count as the same model when their reported name and VRAM match, which keeps a mixed-SKU H100 node fully usable.
|
||||
|
||||
To verify the GPU is visible from inside the instance, SSH in and run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -161,6 +161,12 @@ Both support the same AWS features: public subnets, Elastic IPs, security groups
|
||||
|
||||
## `nat` — Shared SNAT (Simple)
|
||||
|
||||
**`nat` is a supported mode, not a fallback.** It is covered by the nightly e2e suite (cells 19 and 30, single- and multi-node) and it is the only mode a cloud-hosted node can run, so the OCI deployment depends on it. Read "specialised" below as "fewer deployments need it", never as "less finished".
|
||||
|
||||
**Start with `pool` unless one of the reasons below applies.** Pool mode is the default, is what most of this document describes, and is what the AWS feature set was built against. `nat` is the specialised mode: it exists for uplinks that cannot be bridged, and it reaches AWS parity through host-side plumbing rather than by putting VM addresses on the wire.
|
||||
|
||||
Reach for `nat` when the uplink refuses to carry anything but its own MAC and its own addresses — WiFi and WWAN, and **any cloud-hosted node**, where the VNIC drops a frame whose source address is not registered to it. On such a host `nat` is not a preference, it is the only mode that works at all; pool mode's localnet needs to announce VM addresses by ARP and a cloud VNIC will not carry them. See [oci-integration](../../oci-integration/README.md).
|
||||
|
||||
All VMs share a single external IP for outbound SNAT. By default there are no public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as private subnets with internet access. On routed-NAT nodes, adding a public pool restores full public IP parity (see below).
|
||||
|
||||
> **Limitation (routed-NAT v1):** System instances (ECS/EKS/load-balancer
|
||||
@@ -244,6 +250,24 @@ gateway = "192.168.1.1"
|
||||
prefix_len = 24
|
||||
```
|
||||
|
||||
### NAT gateways in NAT mode
|
||||
|
||||
`CreateNatGateway` works here too, and draws its public address from the same public pool an EIP does. On the OVN side it is an `snat` row on the VPC router rewriting the private subnet's CIDR to that address — not the `dnat_and_snat` an EIP gets — so it is centralised on the VPC's gateway chassis by construction and there is no port or MAC to bind it to.
|
||||
|
||||
**That address needs the same host plumbing as an EIP**, and on a routed-NAT node it is the host that delivers it: the `/32` route steering it into OVN, the proxy-ARP entry answering for it on the uplink, and the FORWARD accepts. Without them an egressing private instance leaves with a source address the host holds no route back to, while the masquerade rule — which matches the transit `100.127.0.0/24` alone — never sees the packet. Spinifex binds it when the gateway is created and re-asserts it on the host EIP pass, so no operator step is involved; `ip route show dev spx-nat-host` should list the gateway's public address alongside any EIPs.
|
||||
|
||||
### NAT mode on more than one node
|
||||
|
||||
`spx admin init --external-mode=nat --nodes N` works for N > 1, and each public IP is delivered by the node running its instance rather than by one gateway node. Three things make that true, and all three are automatic:
|
||||
|
||||
- The `dnat_and_snat` row carries `external_mac` and `logical_port`, so OVN processes it on the instance's own chassis — the same mechanism pool mode has always used. It is safe here because `ext-shared` is a localnet, so the transit veth is node-local and only the node running the instance can answer for the address on it.
|
||||
- Each node installs a host `/32` for its own instances' addresses and leaves the rest alone, asking local OVS which ENIs are plugged in here. Run `ip route show dev spx-nat-host` on each node: exactly one node should hold each public address.
|
||||
- Every node's `spx-nat-host` carries the same MAC, `02:00:64:7f:00:01`. OVN keeps one MAC binding for the transit next hop across the whole cluster, so a per-node address would send every chassis's return traffic to whichever node wrote the binding last. `setup-ovn.sh` sets it and vpcd re-asserts it on every start.
|
||||
|
||||
Default egress — an instance with no public IP — is still centralised on the VPC's gateway chassis, which is unchanged and is what the gateway LRP's transit address is for.
|
||||
|
||||
**On a cloud host the address must also be registered on that node's VNIC.** The allocator runs per node and registers on the local VNIC, which is the right one exactly because the traffic now leaves there. `oci_vnic_iface` is the setting; `oci_vnic_id` is only needed to pin a pool to a specific VNIC and should not be set for this.
|
||||
|
||||
**Caveat — reaching an EIP from the spinifex host itself.** Host-sourced traffic enters OVN from the transit net, which is exempt from NAT (that is what makes the jumpbox pattern work) — so a host connection to an EIP that carries the transit source IP would skip DNAT. Spinifex stamps the EIP route with the uplink's LAN IP as source to avoid this, but if no uplink address can be determined, connect to the instance's **private IP** from the host instead. Other machines on the LAN are unaffected.
|
||||
|
||||
## Disabled (Empty/Omitted)
|
||||
@@ -263,6 +287,9 @@ VPC networking is overlay-only. No external connectivity. Instances can only com
|
||||
| DescribeInstances shows public IP | Yes | Yes | With public pool | No |
|
||||
| Admin must reserve IP range | Yes | No | Only static pool | No |
|
||||
| Needs router DHCP | No | Yes | Optional | No |
|
||||
| Works on a non-bridgeable uplink | No | No | Yes | n/a |
|
||||
| NAT gateways | Yes | Yes | With public pool | No |
|
||||
| More than one node | Yes | Yes | Yes | Yes |
|
||||
|
||||
If you start with `nat` and later need public subnets: on a bridgeable uplink switch to `pool` and define a range (or use `source = "dhcp"`); on a routed-NAT node just add a public pool alongside `nat-transit` — no data migration needed.
|
||||
|
||||
@@ -460,8 +487,8 @@ external_mode = "pool" # "pool", "nat", or "" (disabled)
|
||||
|
||||
| Value | Behavior |
|
||||
| -------------- | ----------------------------------------------------------------- |
|
||||
| `"pool"` | Full public networking — public subnets, auto-assign, Elastic IPs |
|
||||
| `"nat"` | Outbound-only SNAT — all VMs share one external IP |
|
||||
| `"pool"` | **Default.** Full public networking — public subnets, auto-assign, Elastic IPs |
|
||||
| `"nat"` | Outbound-only SNAT — all VMs share one external IP. Add a public pool for full parity. For uplinks that cannot be bridged, including every cloud host |
|
||||
| `""` / omitted | Overlay-only — no external connectivity |
|
||||
|
||||
## IP Pools: network.external_pools
|
||||
@@ -923,7 +950,7 @@ firewall_enabled = true
|
||||
|
||||
Before enabling it, check what else the machine is serving — anything listening
|
||||
outside the public port group stops accepting new connections. See
|
||||
[Firewall and Cluster Membership](/docs/install-multi-node#firewall-and-cluster-membership)
|
||||
[Host Firewall](/docs/host-firewall)
|
||||
for the full port policy and the cluster-formation steps.
|
||||
|
||||
### Verifying it
|
||||
|
||||
@@ -146,12 +146,11 @@ Repositories can be managed as code with the standard `aws_ecr_repository` resou
|
||||
|
||||
```hcl
|
||||
provider "aws" {
|
||||
region = "ap-southeast-2"
|
||||
skip_credentials_validation = true
|
||||
skip_requesting_account_id = true
|
||||
region = "ap-southeast-2"
|
||||
|
||||
endpoints {
|
||||
ecr = "https://<your-spinifex-gateway>"
|
||||
sts = "https://<your-spinifex-gateway>"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,18 +17,18 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
The platform serves **381 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
The platform serves **397 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
|
||||
| Service | Operations |
|
||||
|---|---:|
|
||||
| [ACM](/coverage/acm) | 9 |
|
||||
| [EC2](/coverage/ec2) | 125 |
|
||||
| [EC2](/coverage/ec2) | 127 |
|
||||
| [ECR](/coverage/ecr) | 22 |
|
||||
| [ECS](/coverage/ecs) | 31 |
|
||||
| [EKS](/coverage/eks) | 34 |
|
||||
| [ELBv2](/coverage/elbv2) | 34 |
|
||||
| [IAM](/coverage/iam) | 76 |
|
||||
| [EKS](/coverage/eks) | 36 |
|
||||
| [ELBv2](/coverage/elbv2) | 36 |
|
||||
| [IAM](/coverage/iam) | 79 |
|
||||
| [RDS](/coverage/rds) | 26 |
|
||||
| [S3](/coverage/s3) | 19 |
|
||||
| [S3](/coverage/s3) | 26 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **381** |
|
||||
| **Total** | **397** |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **125 operations** in the EC2 `2016-11-15` API model.
|
||||
Spinifex implements **127 operations** in the EC2 `2016-11-15` API model.
|
||||
|
||||
### Spot Instances Are a Mock
|
||||
|
||||
@@ -110,6 +110,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
| `DescribeVolumes` |
|
||||
| `DescribeVolumesModifications` |
|
||||
| `DescribeVpcAttribute` |
|
||||
| `DescribeVpcEndpoints` |
|
||||
| `DescribeVpcs` |
|
||||
| `DetachInternetGateway` |
|
||||
| `DetachNetworkInterface` |
|
||||
@@ -132,6 +133,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
| `ModifyInstanceMetadataOptions` |
|
||||
| `ModifyLaunchTemplate` |
|
||||
| `ModifyNetworkInterfaceAttribute` |
|
||||
| `ModifySecurityGroupRules` |
|
||||
| `ModifySubnetAttribute` |
|
||||
| `ModifyVolume` |
|
||||
| `ModifyVpcAttribute` |
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
Spinifex implements **36 operations** in the EKS `2017-11-01` API model.
|
||||
|
||||
### Operations
|
||||
|
||||
@@ -40,6 +40,7 @@ Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
| `DescribeCluster` |
|
||||
| `DescribeIdentityProviderConfig` |
|
||||
| `DescribeNodegroup` |
|
||||
| `DescribeUpdate` |
|
||||
| `DisassociateAccessPolicy` |
|
||||
| `DisassociateIdentityProviderConfig` |
|
||||
| `ListAccessEntries` |
|
||||
@@ -50,6 +51,7 @@ Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
| `ListIdentityProviderConfigs` |
|
||||
| `ListNodegroups` |
|
||||
| `ListTagsForResource` |
|
||||
| `ListUpdates` |
|
||||
| `TagResource` |
|
||||
| `UntagResource` |
|
||||
| `UpdateAccessEntry` |
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **34 operations** in the ELBv2 `2015-12-01` API model.
|
||||
Spinifex implements **36 operations** in the ELBv2 `2015-12-01` API model.
|
||||
|
||||
### Two data planes
|
||||
|
||||
@@ -40,6 +40,7 @@ The data plane is a system-managed load balancer VM, launched automatically duri
|
||||
| `DeleteTargetGroup` |
|
||||
| `DeregisterTargets` |
|
||||
| `DescribeAccountLimits` |
|
||||
| `DescribeListenerAttributes` |
|
||||
| `DescribeListenerCertificates` |
|
||||
| `DescribeListeners` |
|
||||
| `DescribeLoadBalancerAttributes` |
|
||||
@@ -51,6 +52,7 @@ The data plane is a system-managed load balancer VM, launched automatically duri
|
||||
| `DescribeTargetGroups` |
|
||||
| `DescribeTargetHealth` |
|
||||
| `ModifyListener` |
|
||||
| `ModifyListenerAttributes` |
|
||||
| `ModifyLoadBalancerAttributes` |
|
||||
| `ModifyRule` |
|
||||
| `ModifyTargetGroup` |
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **76 operations** in the IAM `2010-05-08` API model.
|
||||
Spinifex implements **79 operations** in the IAM `2010-05-08` API model.
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -38,6 +38,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `CreateInstanceProfile` |
|
||||
| `CreateOpenIDConnectProvider` |
|
||||
| `CreatePolicy` |
|
||||
| `CreatePolicyVersion` |
|
||||
| `CreateRole` |
|
||||
| `CreateUser` |
|
||||
| `DeleteAccessKey` |
|
||||
@@ -46,6 +47,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `DeleteInstanceProfile` |
|
||||
| `DeleteOpenIDConnectProvider` |
|
||||
| `DeletePolicy` |
|
||||
| `DeletePolicyVersion` |
|
||||
| `DeleteRole` |
|
||||
| `DeleteRolePolicy` |
|
||||
| `DeleteUser` |
|
||||
@@ -91,6 +93,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `PutUserPolicy` |
|
||||
| `RemoveRoleFromInstanceProfile` |
|
||||
| `RemoveUserFromGroup` |
|
||||
| `SetDefaultPolicyVersion` |
|
||||
| `TagInstanceProfile` |
|
||||
| `TagOpenIDConnectProvider` |
|
||||
| `TagPolicy` |
|
||||
|
||||
@@ -4,29 +4,56 @@ Generated by `make aws-model-coverage`. This file is not published: it carries t
|
||||
|
||||
**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.
|
||||
|
||||
Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
|
||||
|
||||
## acm
|
||||
|
||||
`2015-12-08` — 9 of 15 modelled operations implemented (60.0%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
`2015-12-08` — 9 of 40 modelled operations implemented (22.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `CreateAcmeDomainValidation`
|
||||
- `CreateAcmeEndpoint`
|
||||
- `CreateAcmeExternalAccountBinding`
|
||||
- `DeleteAcmeDomainValidation`
|
||||
- `DeleteAcmeEndpoint`
|
||||
- `DeleteAcmeExternalAccountBinding`
|
||||
- `DescribeAcmeAccount`
|
||||
- `DescribeAcmeDomainValidation`
|
||||
- `DescribeAcmeEndpoint`
|
||||
- `DescribeAcmeExternalAccountBinding`
|
||||
- `ExportCertificate`
|
||||
- `GetAccountConfiguration`
|
||||
- `GetAcmeExternalAccountBindingCredentials`
|
||||
- `ListAcmeAccounts`
|
||||
- `ListAcmeDomainValidations`
|
||||
- `ListAcmeEndpoints`
|
||||
- `ListAcmeExternalAccountBindings`
|
||||
- `ListCertificateDomainValidations`
|
||||
- `ListTagsForResource`
|
||||
- `PutAccountConfiguration`
|
||||
- `RenewCertificate`
|
||||
- `ResendValidationEmail`
|
||||
- `RevokeAcmeAccount`
|
||||
- `RevokeAcmeExternalAccountBinding`
|
||||
- `RevokeCertificate`
|
||||
- `SearchCertificates`
|
||||
- `TagResource`
|
||||
- `UntagResource`
|
||||
- `UpdateAcmeDomainValidation`
|
||||
- `UpdateAcmeEndpoint`
|
||||
- `UpdateCertificateOptions`
|
||||
|
||||
## ec2
|
||||
|
||||
`2016-11-15` — 125 of 625 modelled operations implemented (20.0%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
|
||||
`2016-11-15` — 127 of 804 modelled operations implemented (15.8%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `AcceptAddressTransfer`
|
||||
- `AcceptCapacityReservationBillingOwnership`
|
||||
- `AcceptReservedInstancesExchangeQuote`
|
||||
- `AcceptTransitGatewayClientVpnAttachment`
|
||||
- `AcceptTransitGatewayMulticastDomainAssociations`
|
||||
- `AcceptTransitGatewayPeeringAttachment`
|
||||
- `AcceptTransitGatewayVpcAttachment`
|
||||
@@ -39,6 +66,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `AssignIpv6Addresses`
|
||||
- `AssignPrivateIpAddresses`
|
||||
- `AssignPrivateNatGatewayAddress`
|
||||
- `AssociateApplicationStatusCheck`
|
||||
- `AssociateCapacityReservationBillingOwner`
|
||||
- `AssociateClientVpnTargetNetwork`
|
||||
- `AssociateDhcpOptions`
|
||||
- `AssociateEnclaveCertificateIamRole`
|
||||
@@ -46,23 +75,34 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `AssociateIpamByoasn`
|
||||
- `AssociateIpamResourceDiscovery`
|
||||
- `AssociateNatGatewayAddress`
|
||||
- `AssociateRouteServer`
|
||||
- `AssociateSecurityGroupVpc`
|
||||
- `AssociateSubnetCidrBlock`
|
||||
- `AssociateTransitGatewayMulticastDomain`
|
||||
- `AssociateTransitGatewayPolicyTable`
|
||||
- `AssociateTransitGatewayRouteTable`
|
||||
- `AssociateTrunkInterface`
|
||||
- `AssociateVpcCidrBlock`
|
||||
- `AttachImageWatermark`
|
||||
- `AttachVerifiedAccessTrustProvider`
|
||||
- `AttachVpnGateway`
|
||||
- `AuthorizeClientVpnIngress`
|
||||
- `BatchModifyIpamRoutingPolicyRegistrations`
|
||||
- `BundleInstance`
|
||||
- `CancelBundleTask`
|
||||
- `CancelCapacityReservationFleets`
|
||||
- `CancelConversionTask`
|
||||
- `CancelDeclarativePoliciesReport`
|
||||
- `CancelExportTask`
|
||||
- `CancelImageLaunchPermission`
|
||||
- `CancelImportTask`
|
||||
- `CancelSpotFleetRequests`
|
||||
- `CopyVolumes`
|
||||
- `CreateApplicationStatusCheck`
|
||||
- `CreateCapacityManagerDataExport`
|
||||
- `CreateCapacityReservationBySplitting`
|
||||
- `CreateCapacityReservationCancellationQuote`
|
||||
- `CreateCapacityReservationDateChangeQuote`
|
||||
- `CreateCapacityReservationFleet`
|
||||
- `CreateClientVpnEndpoint`
|
||||
- `CreateClientVpnRoute`
|
||||
@@ -71,21 +111,32 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `CreateCustomerGateway`
|
||||
- `CreateDefaultSubnet`
|
||||
- `CreateDefaultVpc`
|
||||
- `CreateDelegateMacVolumeOwnershipTask`
|
||||
- `CreateDhcpOptions`
|
||||
- `CreateFleet`
|
||||
- `CreateFlowLogs`
|
||||
- `CreateImageUsageReport`
|
||||
- `CreateInstanceConnectEndpoint`
|
||||
- `CreateInstanceEventWindow`
|
||||
- `CreateInstanceExportTask`
|
||||
- `CreateInterruptibleCapacityReservationAllocation`
|
||||
- `CreateIpam`
|
||||
- `CreateIpamExternalResourceVerificationToken`
|
||||
- `CreateIpamInternetRegistryAssociation`
|
||||
- `CreateIpamPolicy`
|
||||
- `CreateIpamPool`
|
||||
- `CreateIpamPrefixListResolver`
|
||||
- `CreateIpamPrefixListResolverTarget`
|
||||
- `CreateIpamResourceDiscovery`
|
||||
- `CreateIpamRoutingPolicyRegistration`
|
||||
- `CreateIpamScope`
|
||||
- `CreateLocalGatewayRoute`
|
||||
- `CreateLocalGatewayRouteTable`
|
||||
- `CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation`
|
||||
- `CreateLocalGatewayRouteTableVpcAssociation`
|
||||
- `CreateLocalGatewayVirtualInterface`
|
||||
- `CreateLocalGatewayVirtualInterfaceGroup`
|
||||
- `CreateMacSystemIntegrityProtectionModificationTask`
|
||||
- `CreateManagedPrefixList`
|
||||
- `CreateNetworkAcl`
|
||||
- `CreateNetworkAclEntry`
|
||||
@@ -95,6 +146,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `CreatePublicIpv4Pool`
|
||||
- `CreateReplaceRootVolumeTask`
|
||||
- `CreateRestoreImageTask`
|
||||
- `CreateRouteServer`
|
||||
- `CreateRouteServerEndpoint`
|
||||
- `CreateRouteServerPeer`
|
||||
- `CreateSecondaryNetwork`
|
||||
- `CreateSecondarySubnet`
|
||||
- `CreateSnapshots`
|
||||
- `CreateSpotDatafeedSubscription`
|
||||
- `CreateStoreImageTask`
|
||||
@@ -106,9 +162,12 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `CreateTransitGateway`
|
||||
- `CreateTransitGatewayConnect`
|
||||
- `CreateTransitGatewayConnectPeer`
|
||||
- `CreateTransitGatewayMeteringPolicy`
|
||||
- `CreateTransitGatewayMeteringPolicyEntry`
|
||||
- `CreateTransitGatewayMulticastDomain`
|
||||
- `CreateTransitGatewayPeeringAttachment`
|
||||
- `CreateTransitGatewayPolicyTable`
|
||||
- `CreateTransitGatewayPolicyTableEntry`
|
||||
- `CreateTransitGatewayPrefixListReference`
|
||||
- `CreateTransitGatewayRoute`
|
||||
- `CreateTransitGatewayRouteTable`
|
||||
@@ -118,13 +177,18 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `CreateVerifiedAccessGroup`
|
||||
- `CreateVerifiedAccessInstance`
|
||||
- `CreateVerifiedAccessTrustProvider`
|
||||
- `CreateVpcBlockPublicAccessExclusion`
|
||||
- `CreateVpcEncryptionControl`
|
||||
- `CreateVpcEndpoint`
|
||||
- `CreateVpcEndpointConnectionNotification`
|
||||
- `CreateVpcEndpointServiceConfiguration`
|
||||
- `CreateVpcPeeringConnection`
|
||||
- `CreateVpnConcentrator`
|
||||
- `CreateVpnConnection`
|
||||
- `CreateVpnConnectionRoute`
|
||||
- `CreateVpnGateway`
|
||||
- `DeleteApplicationStatusCheck`
|
||||
- `DeleteCapacityManagerDataExport`
|
||||
- `DeleteClientVpnEndpoint`
|
||||
- `DeleteClientVpnRoute`
|
||||
- `DeleteCoipCidr`
|
||||
@@ -133,17 +197,25 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DeleteDhcpOptions`
|
||||
- `DeleteFleets`
|
||||
- `DeleteFlowLogs`
|
||||
- `DeleteImageUsageReport`
|
||||
- `DeleteInstanceConnectEndpoint`
|
||||
- `DeleteInstanceEventWindow`
|
||||
- `DeleteIpam`
|
||||
- `DeleteIpamExternalResourceVerificationToken`
|
||||
- `DeleteIpamInternetRegistryAssociation`
|
||||
- `DeleteIpamPolicy`
|
||||
- `DeleteIpamPool`
|
||||
- `DeleteIpamPrefixListResolver`
|
||||
- `DeleteIpamPrefixListResolverTarget`
|
||||
- `DeleteIpamResourceDiscovery`
|
||||
- `DeleteIpamRoutingPolicyRegistration`
|
||||
- `DeleteIpamScope`
|
||||
- `DeleteLocalGatewayRoute`
|
||||
- `DeleteLocalGatewayRouteTable`
|
||||
- `DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation`
|
||||
- `DeleteLocalGatewayRouteTableVpcAssociation`
|
||||
- `DeleteLocalGatewayVirtualInterface`
|
||||
- `DeleteLocalGatewayVirtualInterfaceGroup`
|
||||
- `DeleteManagedPrefixList`
|
||||
- `DeleteNetworkAcl`
|
||||
- `DeleteNetworkAclEntry`
|
||||
@@ -154,6 +226,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DeleteNetworkInterfacePermission`
|
||||
- `DeletePublicIpv4Pool`
|
||||
- `DeleteQueuedReservedInstances`
|
||||
- `DeleteRouteServer`
|
||||
- `DeleteRouteServerEndpoint`
|
||||
- `DeleteRouteServerPeer`
|
||||
- `DeleteSecondaryNetwork`
|
||||
- `DeleteSecondarySubnet`
|
||||
- `DeleteSpotDatafeedSubscription`
|
||||
- `DeleteSubnetCidrReservation`
|
||||
- `DeleteTrafficMirrorFilter`
|
||||
@@ -161,11 +238,15 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DeleteTrafficMirrorSession`
|
||||
- `DeleteTrafficMirrorTarget`
|
||||
- `DeleteTransitGateway`
|
||||
- `DeleteTransitGatewayClientVpnAttachment`
|
||||
- `DeleteTransitGatewayConnect`
|
||||
- `DeleteTransitGatewayConnectPeer`
|
||||
- `DeleteTransitGatewayMeteringPolicy`
|
||||
- `DeleteTransitGatewayMeteringPolicyEntry`
|
||||
- `DeleteTransitGatewayMulticastDomain`
|
||||
- `DeleteTransitGatewayPeeringAttachment`
|
||||
- `DeleteTransitGatewayPolicyTable`
|
||||
- `DeleteTransitGatewayPolicyTableEntry`
|
||||
- `DeleteTransitGatewayPrefixListReference`
|
||||
- `DeleteTransitGatewayRoute`
|
||||
- `DeleteTransitGatewayRouteTable`
|
||||
@@ -175,10 +256,13 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DeleteVerifiedAccessGroup`
|
||||
- `DeleteVerifiedAccessInstance`
|
||||
- `DeleteVerifiedAccessTrustProvider`
|
||||
- `DeleteVpcBlockPublicAccessExclusion`
|
||||
- `DeleteVpcEncryptionControl`
|
||||
- `DeleteVpcEndpointConnectionNotifications`
|
||||
- `DeleteVpcEndpointServiceConfigurations`
|
||||
- `DeleteVpcEndpoints`
|
||||
- `DeleteVpcPeeringConnection`
|
||||
- `DeleteVpnConcentrator`
|
||||
- `DeleteVpnConnection`
|
||||
- `DeleteVpnConnectionRoute`
|
||||
- `DeleteVpnGateway`
|
||||
@@ -189,11 +273,24 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DeregisterInstanceEventNotificationAttributes`
|
||||
- `DeregisterTransitGatewayMulticastGroupMembers`
|
||||
- `DeregisterTransitGatewayMulticastGroupSources`
|
||||
- `DescribeAccountVpcEncryptionControl`
|
||||
- `DescribeAddressTransfers`
|
||||
- `DescribeApplicationStatus`
|
||||
- `DescribeApplicationStatusCheckAssociations`
|
||||
- `DescribeApplicationStatusChecks`
|
||||
- `DescribeBundleTasks`
|
||||
- `DescribeByoipCidrs`
|
||||
- `DescribeCapacityBlockExtensionHistory`
|
||||
- `DescribeCapacityBlockExtensionOfferings`
|
||||
- `DescribeCapacityBlockOfferings`
|
||||
- `DescribeCapacityBlockStatus`
|
||||
- `DescribeCapacityBlocks`
|
||||
- `DescribeCapacityManagerDataExports`
|
||||
- `DescribeCapacityReservationBillingRequests`
|
||||
- `DescribeCapacityReservationCancellationQuotes`
|
||||
- `DescribeCapacityReservationDateChangeQuotes`
|
||||
- `DescribeCapacityReservationFleets`
|
||||
- `DescribeCapacityReservationTopology`
|
||||
- `DescribeClientVpnAuthorizationRules`
|
||||
- `DescribeClientVpnConnections`
|
||||
- `DescribeClientVpnEndpoints`
|
||||
@@ -202,6 +299,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeCoipPools`
|
||||
- `DescribeConversionTasks`
|
||||
- `DescribeCustomerGateways`
|
||||
- `DescribeDeclarativePoliciesReports`
|
||||
- `DescribeDhcpOptions`
|
||||
- `DescribeExportImageTasks`
|
||||
- `DescribeExportTasks`
|
||||
@@ -214,15 +312,26 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeHostReservationOfferings`
|
||||
- `DescribeHostReservations`
|
||||
- `DescribeHosts`
|
||||
- `DescribeImageReferences`
|
||||
- `DescribeImageUsageReportEntries`
|
||||
- `DescribeImageUsageReports`
|
||||
- `DescribeImportImageTasks`
|
||||
- `DescribeImportSnapshotTasks`
|
||||
- `DescribeInstanceConnectEndpoints`
|
||||
- `DescribeInstanceEventNotificationAttributes`
|
||||
- `DescribeInstanceEventWindows`
|
||||
- `DescribeInstanceImageMetadata`
|
||||
- `DescribeInstanceSqlHaHistoryStates`
|
||||
- `DescribeInstanceSqlHaStates`
|
||||
- `DescribeInstanceTopology`
|
||||
- `DescribeIpamByoasn`
|
||||
- `DescribeIpamExternalResourceVerificationTokens`
|
||||
- `DescribeIpamInternetRegistryAssociations`
|
||||
- `DescribeIpamPolicies`
|
||||
- `DescribeIpamPoolAllocations`
|
||||
- `DescribeIpamPools`
|
||||
- `DescribeIpamPrefixListResolverTargets`
|
||||
- `DescribeIpamPrefixListResolvers`
|
||||
- `DescribeIpamResourceDiscoveries`
|
||||
- `DescribeIpamResourceDiscoveryAssociations`
|
||||
- `DescribeIpamScopes`
|
||||
@@ -236,6 +345,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeLocalGateways`
|
||||
- `DescribeLockedSnapshots`
|
||||
- `DescribeMacHosts`
|
||||
- `DescribeMacModificationTasks`
|
||||
- `DescribeManagedPrefixLists`
|
||||
- `DescribeNetworkAcls`
|
||||
- `DescribeNetworkInsightsAccessScopeAnalyses`
|
||||
@@ -244,15 +354,24 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeNetworkInsightsPaths`
|
||||
- `DescribeNetworkInterfaceAttribute`
|
||||
- `DescribeNetworkInterfacePermissions`
|
||||
- `DescribeOutpostLags`
|
||||
- `DescribePrefixLists`
|
||||
- `DescribePublicIpv4Pools`
|
||||
- `DescribeReplaceRootVolumeTasks`
|
||||
- `DescribeReservedInstances`
|
||||
- `DescribeReservedInstancesModifications`
|
||||
- `DescribeReservedInstancesOfferings`
|
||||
- `DescribeRouteServerEndpoints`
|
||||
- `DescribeRouteServerPeers`
|
||||
- `DescribeRouteServers`
|
||||
- `DescribeScheduledInstanceAvailability`
|
||||
- `DescribeScheduledInstances`
|
||||
- `DescribeSecondaryInterfaces`
|
||||
- `DescribeSecondaryNetworks`
|
||||
- `DescribeSecondarySubnets`
|
||||
- `DescribeSecurityGroupReferences`
|
||||
- `DescribeSecurityGroupVpcAssociations`
|
||||
- `DescribeServiceLinkVirtualInterfaces`
|
||||
- `DescribeSnapshotAttribute`
|
||||
- `DescribeSnapshotTierStatus`
|
||||
- `DescribeSpotDatafeedSubscription`
|
||||
@@ -268,6 +387,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeTransitGatewayAttachments`
|
||||
- `DescribeTransitGatewayConnectPeers`
|
||||
- `DescribeTransitGatewayConnects`
|
||||
- `DescribeTransitGatewayMeteringPolicies`
|
||||
- `DescribeTransitGatewayMulticastDomains`
|
||||
- `DescribeTransitGatewayPeeringAttachments`
|
||||
- `DescribeTransitGatewayPolicyTables`
|
||||
@@ -282,34 +402,49 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DescribeVerifiedAccessInstances`
|
||||
- `DescribeVerifiedAccessTrustProviders`
|
||||
- `DescribeVolumeAttribute`
|
||||
- `DescribeVpcBlockPublicAccessExclusions`
|
||||
- `DescribeVpcBlockPublicAccessOptions`
|
||||
- `DescribeVpcEncryptionControls`
|
||||
- `DescribeVpcEndpointAssociations`
|
||||
- `DescribeVpcEndpointConnectionNotifications`
|
||||
- `DescribeVpcEndpointConnections`
|
||||
- `DescribeVpcEndpointServiceConfigurations`
|
||||
- `DescribeVpcEndpointServicePermissions`
|
||||
- `DescribeVpcEndpointServices`
|
||||
- `DescribeVpcEndpoints`
|
||||
- `DescribeVpcPeeringConnections`
|
||||
- `DescribeVpnConcentrators`
|
||||
- `DescribeVpnConnections`
|
||||
- `DescribeVpnGateways`
|
||||
- `DetachImageWatermark`
|
||||
- `DetachVerifiedAccessTrustProvider`
|
||||
- `DetachVpnGateway`
|
||||
- `DisableAddressTransfer`
|
||||
- `DisableAllowedImagesSettings`
|
||||
- `DisableApplicationStatusCheckSuppression`
|
||||
- `DisableCapacityManager`
|
||||
- `DisableFastLaunch`
|
||||
- `DisableFastSnapshotRestores`
|
||||
- `DisableImage`
|
||||
- `DisableImageBlockPublicAccess`
|
||||
- `DisableImageDeprecation`
|
||||
- `DisableImageDeregistrationProtection`
|
||||
- `DisableInstanceSqlHaStandbyDetections`
|
||||
- `DisableIpamOrganizationAdminAccount`
|
||||
- `DisableIpamPolicy`
|
||||
- `DisableRouteServerPropagation`
|
||||
- `DisableSnapshotBlockPublicAccess`
|
||||
- `DisableTransitGatewayRouteTablePropagation`
|
||||
- `DisableVgwRoutePropagation`
|
||||
- `DisassociateApplicationStatusCheck`
|
||||
- `DisassociateCapacityReservationBillingOwner`
|
||||
- `DisassociateClientVpnTargetNetwork`
|
||||
- `DisassociateEnclaveCertificateIamRole`
|
||||
- `DisassociateInstanceEventWindow`
|
||||
- `DisassociateIpamByoasn`
|
||||
- `DisassociateIpamResourceDiscovery`
|
||||
- `DisassociateNatGatewayAddress`
|
||||
- `DisassociateRouteServer`
|
||||
- `DisassociateSecurityGroupVpc`
|
||||
- `DisassociateSubnetCidrBlock`
|
||||
- `DisassociateTransitGatewayMulticastDomain`
|
||||
- `DisassociateTransitGatewayPolicyTable`
|
||||
@@ -317,14 +452,21 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `DisassociateTrunkInterface`
|
||||
- `DisassociateVpcCidrBlock`
|
||||
- `EnableAddressTransfer`
|
||||
- `EnableAllowedImagesSettings`
|
||||
- `EnableApplicationStatusCheckSuppression`
|
||||
- `EnableCapacityManager`
|
||||
- `EnableFastLaunch`
|
||||
- `EnableFastSnapshotRestores`
|
||||
- `EnableImage`
|
||||
- `EnableImageBlockPublicAccess`
|
||||
- `EnableImageDeprecation`
|
||||
- `EnableImageDeregistrationProtection`
|
||||
- `EnableInstanceSqlHaStandbyDetections`
|
||||
- `EnableIpamInternetRegistryAssociation`
|
||||
- `EnableIpamOrganizationAdminAccount`
|
||||
- `EnableIpamPolicy`
|
||||
- `EnableReachabilityAnalyzerOrganizationSharing`
|
||||
- `EnableRouteServerPropagation`
|
||||
- `EnableSnapshotBlockPublicAccess`
|
||||
- `EnableTransitGatewayRouteTablePropagation`
|
||||
- `EnableVgwRoutePropagation`
|
||||
@@ -333,16 +475,26 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ExportClientVpnClientConfiguration`
|
||||
- `ExportImage`
|
||||
- `ExportTransitGatewayRoutes`
|
||||
- `ExportVerifiedAccessInstanceClientConfiguration`
|
||||
- `GetActiveVpnTunnelStatus`
|
||||
- `GetAllowedImagesSettings`
|
||||
- `GetAssociatedEnclaveCertificateIamRoles`
|
||||
- `GetAssociatedIpv6PoolCidrs`
|
||||
- `GetCapacityManagerAttributes`
|
||||
- `GetCapacityManagerMetricData`
|
||||
- `GetCapacityManagerMetricDimensions`
|
||||
- `GetCapacityManagerMonitoredTagKeys`
|
||||
- `GetCapacityReservationUsage`
|
||||
- `GetCoipPoolUsage`
|
||||
- `GetConsoleScreenshot`
|
||||
- `GetDeclarativePoliciesReportSummary`
|
||||
- `GetDefaultCreditSpecification`
|
||||
- `GetEbsDefaultKmsKeyId`
|
||||
- `GetEnabledIpamPolicy`
|
||||
- `GetFlowLogsIntegrationTemplate`
|
||||
- `GetGroupsForCapacityReservation`
|
||||
- `GetHostReservationPurchasePreview`
|
||||
- `GetImageAncestry`
|
||||
- `GetImageBlockPublicAccessState`
|
||||
- `GetInstanceMetadataDefaults`
|
||||
- `GetInstanceTpmEkPub`
|
||||
@@ -352,19 +504,36 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `GetIpamDiscoveredAccounts`
|
||||
- `GetIpamDiscoveredPublicAddresses`
|
||||
- `GetIpamDiscoveredResourceCidrs`
|
||||
- `GetIpamDiscoveredRoutes`
|
||||
- `GetIpamInternetRegistryAssociationAsns`
|
||||
- `GetIpamInternetRegistryAssociationCidrs`
|
||||
- `GetIpamPolicyAllocationRules`
|
||||
- `GetIpamPolicyOrganizationTargets`
|
||||
- `GetIpamPoolAllocations`
|
||||
- `GetIpamPoolCidrs`
|
||||
- `GetIpamPrefixListResolverRules`
|
||||
- `GetIpamPrefixListResolverVersionEntries`
|
||||
- `GetIpamPrefixListResolverVersions`
|
||||
- `GetIpamResourceCidrs`
|
||||
- `GetIpamRouteOriginAuthorizations`
|
||||
- `GetIpamRouteProtectionFindings`
|
||||
- `GetIpamRoutingPolicyRegistrationDeltas`
|
||||
- `GetIpamRoutingPolicyRegistrations`
|
||||
- `GetLaunchTemplateData`
|
||||
- `GetManagedPrefixListAssociations`
|
||||
- `GetManagedPrefixListEntries`
|
||||
- `GetManagedResourceVisibility`
|
||||
- `GetNetworkInsightsAccessScopeAnalysisFindings`
|
||||
- `GetNetworkInsightsAccessScopeContent`
|
||||
- `GetReservedInstancesExchangeQuote`
|
||||
- `GetRouteServerAssociations`
|
||||
- `GetRouteServerPropagations`
|
||||
- `GetRouteServerRoutingDatabase`
|
||||
- `GetSnapshotBlockPublicAccessState`
|
||||
- `GetSpotPlacementScores`
|
||||
- `GetSubnetCidrReservations`
|
||||
- `GetTransitGatewayAttachmentPropagations`
|
||||
- `GetTransitGatewayMeteringPolicyEntries`
|
||||
- `GetTransitGatewayMulticastDomainAssociations`
|
||||
- `GetTransitGatewayPolicyTableAssociations`
|
||||
- `GetTransitGatewayPolicyTableEntries`
|
||||
@@ -372,7 +541,9 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `GetTransitGatewayRouteTableAssociations`
|
||||
- `GetTransitGatewayRouteTablePropagations`
|
||||
- `GetVerifiedAccessEndpointPolicy`
|
||||
- `GetVerifiedAccessEndpointTargets`
|
||||
- `GetVerifiedAccessGroupPolicy`
|
||||
- `GetVpcResourcesBlockingEncryptionEnforcement`
|
||||
- `GetVpnConnectionDeviceSampleConfiguration`
|
||||
- `GetVpnConnectionDeviceTypes`
|
||||
- `GetVpnTunnelReplacementStatus`
|
||||
@@ -383,8 +554,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ImportVolume`
|
||||
- `ListImagesInRecycleBin`
|
||||
- `ListSnapshotsInRecycleBin`
|
||||
- `ListVolumesInRecycleBin`
|
||||
- `LockSnapshot`
|
||||
- `ModifyAccountVpcEncryptionControl`
|
||||
- `ModifyAddressAttribute`
|
||||
- `ModifyApplicationStatusCheck`
|
||||
- `ModifyAvailabilityZoneGroup`
|
||||
- `ModifyCapacityReservation`
|
||||
- `ModifyCapacityReservationFleet`
|
||||
@@ -394,22 +568,32 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ModifyFleet`
|
||||
- `ModifyHosts`
|
||||
- `ModifyInstanceCapacityReservationAttributes`
|
||||
- `ModifyInstanceConnectEndpoint`
|
||||
- `ModifyInstanceCpuOptions`
|
||||
- `ModifyInstanceCreditSpecification`
|
||||
- `ModifyInstanceEventStartTime`
|
||||
- `ModifyInstanceEventWindow`
|
||||
- `ModifyInstanceMaintenanceOptions`
|
||||
- `ModifyInstanceMetadataDefaults`
|
||||
- `ModifyInstanceNetworkPerformanceOptions`
|
||||
- `ModifyInstancePlacement`
|
||||
- `ModifyIpam`
|
||||
- `ModifyIpamPolicyAllocationRules`
|
||||
- `ModifyIpamPool`
|
||||
- `ModifyIpamPoolAllocation`
|
||||
- `ModifyIpamPrefixListResolver`
|
||||
- `ModifyIpamPrefixListResolverTarget`
|
||||
- `ModifyIpamResourceCidr`
|
||||
- `ModifyIpamResourceDiscovery`
|
||||
- `ModifyIpamRoutingPolicyRegistration`
|
||||
- `ModifyIpamScope`
|
||||
- `ModifyLocalGatewayRoute`
|
||||
- `ModifyManagedPrefixList`
|
||||
- `ModifyManagedResourceVisibility`
|
||||
- `ModifyPrivateDnsNameOptions`
|
||||
- `ModifyPublicIpDnsNameOptions`
|
||||
- `ModifyReservedInstances`
|
||||
- `ModifySecurityGroupRules`
|
||||
- `ModifyRouteServer`
|
||||
- `ModifySnapshotAttribute`
|
||||
- `ModifySnapshotTier`
|
||||
- `ModifySpotFleetRequest`
|
||||
@@ -417,6 +601,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ModifyTrafficMirrorFilterRule`
|
||||
- `ModifyTrafficMirrorSession`
|
||||
- `ModifyTransitGateway`
|
||||
- `ModifyTransitGatewayMeteringPolicy`
|
||||
- `ModifyTransitGatewayPolicyTableEntry`
|
||||
- `ModifyTransitGatewayPrefixListReference`
|
||||
- `ModifyTransitGatewayVpcAttachment`
|
||||
- `ModifyVerifiedAccessEndpoint`
|
||||
@@ -427,8 +613,12 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ModifyVerifiedAccessInstanceLoggingConfiguration`
|
||||
- `ModifyVerifiedAccessTrustProvider`
|
||||
- `ModifyVolumeAttribute`
|
||||
- `ModifyVpcBlockPublicAccessExclusion`
|
||||
- `ModifyVpcBlockPublicAccessOptions`
|
||||
- `ModifyVpcEncryptionControl`
|
||||
- `ModifyVpcEndpoint`
|
||||
- `ModifyVpcEndpointConnectionNotification`
|
||||
- `ModifyVpcEndpointPayerResponsibility`
|
||||
- `ModifyVpcEndpointServiceConfiguration`
|
||||
- `ModifyVpcEndpointServicePayerResponsibility`
|
||||
- `ModifyVpcEndpointServicePermissions`
|
||||
@@ -439,17 +629,21 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `ModifyVpnTunnelCertificate`
|
||||
- `ModifyVpnTunnelOptions`
|
||||
- `MoveByoipCidrToIpam`
|
||||
- `MoveCapacityReservationInstances`
|
||||
- `ProvisionByoipCidr`
|
||||
- `ProvisionIpamByoasn`
|
||||
- `ProvisionIpamPoolCidr`
|
||||
- `ProvisionPublicIpv4PoolCidr`
|
||||
- `PurchaseCapacityBlock`
|
||||
- `PurchaseCapacityBlockExtension`
|
||||
- `PurchaseHostReservation`
|
||||
- `PurchaseReservedInstancesOffering`
|
||||
- `PurchaseScheduledInstances`
|
||||
- `RegisterInstanceEventNotificationAttributes`
|
||||
- `RegisterTransitGatewayMulticastGroupMembers`
|
||||
- `RegisterTransitGatewayMulticastGroupSources`
|
||||
- `RejectCapacityReservationBillingOwnership`
|
||||
- `RejectTransitGatewayClientVpnAttachment`
|
||||
- `RejectTransitGatewayMulticastDomainAssociations`
|
||||
- `RejectTransitGatewayPeeringAttachment`
|
||||
- `RejectTransitGatewayVpcAttachment`
|
||||
@@ -457,6 +651,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `RejectVpcPeeringConnection`
|
||||
- `ReleaseHosts`
|
||||
- `ReleaseIpamPoolAllocation`
|
||||
- `ReplaceImageCriteriaInAllowedImagesSettings`
|
||||
- `ReplaceImageInstanceTypeSpecification`
|
||||
- `ReplaceNetworkAclAssociation`
|
||||
- `ReplaceNetworkAclEntry`
|
||||
- `ReplaceTransitGatewayRoute`
|
||||
@@ -471,12 +667,14 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `RestoreManagedPrefixListVersion`
|
||||
- `RestoreSnapshotFromRecycleBin`
|
||||
- `RestoreSnapshotTier`
|
||||
- `RestoreVolumeFromRecycleBin`
|
||||
- `RevokeClientVpnIngress`
|
||||
- `RunScheduledInstances`
|
||||
- `SearchLocalGatewayRoutes`
|
||||
- `SearchTransitGatewayMulticastGroups`
|
||||
- `SearchTransitGatewayRoutes`
|
||||
- `SendDiagnosticInterrupt`
|
||||
- `StartDeclarativePoliciesReport`
|
||||
- `StartNetworkInsightsAccessScopeAnalysis`
|
||||
- `StartNetworkInsightsAnalysis`
|
||||
- `StartVpcEndpointServicePrivateDnsVerification`
|
||||
@@ -485,6 +683,10 @@ Pinned model source: `aws-sdk-go v1.55.8`.
|
||||
- `UnassignPrivateIpAddresses`
|
||||
- `UnassignPrivateNatGatewayAddress`
|
||||
- `UnlockSnapshot`
|
||||
- `UpdateCapacityManagerMonitoredTagKeys`
|
||||
- `UpdateCapacityManagerOrganizationsAccess`
|
||||
- `UpdateInterruptibleCapacityReservationAllocation`
|
||||
- `ValidateSecurityGroupQuotasForInterface`
|
||||
- `WithdrawByoipCidr`
|
||||
|
||||
### Not applicable — do not build
|
||||
@@ -560,7 +762,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## ecr
|
||||
|
||||
`2015-09-21` — 22 of 47 modelled operations implemented (46.8%), 11 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
`2015-09-21` — 22 of 58 modelled operations implemented (37.9%), 11 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -569,9 +771,20 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `DeletePullThroughCacheRule`
|
||||
- `DeleteRegistryPolicy`
|
||||
- `DeleteRepositoryCreationTemplate`
|
||||
- `DeleteSigningConfiguration`
|
||||
- `DeregisterPullTimeUpdateExclusion`
|
||||
- `DescribeImageReplicationStatus`
|
||||
- `DescribeImageSigningStatus`
|
||||
- `DescribePullThroughCacheRules`
|
||||
- `DescribeRepositoryCreationTemplates`
|
||||
- `GetAccountSetting`
|
||||
- `GetSigningConfiguration`
|
||||
- `ListImageReferrers`
|
||||
- `ListPullTimeUpdateExclusions`
|
||||
- `PutAccountSetting`
|
||||
- `PutSigningConfiguration`
|
||||
- `RegisterPullTimeUpdateExclusion`
|
||||
- `UpdateImageStorageClass`
|
||||
- `UpdatePullThroughCacheRule`
|
||||
- `UpdateRepositoryCreationTemplate`
|
||||
- `ValidatePullThroughCacheRule`
|
||||
@@ -607,27 +820,48 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## ecs
|
||||
|
||||
`2014-11-13` — 31 of 56 modelled operations implemented (55.4%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
`2014-11-13` — 31 of 77 modelled operations implemented (40.3%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `ContinueServiceDeployment`
|
||||
- `CreateDaemon`
|
||||
- `CreateExpressGatewayService`
|
||||
- `CreateTaskSet`
|
||||
- `DeleteAccountSetting`
|
||||
- `DeleteAttributes`
|
||||
- `DeleteDaemon`
|
||||
- `DeleteDaemonTaskDefinition`
|
||||
- `DeleteExpressGatewayService`
|
||||
- `DeleteTaskDefinitions`
|
||||
- `DeleteTaskSet`
|
||||
- `DescribeDaemon`
|
||||
- `DescribeDaemonDeployments`
|
||||
- `DescribeDaemonRevisions`
|
||||
- `DescribeDaemonTaskDefinition`
|
||||
- `DescribeExpressGatewayService`
|
||||
- `DescribeServiceDeployments`
|
||||
- `DescribeServiceRevisions`
|
||||
- `DescribeTaskSets`
|
||||
- `DiscoverPollEndpoint`
|
||||
- `ExecuteCommand`
|
||||
- `GetTaskProtection`
|
||||
- `ListAttributes`
|
||||
- `ListDaemonDeployments`
|
||||
- `ListDaemonTaskDefinitions`
|
||||
- `ListDaemons`
|
||||
- `ListServiceDeployments`
|
||||
- `PutAccountSettingDefault`
|
||||
- `PutAttributes`
|
||||
- `RegisterDaemonTaskDefinition`
|
||||
- `StopServiceDeployment`
|
||||
- `SubmitAttachmentStateChanges`
|
||||
- `SubmitContainerStateChange`
|
||||
- `UpdateCapacityProvider`
|
||||
- `UpdateClusterSettings`
|
||||
- `UpdateContainerAgent`
|
||||
- `UpdateDaemon`
|
||||
- `UpdateExpressGatewayService`
|
||||
- `UpdateServicePrimaryTaskSet`
|
||||
- `UpdateTaskProtection`
|
||||
- `UpdateTaskSet`
|
||||
@@ -648,30 +882,42 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## eks
|
||||
|
||||
`2017-11-01` — 34 of 56 modelled operations implemented (60.7%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
|
||||
`2017-11-01` — 36 of 70 modelled operations implemented (51.4%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `ActivateCertificateAuthority`
|
||||
- `AssociateEncryptionConfig`
|
||||
- `CancelUpdate`
|
||||
- `CreateCapability`
|
||||
- `CreateCertificateAuthority`
|
||||
- `CreateEksAnywhereSubscription`
|
||||
- `CreateFargateProfile`
|
||||
- `CreatePodIdentityAssociation`
|
||||
- `DeleteCapability`
|
||||
- `DeleteCertificateAuthority`
|
||||
- `DeleteEksAnywhereSubscription`
|
||||
- `DeleteFargateProfile`
|
||||
- `DeletePodIdentityAssociation`
|
||||
- `DeregisterCluster`
|
||||
- `DescribeAddonConfiguration`
|
||||
- `DescribeCapability`
|
||||
- `DescribeCertificateAuthority`
|
||||
- `DescribeClusterVersions`
|
||||
- `DescribeEksAnywhereSubscription`
|
||||
- `DescribeFargateProfile`
|
||||
- `DescribeInsight`
|
||||
- `DescribeInsightsRefresh`
|
||||
- `DescribePodIdentityAssociation`
|
||||
- `DescribeUpdate`
|
||||
- `ListCapabilities`
|
||||
- `ListCertificateAuthorities`
|
||||
- `ListEksAnywhereSubscriptions`
|
||||
- `ListFargateProfiles`
|
||||
- `ListInsights`
|
||||
- `ListPodIdentityAssociations`
|
||||
- `ListUpdates`
|
||||
- `RegisterCluster`
|
||||
- `StartInsightsRefresh`
|
||||
- `UpdateCapability`
|
||||
- `UpdateEksAnywhereSubscription`
|
||||
- `UpdatePodIdentityAssociation`
|
||||
|
||||
@@ -684,7 +930,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## elasticloadbalancingv2
|
||||
|
||||
`2015-12-01` — 34 of 46 modelled operations implemented (73.9%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
|
||||
`2015-12-01` — 36 of 51 modelled operations implemented (70.6%), 0 stubbed, 0 not applicable, 2 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -692,74 +938,92 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `CreateTrustStore`
|
||||
- `DeleteSharedTrustStoreAssociation`
|
||||
- `DeleteTrustStore`
|
||||
- `DescribeCapacityReservation`
|
||||
- `DescribeTrustStoreAssociations`
|
||||
- `DescribeTrustStoreRevocations`
|
||||
- `DescribeTrustStores`
|
||||
- `GetResourcePolicy`
|
||||
- `GetTrustStoreCaCertificatesBundle`
|
||||
- `GetTrustStoreRevocationContent`
|
||||
- `ModifyCapacityReservation`
|
||||
- `ModifyIpPools`
|
||||
- `ModifyTrustStore`
|
||||
- `RemoveTrustStoreRevocations`
|
||||
|
||||
### Registered outside the pinned model
|
||||
|
||||
- `DescribeListenerAttributes`
|
||||
- `GetLBConfig`
|
||||
- `LBAgentHeartbeat`
|
||||
- `ModifyListenerAttributes`
|
||||
|
||||
## iam
|
||||
|
||||
`2010-05-08` — 76 of 159 modelled operations implemented (47.8%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
||||
`2010-05-08` — 79 of 180 modelled operations implemented (43.9%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `AcceptDelegationRequest`
|
||||
- `AcquireRole`
|
||||
- `AddClientIDToOpenIDConnectProvider`
|
||||
- `AssociateDelegationRequest`
|
||||
- `ChangePassword`
|
||||
- `CreateAccountAlias`
|
||||
- `CreateDelegationRequest`
|
||||
- `CreateLoginProfile`
|
||||
- `CreatePolicyVersion`
|
||||
- `CreateSAMLProvider`
|
||||
- `CreateVirtualMFADevice`
|
||||
- `DeactivateMFADevice`
|
||||
- `DeleteAccountAlias`
|
||||
- `DeleteAccountPasswordPolicy`
|
||||
- `DeleteLoginProfile`
|
||||
- `DeletePolicyVersion`
|
||||
- `DeleteRolePermissionsBoundary`
|
||||
- `DeleteSAMLProvider`
|
||||
- `DeleteServerCertificate`
|
||||
- `DeleteUserPermissionsBoundary`
|
||||
- `DeleteVirtualMFADevice`
|
||||
- `DisableOrganizationsRootCredentialsManagement`
|
||||
- `DisableOrganizationsRootSessions`
|
||||
- `DisableOutboundWebIdentityFederation`
|
||||
- `EnableMFADevice`
|
||||
- `EnableOrganizationsRootCredentialsManagement`
|
||||
- `EnableOrganizationsRootSessions`
|
||||
- `EnableOutboundWebIdentityFederation`
|
||||
- `GenerateCredentialReport`
|
||||
- `GenerateServiceLastAccessedDetails`
|
||||
- `GetAccessKeyLastUsed`
|
||||
- `GetAccountAuthorizationDetails`
|
||||
- `GetAccountPasswordPolicy`
|
||||
- `GetAccountProperties`
|
||||
- `GetContextKeysForCustomPolicy`
|
||||
- `GetContextKeysForPrincipalPolicy`
|
||||
- `GetCredentialReport`
|
||||
- `GetDelegationRequest`
|
||||
- `GetHumanReadableSummary`
|
||||
- `GetLoginProfile`
|
||||
- `GetMFADevice`
|
||||
- `GetOutboundWebIdentityFederationInfo`
|
||||
- `GetRoleTemplateVersion`
|
||||
- `GetSAMLProvider`
|
||||
- `GetServerCertificate`
|
||||
- `GetServiceLastAccessedDetails`
|
||||
- `GetServiceLastAccessedDetailsWithEntities`
|
||||
- `ListAccountAliases`
|
||||
- `ListDelegationRequests`
|
||||
- `ListMFADeviceTags`
|
||||
- `ListMFADevices`
|
||||
- `ListOrganizationsFeatures`
|
||||
- `ListPoliciesGrantingServiceAccess`
|
||||
- `ListSAMLProviderTags`
|
||||
- `ListSAMLProviders`
|
||||
- `ListServerCertificateTags`
|
||||
- `ListServerCertificates`
|
||||
- `ListVirtualMFADevices`
|
||||
- `PutAccountProperties`
|
||||
- `PutRolePermissionsBoundary`
|
||||
- `PutUserPermissionsBoundary`
|
||||
- `RejectDelegationRequest`
|
||||
- `RemoveClientIDFromOpenIDConnectProvider`
|
||||
- `ResyncMFADevice`
|
||||
- `SetDefaultPolicyVersion`
|
||||
- `SendDelegationToken`
|
||||
- `SetSecurityTokenServicePreferences`
|
||||
- `SimulateCustomPolicy`
|
||||
- `SimulatePrincipalPolicy`
|
||||
@@ -770,6 +1034,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `UntagSAMLProvider`
|
||||
- `UntagServerCertificate`
|
||||
- `UpdateAccountPasswordPolicy`
|
||||
- `UpdateDelegationRequest`
|
||||
- `UpdateGroup`
|
||||
- `UpdateLoginProfile`
|
||||
- `UpdateOpenIDConnectProviderThumbprint`
|
||||
@@ -814,7 +1079,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
|
||||
## rds
|
||||
|
||||
`2014-10-31` — 26 of 162 modelled operations implemented (16.0%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
|
||||
`2014-10-31` — 26 of 164 modelled operations implemented (15.9%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -870,6 +1135,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `DescribeDBClusterSnapshotAttributes`
|
||||
- `DescribeDBClusterSnapshots`
|
||||
- `DescribeDBLogFiles`
|
||||
- `DescribeDBMajorEngineVersions`
|
||||
- `DescribeDBProxies`
|
||||
- `DescribeDBProxyEndpoints`
|
||||
- `DescribeDBProxyTargetGroups`
|
||||
@@ -890,6 +1156,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `DescribePendingMaintenanceActions`
|
||||
- `DescribeReservedDBInstances`
|
||||
- `DescribeReservedDBInstancesOfferings`
|
||||
- `DescribeServerlessV2PlatformVersions`
|
||||
- `DescribeSourceRegions`
|
||||
- `DescribeTenantDatabases`
|
||||
- `DescribeValidDBInstanceModifications`
|
||||
@@ -973,45 +1240,48 @@ option-groups: Option groups configure engine add-ons for engines this platform
|
||||
|
||||
## s3
|
||||
|
||||
`2006-03-01` — 19 of 99 modelled operations implemented (19.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
|
||||
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `CreateBucketMetadataConfiguration`
|
||||
- `CreateBucketMetadataTableConfiguration`
|
||||
- `DeleteBucketAnalyticsConfiguration`
|
||||
- `DeleteBucketCors`
|
||||
- `DeleteBucketEncryption`
|
||||
- `DeleteBucketIntelligentTieringConfiguration`
|
||||
- `DeleteBucketInventoryConfiguration`
|
||||
- `DeleteBucketLifecycle`
|
||||
- `DeleteBucketMetadataConfiguration`
|
||||
- `DeleteBucketMetadataTableConfiguration`
|
||||
- `DeleteBucketMetricsConfiguration`
|
||||
- `DeleteBucketOwnershipControls`
|
||||
- `DeleteBucketPolicy`
|
||||
- `DeleteBucketReplication`
|
||||
- `DeleteBucketTagging`
|
||||
- `DeleteBucketWebsite`
|
||||
- `DeleteObjectAnnotation`
|
||||
- `DeleteObjectTagging`
|
||||
- `DeletePublicAccessBlock`
|
||||
- `GetBucketAbac`
|
||||
- `GetBucketAcl`
|
||||
- `GetBucketAnalyticsConfiguration`
|
||||
- `GetBucketCors`
|
||||
- `GetBucketEncryption`
|
||||
- `GetBucketIntelligentTieringConfiguration`
|
||||
- `GetBucketInventoryConfiguration`
|
||||
- `GetBucketLifecycle`
|
||||
- `GetBucketLifecycleConfiguration`
|
||||
- `GetBucketLocation`
|
||||
- `GetBucketLogging`
|
||||
- `GetBucketMetadataConfiguration`
|
||||
- `GetBucketMetadataTableConfiguration`
|
||||
- `GetBucketMetricsConfiguration`
|
||||
- `GetBucketNotification`
|
||||
- `GetBucketNotificationConfiguration`
|
||||
- `GetBucketOwnershipControls`
|
||||
- `GetBucketPolicy`
|
||||
- `GetBucketPolicyStatus`
|
||||
- `GetBucketReplication`
|
||||
- `GetBucketTagging`
|
||||
- `GetBucketVersioning`
|
||||
- `GetBucketWebsite`
|
||||
- `GetObjectAcl`
|
||||
- `GetObjectAnnotation`
|
||||
- `GetObjectAttributes`
|
||||
- `GetObjectLegalHold`
|
||||
- `GetObjectLockConfiguration`
|
||||
@@ -1024,33 +1294,36 @@ option-groups: Option groups configure engine add-ons for engines this platform
|
||||
- `ListBucketInventoryConfigurations`
|
||||
- `ListBucketMetricsConfigurations`
|
||||
- `ListDirectoryBuckets`
|
||||
- `ListObjectVersions`
|
||||
- `ListObjectAnnotations`
|
||||
- `PutBucketAbac`
|
||||
- `PutBucketAcl`
|
||||
- `PutBucketAnalyticsConfiguration`
|
||||
- `PutBucketCors`
|
||||
- `PutBucketEncryption`
|
||||
- `PutBucketIntelligentTieringConfiguration`
|
||||
- `PutBucketInventoryConfiguration`
|
||||
- `PutBucketLifecycle`
|
||||
- `PutBucketLifecycleConfiguration`
|
||||
- `PutBucketLogging`
|
||||
- `PutBucketMetricsConfiguration`
|
||||
- `PutBucketNotification`
|
||||
- `PutBucketNotificationConfiguration`
|
||||
- `PutBucketOwnershipControls`
|
||||
- `PutBucketPolicy`
|
||||
- `PutBucketReplication`
|
||||
- `PutBucketTagging`
|
||||
- `PutBucketVersioning`
|
||||
- `PutBucketWebsite`
|
||||
- `PutObjectAcl`
|
||||
- `PutObjectAnnotation`
|
||||
- `PutObjectLegalHold`
|
||||
- `PutObjectLockConfiguration`
|
||||
- `PutObjectRetention`
|
||||
- `PutObjectTagging`
|
||||
- `PutPublicAccessBlock`
|
||||
- `RenameObject`
|
||||
- `RestoreObject`
|
||||
- `SelectObjectContent`
|
||||
- `UpdateBucketMetadataAnnotationTableConfiguration`
|
||||
- `UpdateBucketMetadataInventoryTableConfiguration`
|
||||
- `UpdateBucketMetadataJournalTableConfiguration`
|
||||
- `UpdateObjectEncryption`
|
||||
|
||||
### Not applicable — do not build
|
||||
|
||||
@@ -1074,11 +1347,14 @@ request-payment: Requester Pays shifts transfer charges to the caller, which nee
|
||||
|
||||
## sts
|
||||
|
||||
`2011-06-15` — 5 of 8 modelled operations implemented (62.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
`2011-06-15` — 5 of 11 modelled operations implemented (45.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `AssumeRoleWithSAML`
|
||||
- `AssumeRoot`
|
||||
- `DecodeAuthorizationMessage`
|
||||
- `GetDelegatedAccessToken`
|
||||
- `GetFederationToken`
|
||||
- `GetWebIdentityToken`
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Predastore implements **19 operations** in the S3 `2006-03-01` API model.
|
||||
Predastore implements **26 operations** in the S3 `2006-03-01` API model.
|
||||
|
||||
### Predastore serves this endpoint
|
||||
|
||||
@@ -40,16 +40,23 @@ That behaviour is measured separately, against the `ceph/s3-tests` suite Ceph RG
|
||||
| `CreateBucket` |
|
||||
| `CreateMultipartUpload` |
|
||||
| `DeleteBucket` |
|
||||
| `DeleteBucketTagging` |
|
||||
| `DeleteObject` |
|
||||
| `DeleteObjects` |
|
||||
| `GetBucketLocation` |
|
||||
| `GetBucketTagging` |
|
||||
| `GetBucketVersioning` |
|
||||
| `GetObject` |
|
||||
| `HeadBucket` |
|
||||
| `HeadObject` |
|
||||
| `ListBuckets` |
|
||||
| `ListMultipartUploads` |
|
||||
| `ListObjectVersions` |
|
||||
| `ListObjects` |
|
||||
| `ListObjectsV2` |
|
||||
| `ListParts` |
|
||||
| `PutBucketTagging` |
|
||||
| `PutBucketVersioning` |
|
||||
| `PutObject` |
|
||||
| `UploadPart` |
|
||||
| `UploadPartCopy` |
|
||||
|
||||
@@ -22,8 +22,6 @@ resources:
|
||||
url: "/hardware/cisco/platform-benchmark"
|
||||
- title: "Vision pipeline (companion document)"
|
||||
url: "/hardware/cisco/vision-pipeline"
|
||||
- title: "Benchmark and workload materials (CISCO-refarch)"
|
||||
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
|
||||
- title: "vLLM"
|
||||
url: "https://docs.vllm.ai"
|
||||
- title: "Qwen2.5-7B-Instruct"
|
||||
|
||||
@@ -22,8 +22,6 @@ resources:
|
||||
url: "/docs/launching-instances"
|
||||
- title: "GPU Passthrough"
|
||||
url: "/docs/gpu-passthrough"
|
||||
- title: "cisco-ucs-platform-benchmark (scripts and raw results)"
|
||||
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
|
||||
sections:
|
||||
- overview
|
||||
- prerequisites
|
||||
@@ -239,42 +237,46 @@ sequential repetitions per guest.
|
||||
|
||||
| Guest | 16K mixed read/write | 16K random read | 128K mixed read/write | 128K random read |
|
||||
|---|---:|---:|---:|---:|
|
||||
| GPU | 75 / 32 MiB/s | 75 MiB/s | 210 / 91 MiB/s | 332 MiB/s |
|
||||
| CPU1 | 74 / 32 MiB/s | 76 MiB/s | 218 / 94 MiB/s | 333 MiB/s |
|
||||
| CPU2 | 69 / 30 MiB/s | 107 MiB/s | 226 / 98 MiB/s | 405 MiB/s |
|
||||
| GPU | 107 / 46 MiB/s | 107 MiB/s | 232 / 101 MiB/s | 376 MiB/s |
|
||||
| CPU1 | 80 / 35 MiB/s | 80 MiB/s | 226 / 98 MiB/s | 371 MiB/s |
|
||||
| CPU2 | 79 / 34 MiB/s | 80 MiB/s | 228 / 98 MiB/s | 372 MiB/s |
|
||||
|
||||
The 128K guest random-read numbers (332–405 MiB/s) sit between the [AWS EBS gp3](https://docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html)
|
||||
baseline (125 MiB/s) and its provisioned maximum (1,000 MiB/s) — a meaningful result
|
||||
given that gp3 baseline is what most AWS operators treat as the default floor for
|
||||
general-purpose block storage. The 16K numbers (75–107 MiB/s) land near that baseline.
|
||||
The gap to the raw NVMe host performance (the [KIOXIA CD8P](https://americas.kioxia.com/en-us/business/ssd/data-center-ssd/cd8p-r.html) delivers over 4,000 MiB/s
|
||||
host-side at comparable queue depths) is not architectural — it is the current
|
||||
single-queue virtio-blk attach path and unthreaded NBD backend, both of which have
|
||||
a clear source-level fix. These numbers are the baseline to improve against in future
|
||||
releases, not the ceiling.
|
||||
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/guest-storage-throughput.svg" alt="Guest block storage throughput by block size and access pattern">
|
||||
|
||||
The 128K guest random-read numbers (371–376 MiB/s) sit between the [AWS EBS gp3](https://docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html)
|
||||
baseline (125 MiB/s) and its provisioned maximum (1,000 MiB/s). The 16K numbers (79–107 MiB/s) land near that baseline.
|
||||
|
||||
128K mixed I/O improves throughput but carries a high p99 latency envelope (roughly
|
||||
0.47–0.75 s); size queues and write patterns accordingly for latency-sensitive
|
||||
0.45–0.80 s); size queues and write patterns accordingly for latency-sensitive
|
||||
applications.
|
||||
|
||||
Predastore S3 validation used 1 GiB objects with three sequential and three
|
||||
distributed-concurrent repetitions per host:
|
||||
Predastore S3 validation used 1 GiB objects. The AWS CLI splits any download above 8 MiB into concurrent ranged GETs while a plain client issues a single streaming GET, so the sweep measures both patterns with the same client (presigned `curl`) on the host and on each guest — a single-stream PUT/GET, and a chunked/concurrent Range GET (8 MiB parts, 10 in flight) that mirrors the AWS CLI's default download path — with three repetitions each:
|
||||
|
||||
| Workload | Write, median | Read, median |
|
||||
| Client | Single-stream read | Chunked/concurrent read |
|
||||
|---|---:|---:|
|
||||
| One host client | 121.9 MiB/s | 213.2 MiB/s |
|
||||
| Three-host aggregate | 184.2 MiB/s | 381.7 MiB/s |
|
||||
| Host | 340 MiB/s | 462 MiB/s |
|
||||
| GPU guest | 399 MiB/s | 391 MiB/s |
|
||||
| CPU1 guest | 525 MiB/s | 350 MiB/s |
|
||||
| CPU2 guest | 517 MiB/s | 351 MiB/s |
|
||||
|
||||
The single-host sequential read (213 MiB/s) is a credible result for a single-node
|
||||
S3-compatible store at low-to-moderate concurrency with 1 GiB objects — throughput at
|
||||
this scale is typically network- and per-request-latency-bound rather than disk-bound,
|
||||
and 213 MiB/s uses roughly 17% of the 25GbE storage fabric. The three-node distributed read (381.7 MiB/s, 1.79× single-host) confirms
|
||||
Predastore is distributing reads across the cluster. The write scaling ratio (184.2 vs
|
||||
121.9 MiB/s, 1.51×) is narrower than read, so worth tracking as a leading indicator of
|
||||
backend contention as the cluster grows and write load increases.
|
||||
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/predastore-s3-read-by-client.svg" alt="Predastore S3 read throughput by client, single-stream versus chunked/concurrent GET">
|
||||
|
||||
All sequential S3 checksum validations passed. Full methodology, raw fio JSON and S3
|
||||
metrics are in the [benchmark repository](https://github.com/tomnewton-mulga/CISCO-refarch).
|
||||
Single-stream writes land at 282–304 MiB/s across the host and all three guests. Under three simultaneous clients — one per physical node — the cluster sustains 640 MiB/s aggregate read and 538 MiB/s aggregate write from the host, and 665 / 515 MiB/s from the guests.
|
||||
|
||||
With the client held constant, single-stream host and guest reads fall in the same 340–525 MiB/s band, and aggregate read reaches 640–665 MiB/s across three concurrent clients. The chunked/concurrent Range-GET results vary by individual client rather than tracking host-versus-guest; that variation has no confirmed explanation yet and remains under investigation. All sequential S3 checksum validations passed.
|
||||
|
||||
### 6. Network benchmark
|
||||
|
||||
Network throughput was measured with iperf3 over TCP, with a 10-second run after a 3-second warmup, five repetitions at 1, 2, 4, 8 and 16 parallel streams, on two paths: host-to-host across the dedicated 25GbE storage fabric (VLAN 1336), and guest-to-guest across the OVN overlay that connects instances.
|
||||
|
||||
| Path | Single stream | 2–16 streams |
|
||||
|---|---:|---:|
|
||||
| Host physical fabric (25GbE) | 23.4 Gbit/s | 23.5 Gbit/s |
|
||||
| Guest overlay (OVN) | 22.0 Gbit/s | 22.5 Gbit/s |
|
||||
|
||||
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/network-goodput-vs-streams.svg" alt="Network TCP goodput versus parallel stream count, host physical fabric versus guest overlay">
|
||||
|
||||
TCP goodput on a 25GbE link settles near 23.5 Gbit/s once framing and protocol overhead are removed, so the host path runs at effectively line rate with zero retransmissions. The guest overlay sustains 22.0 Gbit/s on a single stream, rising to 22.5 Gbit/s at two streams and holding flat through sixteen — 95–96% of the physical fabric across the full range.
|
||||
|
||||
## What this architecture unlocks
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ tags:
|
||||
- predastore
|
||||
- terraform
|
||||
resources:
|
||||
- title: "Benchmark and workload materials (CISCO-refarch)"
|
||||
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
|
||||
- title: "Multi-Node Install"
|
||||
url: "/docs/install-multi-node"
|
||||
- title: "VPC Networking"
|
||||
|
||||
@@ -130,15 +130,18 @@ Commonly used paths under `/latest/meta-data/`:
|
||||
| `security-groups` | Security group names, one per line |
|
||||
| `placement/availability-zone` | Availability zone |
|
||||
| `placement/region` | Region (AZ with trailing letter stripped) |
|
||||
| `services/domain`, `services/partition` | `amazonaws.com` / `aws` |
|
||||
| `services/domain`, `services/partition` | The cluster's configured services domain (default `services.internal`) / `aws` |
|
||||
| `public-keys/0/openssh-key` | Launch key pair's SSH public key; 404 if no key pair or the key was deleted |
|
||||
| `iam/info` | Instance profile ARN and ID; 404 if no profile |
|
||||
| `iam/security-credentials/<role>` | Temporary role credentials (see below) |
|
||||
| `network/interfaces/macs/<mac>/...` | Primary interface subtree: `interface-id`, `owner-id`, `subnet-id`, `vpc-id`, `local-ipv4s`, `security-group-ids`, `subnet-ipv4-cidr-block`, `vpc-ipv4-cidr-block`, and more |
|
||||
| `block-device-mapping/` | `ami` and `root` (both name the boot device) plus one `ebsN` per additional attached EBS volume, numbered from 1 in attachment order |
|
||||
|
||||
`services/domain` is the slot an AWS SDK's default endpoint resolution substitutes into `{service}.{region}.{domain}`. Real AWS varies this per partition: standard AWS returns `amazonaws.com`, China returns `amazonaws.com.cn`, and GovCloud differs again. Spinifex is effectively its own partition, so it returns its own configured domain here rather than `amazonaws.com` — an SDK left on default endpoint resolution now resolves a Spinifex-served name instead of silently reaching real AWS. `services/partition` still reports `aws`, since AWS SDKs carry hardcoded partition tables and a novel partition name would not match any of them. Note that this fixes **addressing**, not **trust**: a stock guest image does not trust the Spinifex CA, so a discovered HTTPS endpoint still needs `AWS_CA_BUNDLE` set or the CA installed.
|
||||
|
||||
The `network/interfaces/macs/` subtree covers the **primary interface only**; querying another MAC returns 404 (multi-ENI metadata is deferred).
|
||||
|
||||
Paths that intentionally return **404**: `tags/instance/*`, `block-device-mapping/*`, `placement/{group-name,partition-number,availability-zone-id,host-id}`, `instance-action`, and `spot/{instance-action,termination-time}` (404 is the correct "no interruption scheduled" answer for spot pollers).
|
||||
Paths that intentionally return **404**: `tags/instance/*`, `placement/{group-name,partition-number,availability-zone-id,host-id}`, `instance-action`, and `spot/{instance-action,termination-time}` (404 is the correct "no interruption scheduled" answer for spot pollers).
|
||||
|
||||
## User Data
|
||||
|
||||
|
||||
@@ -72,14 +72,19 @@ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
|
||||
-o awscliv2.zip
|
||||
```
|
||||
|
||||
Mirror the cloud image you intend to run as guest VMs:
|
||||
Mirror the cloud image you intend to run as guest VMs, together with the sums file it was published with. Keep the upstream filename, because the import looks the image up in the sums file by name:
|
||||
|
||||
```bash
|
||||
mkdir -p images
|
||||
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \
|
||||
-o images/debian-13-amd64.qcow2
|
||||
cd images
|
||||
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
|
||||
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS"
|
||||
sha512sum -c --ignore-missing SHA512SUMS
|
||||
cd ..
|
||||
```
|
||||
|
||||
The sums file only proves the image matches it; a sums file fetched over the same channel as the image can be tampered with alongside it. Where the vendor publishes a signature for the sums file (for example Ubuntu's `SHA256SUMS.gpg`), download it too and check it with `gpg --verify` against the vendor's signing key on this connected machine.
|
||||
|
||||
## Step 3. Assemble Transfer Media
|
||||
|
||||
```bash
|
||||
@@ -88,7 +93,7 @@ cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/
|
||||
cp setup.sh /media/spinifex-deploy/
|
||||
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
|
||||
cp awscliv2.zip /media/spinifex-deploy/aws/
|
||||
cp images/*.qcow2 /media/spinifex-deploy/images/
|
||||
cp images/*.qcow2 images/SHA512SUMS /media/spinifex-deploy/images/
|
||||
```
|
||||
|
||||
## Step 4. Install on the Air-Gapped Target
|
||||
@@ -139,13 +144,16 @@ sudo systemctl start spinifex.target
|
||||
|
||||
## Step 8. Import Cloud Images
|
||||
|
||||
Register the pre-staged image with Spinifex:
|
||||
Register the pre-staged image with Spinifex, verifying it against the sums file carried across with it:
|
||||
|
||||
```bash
|
||||
sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \
|
||||
sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64.qcow2 \
|
||||
--checksum /mnt/usb/images/SHA512SUMS \
|
||||
--distro debian --version 13 --arch x86_64 --boot-mode uefi
|
||||
```
|
||||
|
||||
A digest mismatch exits before anything is written to storage. On success the import prints the verified digest, which `spx admin images describe --image-id <ami>` shows again later.
|
||||
|
||||
## Step 9. Verify
|
||||
|
||||
```bash
|
||||
@@ -185,4 +193,4 @@ sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
|
||||
|
||||
### Image Import Fails
|
||||
|
||||
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
|
||||
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. A `checksum entry for image filename not found` error means the image was renamed after download; restore the upstream filename so it matches its line in the sums file. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
|
||||
|
||||
@@ -30,13 +30,13 @@ resources:
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Cluster sizing](#cluster-sizing)
|
||||
- [How the cluster's own state is replicated](#how-the-clusters-own-state-is-replicated)
|
||||
- [Hardware](#hardware)
|
||||
- [Network requirements](#network-requirements)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Instructions](#instructions)
|
||||
- [Verifying the cluster](#step-6-verify-the-cluster)
|
||||
- [Firewall and cluster membership](#firewall-and-cluster-membership)
|
||||
- [How multi-node storage works](#how-multi-node-storage-works)
|
||||
- [Setting up your cluster](#step-7-set-up-your-cluster)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
@@ -66,11 +66,82 @@ A Spinifex cluster distributes services across multiple servers for high availab
|
||||
| **VPC networking** — OVN | Control-plane databases run clustered, surviving the loss of any one node. |
|
||||
| **Object storage** — Predastore (S3) | Objects are erasure coded `RS(2,1)`, surviving the loss of any one node's shards. |
|
||||
| **Block storage** — Viperblock (EBS) | Volumes are stored in Predastore, so they inherit the same durability. |
|
||||
| **Cluster state** — NATS JetStream | Every key-value bucket is replicated onto all three nodes, so the control plane keeps its own state through the loss of any one. |
|
||||
|
||||
On one or two servers none of that holds. OVN runs standalone on the first node, and the storage metadata quorum has no majority to lose. If that node goes down, running instances keep full networking — but nothing can *change*: no new VPCs, no launches, no security group edits. See [OVN control plane on multi-node clusters](/docs/vpc-networking#ovn-control-plane-on-multi-node-clusters).
|
||||
|
||||
Servers beyond the third run the full set of services — storage, gateway and networking agents — and add their capacity to the pool. What they do not do is join the OVN database cluster, which stays at three members, so write latency there stays flat as the cluster grows.
|
||||
|
||||
### How the cluster's own state is replicated
|
||||
|
||||
Guest data has an obvious home: volumes live in Viperblock, objects in Predastore, both erasure coded across the cluster. The cluster's own bookkeeping has a less obvious one, and it matters just as much. Every instance record, every VPC and subnet, the server roster, IAM users and access keys, quotas and DNS records are kept in **NATS JetStream key-value buckets** — Spinifex's control-plane database, replicated across the servers you installed rather than sitting on any one of them. A cluster that has lost its state has lost the ability to describe, launch, terminate or route anything, even while every guest is still running.
|
||||
|
||||
**A bucket is replicated across the largest odd number of nodes the cluster has, up to five.** That number is decided once, by the `--nodes` count you give `spx admin init` in Step 4, and every service on every node reads it back out of `/etc/spinifex/spinifex.toml` before it creates anything.
|
||||
|
||||
| Servers | Replicas per bucket | Quorum | Survives |
|
||||
|---|---|---|---|
|
||||
| 1 | 1 | 1 | nothing — a single node is a single point of failure by definition |
|
||||
| 2 | — | — | rejected by `spx admin init`; see below |
|
||||
| 3 | 3 | 2 | any 1 node lost |
|
||||
| 4 | 3 | 2 | any 1 node lost |
|
||||
| 5 | 5 | 3 | any 2 nodes lost |
|
||||
| 10 | 5 | 3 | any 2 of the 5 nodes holding each bucket |
|
||||
|
||||
**The count is always odd, and that is why four servers replicate three ways rather than four.** Agreement is by majority, and an even number of copies does not buy you one: four copies cost a fourth write every time and still survive exactly one loss, the same as three. On four servers NATS spreads which three nodes hold each bucket, so all four carry their share and none of them is idle.
|
||||
|
||||
Five is JetStream's own ceiling on a stream's replica count, not a choice of ours. Past five servers the buckets stay at five and NATS spreads which five nodes hold each one, so the cluster keeps growing while the cost of a write does not.
|
||||
|
||||
**Two servers is refused rather than allowed with a warning.** There is no replica count that works on two: one disappears with the node holding it, and two stop accepting writes when either member goes, because neither survivor is a majority on its own. OVN and the storage metadata quorum have the same problem on two servers, so `spx admin init --nodes 2` fails and tells you to use one server or three.
|
||||
|
||||
#### Worked example — three servers
|
||||
|
||||
Say you ran Step 4 with `--nodes 3` on `node1`, `node2` and `node3`. Every bucket the cluster creates is held by all three servers, with one of them responsible for coordinating changes to it:
|
||||
|
||||
```
|
||||
KV bucket "spinifex-instance-state" KV bucket "spinifex-vpcd-reconcile"
|
||||
┌──────────┬──────────┬──────────┐ ┌──────────┬──────────┬──────────┐
|
||||
│ node1 │ node2 │ node3 │ │ node1 │ node2 │ node3 │
|
||||
│ leader │ follower │ follower │ │ follower │ leader │ follower │
|
||||
└──────────┴──────────┴──────────┘ └──────────┴──────────┴──────────┘
|
||||
quorum = 2 of 3 quorum = 2 of 3
|
||||
```
|
||||
|
||||
Three things follow from that picture, and they are the whole reason three servers is the floor:
|
||||
|
||||
- **A change is not confirmed until most of the cluster has it.** When you launch an instance, the API does not report success until at least two of the three servers have written the record to disk. Losing a server afterwards cannot lose that instance.
|
||||
- **Losing a server costs a pause, not the data.** If `node1` goes down, `node2` and `node3` are still a majority. They take over the buckets `node1` was responsible for — seconds on a healthy cluster — and the API keeps answering throughout. Nothing becomes unreadable, because nothing lived only on `node1`.
|
||||
- **The cluster keeps repairing itself.** Spinifex runs background workers that fix up VPC state, enforce quotas and publish DNS. Only one server runs each at a time, and they claim that right by writing to a bucket. If those buckets were not replicated, a server failure would stop the repair work at precisely the moment there was repair work to do.
|
||||
|
||||
Contrast that with a bucket on **one** server. It lives wherever it happened to be created, which is not recorded in any config file you can inspect. Stopping, rebooting or losing that one server makes the bucket unreachable from *every* node at once — a single-server event becomes a cluster-wide outage, on a server nobody chose.
|
||||
|
||||
#### What you see when a server fails
|
||||
|
||||
Majority is not an implementation detail. It is what stops two halves of a damaged cluster from both believing they are in charge, and it is the reason the minimum is three servers rather than two.
|
||||
|
||||
**With one server down, the cluster carries on.** `node2` and `node3` are two of three. Instances launch, VPCs change, the API answers normally, and there is nothing to do but replace the failed server. What you have lost is the *next* failure: a second server down leaves no majority and the cluster stops accepting changes until one comes back.
|
||||
|
||||
**A server that is cut off stops accepting changes, on purpose.** If `node1` is not down but isolated — a failed switch, a misconfigured port — it is one server out of three, which is not a majority, so it will not record anything. An operator working on `node1` who tries to launch an instance gets a failure rather than a success. That is the behaviour you want: if `node1` accepted changes while `node2` and `node3` were also accepting them, you would end up with two different versions of the same cluster and no way to merge them. Refusing the isolated side is what makes the surviving side worth trusting.
|
||||
|
||||
Everything else on `node1` keeps running. Guests continue to execute and their volumes keep serving. What stops is anything that has to be recorded: launching, terminating, attaching a volume, changing a security group. Storage writes fail alongside it, so a guest on the isolated server stalls rather than quietly diverging from the rest of the cluster.
|
||||
|
||||
**Be careful reading state from an isolated server.** A server that is cut off can still answer `describe-instances` from its own copy of the data — frozen at the moment it lost contact, with nothing in the answer to say so. **Run operator commands from a server you know is with the majority**, and if one server's answers disagree with its peers', believe the peers.
|
||||
|
||||
**A returning server catches up on its own.** Bring `node1` back and it synchronises from the others automatically: no command to run, no restart, no repair step. (`spx admin kv replicas --repair` exists for a different situation — a bucket configured with too few copies — not a server that is simply behind.)
|
||||
|
||||
#### Growing an existing cluster
|
||||
|
||||
Adding a fourth server does not, on its own, re-replicate the buckets that already exist — the nodes that hold them are perfectly healthy, so nothing prompts a change. Spinifex raises them for you at the next service start: each service checks every bucket it opens against the cluster's current node count and raises any that are short. It never lowers one.
|
||||
|
||||
**A config that names a node before that node is serving is safe.** It is the ordinary middle of growing a cluster, and services start normally through it: a bucket that cannot be raised yet keeps working at the count it has, and the node logs `Could not raise KV bucket to the cluster's replica count`. The raise is retried every time a service opens the bucket, so it completes on its own once the new server is in the cluster.
|
||||
|
||||
To do it immediately rather than waiting for a restart, run the audit with `--repair` from any node:
|
||||
|
||||
```bash
|
||||
sudo spx admin kv replicas --repair
|
||||
```
|
||||
|
||||
It changes nothing about a bucket except the replica count, never lowers one, and is safe to run twice or on a healthy cluster.
|
||||
|
||||
### Hardware
|
||||
|
||||
Per server, for the three-node minimum:
|
||||
@@ -103,14 +174,14 @@ Open between the three OVN database servers (servers 1 to 3) only:
|
||||
|---|---|
|
||||
| TCP 6643, 6644 | OVN database clustering |
|
||||
|
||||
Predastore uses the same three ports on every server, so the surface does not widen as the cluster grows. See [How multi-node storage works](#how-multi-node-storage-works).
|
||||
Predastore uses the same three ports on every server, so the surface does not widen as the cluster grows. See [Predastore Distributed Storage](/docs/predastore-distributed-storage).
|
||||
|
||||
Those are the ports your **network** has to permit between the servers — switches, upstream firewalls, and anything else in the path.
|
||||
|
||||
Spinifex's own **host** firewall is separate, and already carries this policy. It ships armed on the ISO path and off on the binary installer path. Two things follow from that, both of which the ISO box in Step 2 acts on:
|
||||
Spinifex's own **host** firewall is separate, and already carries this policy — see [Host Firewall](/docs/host-firewall). It ships armed on the ISO path and off on the binary installer path. Two things follow from that, both of which the ISO box in Step 2 acts on:
|
||||
|
||||
- **The formation port needs nothing from you.** `spx admin init` opens 4432 to any source for the length of the formation window and closes it again afterwards, because a node dialling in to join is not a peer yet. The handshake behind it is TLS 1.3 with a bearer token.
|
||||
- **The rest of the cluster plane is peer-scoped**, and that includes the OVN database ports Step 3 uses. Nodes that do not yet know each other cannot reach them, which is why an ISO-installed node has its firewall taken down before Step 3 and re-armed after Step 6.
|
||||
- **The rest of the cluster plane is peer-scoped**, and that includes the OVN database ports Step 3 uses. Nodes that do not yet know each other cannot reach them, which is why an ISO-installed node has its firewall taken down before Step 3 and re-armed once the cluster is verified.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -172,7 +243,7 @@ Adding a fourth server or more? Export `SPINIFEX_NODE4` and so on alongside thes
|
||||
>
|
||||
> Each node's firewall currently trusts only itself, because that is the whole cluster as far as it knows, and the cluster plane is peer-scoped — so the OVN database connections Step 3 makes between servers are dropped. Stopping `spinifex.target` is not enough: the firewall lives in the kernel and outlives the services.
|
||||
>
|
||||
> Turn it back on after Step 6 — see [Firewall and cluster membership](#firewall-and-cluster-membership).
|
||||
> Turn it back on once the cluster is verified — see [Host Firewall](/docs/host-firewall).
|
||||
|
||||
## Step 3. Set Up OVN Networking
|
||||
|
||||
@@ -253,6 +324,8 @@ sudo spx admin init --force \
|
||||
|
||||
`--nodes 3` is the number of servers init waits for. Set it to your total node count if you are building a larger cluster.
|
||||
|
||||
It is also what decides how many nodes the cluster's JetStream state is replicated across, so it is worth getting right rather than raising later — see [How the cluster's own state is replicated](#how-the-clusters-own-state-is-replicated). Every joining server takes the count from server 1, so it is chosen once, here. `--nodes 2` is refused: use 1 for a single server, or 3 and up for a cluster that can survive losing one.
|
||||
|
||||
IPsec encrypts the Geneve overlay between servers and is on by default. Joining servers take the setting from server 1, so it is chosen once, on init. On servers that share a trusted private link, `--ipsec=false` leaves the overlay unencrypted in exchange for considerably higher throughput between instances.
|
||||
|
||||
The init output displays the join command including the token:
|
||||
@@ -361,7 +434,40 @@ The top table is per-node CPU, memory and GPU usage. The bottom table is what th
|
||||
|
||||
If capacity looks like a single server rather than the sum of your nodes, the others have not joined.
|
||||
|
||||
**3. The AWS API answers.**
|
||||
**3. The cluster's own state is replicated across every node.**
|
||||
|
||||
```bash
|
||||
sudo spx admin kv replicas
|
||||
```
|
||||
|
||||
```
|
||||
spinifex@node1:~$ sudo spx admin kv replicas
|
||||
BUCKET REPLICAS WANT ONLINE STATUS LEADER HELD BY
|
||||
spinifex-cluster-state 3 3 3 ok spinifex-nats-node1 spinifex-nats-node1,spinifex-nats-node2,spinifex-nats-node3
|
||||
spinifex-dns-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node1,spinifex-nats-node3
|
||||
spinifex-iam 3 3 3 ok spinifex-nats-node3 spinifex-nats-node3,spinifex-nats-node1,spinifex-nats-node2
|
||||
spinifex-instance-state 3 3 3 ok spinifex-nats-node1 spinifex-nats-node1,spinifex-nats-node2,spinifex-nats-node3
|
||||
spinifex-quota-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node3,spinifex-nats-node1
|
||||
spinifex-terminated-instances 3 3 3 ok spinifex-nats-node3 spinifex-nats-node3,spinifex-nats-node2,spinifex-nats-node1
|
||||
spinifex-vpcd-reconcile 3 3 3 ok spinifex-nats-node2 spinifex-nats-node2,spinifex-nats-node1,spinifex-nats-node3
|
||||
|
||||
47 buckets, 0 under-replicated, 0 without quorum
|
||||
```
|
||||
|
||||
Server names are the node name behind a `spinifex-nats-` prefix, which is what NATS itself calls each server.
|
||||
|
||||
What to check:
|
||||
|
||||
- **`0 under-replicated, 0 without quorum`.** These are different problems. Under-replicated means a bucket lives on fewer nodes than depend on it — see [Buckets report as under-replicated](#buckets-report-as-under-replicated), and `--repair` is the fix. Without quorum means a bucket is not able to accept a write right now, whatever its replica count says, and no repair helps until you find out why a node is not answering.
|
||||
- **`WANT` is the largest odd number of nodes you have**, capped at five — 3 on three or four servers, 5 on five or more. If it says 1 on a three-server cluster, the node the command ran on is not configured for three: check `--nodes` in Step 4 and the `[nodes.*]` sections of that host's `/etc/spinifex/spinifex.toml`.
|
||||
- **`ONLINE` equals `REPLICAS`.** It counts the replicas that are caught up and reachable, so a bucket at `3` replicas with `ONLINE 2` is one node away from being unavailable and needs looking at before anything else is done to the cluster.
|
||||
- **`LEADER` is spread across the cluster** rather than parked on one node, and `HELD BY` names as many distinct servers as `REPLICAS`.
|
||||
|
||||
Exit status is what a deployment gate should read rather than the output: `0` healthy, `1` something is under-replicated, `2` something has no quorum or the report could not be produced at all.
|
||||
|
||||
Run it on every node, not just one. It reads cluster-global Raft metadata, so all of them should give the same answer; one that disagrees is not fully part of the cluster.
|
||||
|
||||
**4. The AWS API answers.**
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
@@ -370,117 +476,13 @@ aws ec2 describe-instance-types
|
||||
|
||||
A list of instance types means the gateway, IAM and the cluster behind them are all working.
|
||||
|
||||
**4. Every server holds the same cluster state.**
|
||||
**Congratulations! Your multi-server Spinifex cluster is installed.**
|
||||
|
||||
Services report Ready even over a cluster whose JetStream replicas disagree, so check the replicas themselves. On **every server**, take a digest of its local store:
|
||||
## Step 7. Set Up Your Cluster
|
||||
|
||||
```bash
|
||||
sudo spx admin kv digest --json --seqs > kv-digest-$(hostname).json
|
||||
```
|
||||
The cluster is running, but it holds nothing yet — no machine images, no networks, no instances.
|
||||
|
||||
Copy the files to one server and compare them:
|
||||
|
||||
```bash
|
||||
spx admin kv compare kv-digest-*.json
|
||||
```
|
||||
|
||||
```
|
||||
compared 3 digests: node1@10:02:11 node2@10:02:14 node3@10:02:18
|
||||
ok KV_spinifex-iam-users [node1,node2,node3]
|
||||
identical, seqs 1-14
|
||||
...
|
||||
42 streams: 42 consistent, 0 diverged
|
||||
```
|
||||
|
||||
The last line must report `0 diverged`, and the command exits non-zero otherwise. The digests are copied from live stores a few seconds apart, so a write landing between two copies can briefly show as a difference: take fresh digests and compare again before acting on one. A difference that persists means the cluster adopted a server's old store as a replica. Do not put that cluster in service — reset the servers and form it again.
|
||||
|
||||
A replica that is merely behind is not reported, and neither is a message that one server has already replaced with a newer write to the same key. `spx admin kv compare --help` lists exactly what counts as divergence.
|
||||
|
||||
**Congratulations! Your Spinifex cluster is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Firewall and Cluster Membership
|
||||
|
||||
Spinifex ships an optional host firewall. It divides the node's ports into two groups:
|
||||
|
||||
| Group | Ports | Who can reach them |
|
||||
|---|---|---|
|
||||
| **Public** | SSH, 443, 3000 (console), 8443 (S3), 9999 (AWS gateway), 53 (DNS) | anyone |
|
||||
| **Internal** | OVN, NATS, formation, Geneve and the rest of the cluster plane | **cluster members only** |
|
||||
|
||||
The internal group is the point. Before this existed, OVN and NATS were reachable from the public internet on a WAN-facing node.
|
||||
|
||||
"Cluster members" is not a list you maintain. Each node works it out from the cluster it belongs to and rewrites its own rules whenever membership changes — you never edit the peer list by hand.
|
||||
|
||||
### Is it on?
|
||||
|
||||
| How the node was installed | Firewall |
|
||||
|---|---|
|
||||
| From the ISO | **on** |
|
||||
| Binary installer (`curl \| bash`) or `setup.sh` | **off** |
|
||||
| `setup.sh --firewall=on` | **on** |
|
||||
|
||||
The binary installer defaults to off deliberately: it runs on servers that already have an operating system and services on them, and switching on a default-deny policy uninvited could cut off something Spinifex knows nothing about. **For production, turn it on** — either at install time:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on
|
||||
```
|
||||
|
||||
or afterwards, by setting it in `/etc/spinifex/spinifex.toml` and restarting the daemon:
|
||||
|
||||
```toml
|
||||
[network]
|
||||
firewall_enabled = true
|
||||
```
|
||||
|
||||
Before you do, check what else the machine is serving. Anything listening on a port outside the public group above stops accepting new connections.
|
||||
|
||||
### Turning it off and on around cluster changes
|
||||
|
||||
A node only recognises the members of the cluster it currently belongs to, so during formation — when the nodes do not yet know each other — internal traffic between them is blocked. Turn the firewall off while you form the cluster, and on again once it is up:
|
||||
|
||||
```bash
|
||||
# Off — before forming or expanding a cluster. Run on every node.
|
||||
sudo /usr/local/lib/spinifex/spinifex-firewall-apply disable
|
||||
|
||||
# On — once the cluster is formed and verified. Run on every node.
|
||||
sudo systemctl restart spinifex-daemon
|
||||
```
|
||||
|
||||
Restarting the daemon is what re-arms it: the node rebuilds its peer list from the cluster it is now part of, reloads the rules, and re-enables the boot-time unit so the policy survives a reboot. It also happens on its own within five minutes if you would rather wait.
|
||||
|
||||
### Checking it
|
||||
|
||||
```bash
|
||||
sudo nft list table inet spinifex_filter
|
||||
```
|
||||
|
||||
The peer list is an nft variable, expanded when the rules load, so it does not appear under a name of its own. Look instead at the `ip saddr { ... }` addresses on the cluster-plane rules — the ones accepting 4222, 6641, 6642 and the rest.
|
||||
|
||||
Every node's addresses should be there. On a multi-NIC node that means its WAN, LAN and VPC addresses, so expect several entries per node. A missing node means its cluster traffic is being dropped.
|
||||
|
||||
Dropped packets are logged, rate-limited, so this tells you whether a connection problem is the firewall or something else:
|
||||
|
||||
```bash
|
||||
sudo journalctl -k | grep 'spinifex-fw drop'
|
||||
```
|
||||
|
||||
## How Multi-Node Storage Works
|
||||
|
||||
Background reading — you do not configure any of this by hand. `spx admin init` and `spx admin join` build the topology from the servers that actually form the cluster in Step 4, and each machine gets the same file with its own host ID recorded in `spinifex.toml`.
|
||||
|
||||
Predastore is configured for the whole cluster in `/etc/spinifex/predastore/predastore.toml`. Each server is one `[[host]]` — a single Predastore process owning that machine's data directory and TLS identity — carrying three nodes under `[[host.node]]`:
|
||||
|
||||
| Role | Port | Purpose |
|
||||
|---|---|---|
|
||||
| `gate` | TCP 8443 | Serves the S3 API. Every server runs one, so any of them answers an S3 request. |
|
||||
| `blob` | UDP 6660 | Holds erasure-coded object shards. One per machine. |
|
||||
| `meta` | UDP 7660 | Member of the Raft quorum over global state — buckets and the object index. |
|
||||
|
||||
Ports have to be unique within a host but not across the cluster, so every machine uses the same three. Blob and meta traffic between hosts runs over QUIC, authenticated by the cluster CA; nodes on the same machine talk over an in-process pipe and open no socket, which is why a single-server install listens on 8443 alone.
|
||||
|
||||
Reed-Solomon parameters are chosen from the cluster size, since each machine contributes exactly one blob node: two servers get `RS(1,1)`, three or more get `RS(2,1)`. `RS(2,1)` survives the loss of any one server's shards — another reason three nodes is the recommended minimum.
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance. It ends by arming the [host firewall](/docs/host-firewall), which is also where you re-arm it if you turned it off to form the cluster.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -500,7 +502,7 @@ If it hangs, check node 1's init output before blaming the firewall — `spx adm
|
||||
sudo journalctl -k | grep 'spinifex-fw drop'
|
||||
```
|
||||
|
||||
Turn the firewall off on **every** node and retry the join, then re-arm once the cluster is up — see [Firewall and cluster membership](#firewall-and-cluster-membership). The joining node retries for 20 minutes by default, so it is often still waiting while you fix this.
|
||||
Turn the firewall off on **every** node and retry the join, then re-arm once the cluster is up — see [Host Firewall](/docs/host-firewall). The joining node retries for 20 minutes by default, so it is often still waiting while you fix this.
|
||||
|
||||
### Join Refuses: "this node is already initialized"
|
||||
|
||||
@@ -508,6 +510,28 @@ The node has its own cluster configuration — normal for anything installed fro
|
||||
|
||||
Safe on a freshly installed node. On one that has been in service it orphans every volume and fragment sealed under the old key, so check before forcing.
|
||||
|
||||
### Buckets Report as Under-Replicated
|
||||
|
||||
```bash
|
||||
sudo spx admin kv replicas
|
||||
```
|
||||
|
||||
A bucket showing `UNDER-REPLICATED` lives on fewer nodes than the cluster has, so losing the node holding it takes it down for the whole cluster. Two things put a bucket in that state, and the fix is the same for both:
|
||||
|
||||
```bash
|
||||
sudo spx admin kv replicas --repair
|
||||
```
|
||||
|
||||
The first cause is **a cluster that grew**: buckets created when there were three servers stay on three replicas until something raises them. Services do this themselves as they restart, and `--repair` does it now.
|
||||
|
||||
The second is **a node that was down when the bucket was created**, so the Raft group formed short. Bring the node back first — `--repair` on an incomplete cluster raises the configured count, but the group cannot place the extra replica until the node is there to hold it. Confirm with `spx get nodes` before repairing.
|
||||
|
||||
If `WANT` itself is wrong — 1 on a three-server cluster — the problem is not the buckets but that host's view of the cluster. Check it has all three `[nodes.*]` sections:
|
||||
|
||||
```bash
|
||||
sudo grep -oP '^\[nodes\.\K[^.\]]+' /etc/spinifex/spinifex.toml | sort -u
|
||||
```
|
||||
|
||||
### OVN Database Cluster Not Forming
|
||||
|
||||
```bash
|
||||
|
||||
@@ -183,7 +183,7 @@ The node rewrites its peer list from the cluster it is now part of and re-arms i
|
||||
sudo nft list table inet spinifex_filter
|
||||
```
|
||||
|
||||
Check the `ip saddr { ... }` addresses on the cluster-plane rules — the peer list is an nft variable expanded at load time, so it has no name of its own in the output. The addresses of **all** your servers should appear. If one is missing, that server's cluster traffic will be blocked — see [Firewall and cluster membership](/docs/install-multi-node#firewall-and-cluster-membership).
|
||||
Check the `ip saddr { ... }` addresses on the cluster-plane rules — the peer list is an nft variable expanded at load time, so it has no name of its own in the output. The addresses of **all** your servers should appear. If one is missing, that server's cluster traffic will be blocked — see [Host Firewall](/docs/host-firewall).
|
||||
|
||||
### Setup Complete
|
||||
|
||||
|
||||
@@ -122,7 +122,11 @@ If this returns a list of available instance types, your installation is working
|
||||
|
||||
**Congratulations! Spinifex is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
## Step 6. Set Up Your Cluster
|
||||
|
||||
Spinifex is running, but it holds nothing yet — no machine images, no networks, no instances.
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -74,10 +74,8 @@ provider "aws" {
|
||||
s3 = "https://localhost:8443"
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,16 +89,34 @@ aws s3 sync s3://local-bucket/ s3://cloud-bucket/ --source-region spinifex --reg
|
||||
|
||||
## Terraform Provider Errors
|
||||
|
||||
Ensure all four skip flags are set in your provider configuration:
|
||||
Ensure these two skip flags are set in your provider configuration:
|
||||
|
||||
```hcl
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
```
|
||||
|
||||
Without these, Terraform will try to validate credentials and metadata against real AWS endpoints.
|
||||
Without them, Terraform queries the EC2 instance metadata service for credentials and rejects a region name AWS does not know.
|
||||
|
||||
Leave `skip_credentials_validation` and `skip_requesting_account_id` unset, and point the `sts` endpoint at Spinifex. The provider then learns the account ID from `GetCallerIdentity` as it does on AWS. With the account ID blank, a security group rule that references a group in the same account reads back as `<account>/sg-...` and never reaches a clean plan.
|
||||
|
||||
### Tagged S3 buckets need both flags set
|
||||
|
||||
There is one exception, and it applies to a provider block that manages an `aws_s3_bucket` with tags. AWS provider 6.x reads bucket tags through S3 Control, whose endpoint puts the account ID in the hostname — so the provider dials `https://000000000001.<your-endpoint>`, which cannot resolve when your endpoint is an IP address:
|
||||
|
||||
```
|
||||
Error: listing tags for S3 (Simple Storage) Bucket (uploads): operation error S3 Control:
|
||||
ListTagsForResource, ... dial tcp: lookup 000000000001.10.0.0.10: no such host
|
||||
```
|
||||
|
||||
Set both flags in that provider block. With no account ID the provider reads tags through S3 `GetBucketTagging` instead, which Spinifex serves:
|
||||
|
||||
```hcl
|
||||
skip_requesting_account_id = true
|
||||
skip_credentials_validation = true
|
||||
```
|
||||
|
||||
Both are needed — `skip_requesting_account_id` on its own has no effect, because the credentials check calls `sts:GetCallerIdentity` and supplies the account ID before the account lookup is ever skipped. If the same configuration also needs the account ID, for `aws_ecr_repository` or a security group rule that references another group, put the bucket in a second, aliased provider block rather than setting the flags globally.
|
||||
|
||||
## S3 Signature Errors
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
|
||||
|
||||
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
|
||||
|
||||
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
|
||||
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally with their sums files and use `spx admin images import --file <image> --checksum <sums-file>` with the pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
|
||||
|
||||
## 3. Cross-Node (Internal) Connections
|
||||
|
||||
@@ -226,7 +226,7 @@ Notes that make the difference between this working and locking you out:
|
||||
- **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute.
|
||||
- **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there.
|
||||
|
||||
Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file`.
|
||||
Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file <image> --checksum <sums-file>`.
|
||||
|
||||
## 5. Configuration Surface
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ services:
|
||||
# Cluster coordination subjects.
|
||||
- "ec2.ReserveClusterNode"
|
||||
- "ec2.FinalizeClusterInstances"
|
||||
# An EIP association reaches whichever node holds the instance, which is
|
||||
# rarely the node that served the request (fan-out, no queue group).
|
||||
- "ec2.eni.public-ip-changed"
|
||||
- "spinifex.node.status"
|
||||
- "spinifex.node.vms"
|
||||
- "spinifex.nodes.discover"
|
||||
@@ -168,6 +171,8 @@ services:
|
||||
- "ebs.config.*"
|
||||
# → predastore (S3 / AMI / keypair store)
|
||||
- "s3.*"
|
||||
# → every daemon, so the one holding the instance reports the new address
|
||||
- "ec2.eni.public-ip-changed"
|
||||
# IAM lifecycle event
|
||||
- "iam.account.created"
|
||||
# → the in-guest rds-agent: set-password and the like, published on the
|
||||
|
||||
@@ -30,15 +30,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
version = "6.66.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +76,8 @@ provider "aws" {
|
||||
sts = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,15 +32,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,10 +114,8 @@ provider "aws" {
|
||||
elasticloadbalancingv2 = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,11 +30,11 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,8 @@ provider "aws" {
|
||||
acm = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
|
||||
@@ -25,11 +25,11 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = ">= 2.20"
|
||||
version = "3.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,10 +80,8 @@ provider "aws" {
|
||||
eks = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "infra" {
|
||||
|
||||
@@ -33,11 +33,11 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,8 @@ provider "aws" {
|
||||
acm = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
|
||||
@@ -22,11 +22,11 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = ">= 2.20"
|
||||
version = "3.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,8 @@ provider "aws" {
|
||||
eks = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# Cluster identity comes from the parent module's state; the live endpoint/CA
|
||||
|
||||
@@ -29,7 +29,7 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,8 @@ provider "aws" {
|
||||
ecr = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,11 +17,11 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
version = ">= 2.20"
|
||||
version = "3.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,10 +54,8 @@ provider "aws" {
|
||||
eks = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# Cluster identity comes from the parent module's state; the live endpoint/CA
|
||||
|
||||
@@ -35,15 +35,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.65.0, < 5.66.0"
|
||||
version = "5.65.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ variable "region" {
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
type = string
|
||||
default = "t3.small"
|
||||
}
|
||||
|
||||
@@ -82,10 +82,8 @@ provider "aws" {
|
||||
elasticloadbalancingv2 = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,15 +19,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
version = "6.66.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,8 @@ provider "aws" {
|
||||
sts = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,15 +38,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.40, < 6.0"
|
||||
version = "5.100.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,8 @@ provider "aws" {
|
||||
rds = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -139,6 +139,12 @@ curl -k https://localhost:8443/
|
||||
aws s3 ls --profile spinifex --endpoint-url https://localhost:8443
|
||||
```
|
||||
|
||||
### `ListTagsForResource ... no such host` After the Bucket Is Created
|
||||
|
||||
The bucket was created and the failure is on the read-back of its tags. AWS provider 6.x reads bucket tags through S3 Control, whose endpoint carries the account ID in the hostname, so the provider dials `https://000000000001.<your-endpoint>` — a name that cannot resolve against an IP address.
|
||||
|
||||
This workbook's provider block already sets `skip_requesting_account_id` and `skip_credentials_validation`, which is what keeps the provider on S3 `GetBucketTagging`. If you copied the block into your own configuration, carry both across: `skip_requesting_account_id` on its own has no effect, because the credentials check supplies the account ID before the account lookup is skipped.
|
||||
|
||||
### Flask App Not Starting
|
||||
|
||||
SSH into the instance and check the service:
|
||||
|
||||
@@ -46,15 +46,15 @@ terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
version = "6.66.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
version = "4.4.1"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
version = "2.9.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,14 @@ provider "aws" {
|
||||
|
||||
s3_use_path_style = true
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
|
||||
# Both, not either: an account ID sends the bucket tag read to S3 Control at
|
||||
# https://{account}.{endpoint}, which cannot resolve against an IP, and the
|
||||
# credentials check supplies one even when the account lookup is skipped.
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
skip_credentials_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -164,6 +168,15 @@ data "aws_ami" "ubuntu" {
|
||||
|
||||
resource "aws_s3_bucket" "uploads" {
|
||||
bucket = var.bucket_name
|
||||
|
||||
# The webapp uploads into this bucket, so a destroy of a bucket that has been
|
||||
# used fails BucketNotEmpty without this.
|
||||
force_destroy = true
|
||||
|
||||
tags = {
|
||||
Name = var.bucket_name
|
||||
Example = "s3-webapp"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# What each E2E suite proves
|
||||
|
||||
**This index exists to answer "does anything already test that?" before someone reasons it out from the code.** Comments describe intent when written and can go stale; these suites run against a live cluster and are the current answer on behaviour.
|
||||
|
||||
Suites live in `tests/e2e/<name>/` and build to `tests/e2e/_bin/<name>.test`. Every file carries `//go:build e2e`, so a default build skips them. The sets that decide what runs where are in `.github/scripts/suite-sets.sh`, and the nightly permutation matrix is `.github/e2e-cells.json`.
|
||||
|
||||
| Set | Suites |
|
||||
| --- | --- |
|
||||
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota storagefault` |
|
||||
| `E2E_SUITES_MULTI` | `multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault` |
|
||||
| `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` |
|
||||
| `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` |
|
||||
|
||||
The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. `eks`, `ecs`, `rds`, `storagefault`, `instancerecovery`, `lbrecovery` and `kvquorum` each get a dedicated cell instead.
|
||||
|
||||
**`multinode` carries the KV replication sweep as well**, which is why the thing `kvquorum` proves is still checked in every multi-node permutation cell even though the suite itself is not. The read-only half costs a NATS connection and a stream listing; only the node-loss half needs a cell of its own.
|
||||
|
||||
## The suites
|
||||
|
||||
### `single` — the broad single-node EC2/EBS/VPC surface
|
||||
|
||||
The largest suite, 25 top-level tests. Notable entry points:
|
||||
|
||||
- **`TestVPCEgressPaths`** — **the authority on subnet egress semantics.** One VPC with a public and a private subnet, sharing two guests across four stages: `PublicSubnetEgress` (IGW egress works), `RouteBeforeSubnet` (a regression guard for an IGW route installed on the main route table before the subnet exists), the NAT Gateway rounds, and `EIPFlip` (associating an Elastic IP onto a running instance).
|
||||
- **`NATBaseline` hard-fails if a private-subnet guest reaches the internet with no NAT Gateway.** `NATGatewayUp` then proves egress appears and `NATGatewayDown` that it disappears. `SPINIFEX_VPCEGRESS_NAT_ROUNDS` repeats the up/down cycle for a soak run.
|
||||
- **This is AWS parity for private subnets.** It ran in pool mode only until 2026-09-26: the fixture gated it on `external_mode = "pool"`, on the stale premise that nat clusters have no EIPs. They do when a public pool is configured, so the gate is now "can this cluster allocate public addresses" and the nat-single cell runs this test beside `natuplink`, which never creates a private subnet.
|
||||
- `TestSGPolicyDatapath`, `TestSGReachabilityPolicy` — security groups as an actual datapath, not just API state.
|
||||
- `TestWANEgressSMTPBlock` — egress port blocking.
|
||||
- `TestInstanceMetadata` — IMDS from inside a guest.
|
||||
- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`.
|
||||
- `TestNegativeErrorPaths` — the `8a`–`8h` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, and so on).
|
||||
- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across all three host and network permutations.
|
||||
|
||||
### `natuplink` — routed NAT (`external_mode = "nat"`), one node or three
|
||||
|
||||
Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips unless `spinifex.toml` says `external_mode = "nat"`. Nine sequential phases: host wiring (transit veth, `ip_forward`, NAT egress rules), config, the EIP surface (enabled or disabled depending on whether a public pool exists), default-subnet public IP mapping, OVN gateway and SNAT on the transit net, instance egress **proven via the serial console**, host-to-guest Tier 1 ingress, a second VPC getting a unique transit gateway IP, and the EIP ingress lifecycle.
|
||||
|
||||
**Phase 9 has two shapes.** On one node it is the EIP lifecycle: allocate, associate, host delivery plumbing, a TCP handshake to the address, a vpcd restart that must replay the plumbing, and disassociate tearing it down. On a cluster it is the distributed lane instead — a guest on every node, each public IP plumbed by the node running its guest and by **no other**, every transit veth carrying the one cluster-wide MAC, each `dnat_and_snat` row carrying `external_mac` and `logical_port`, inbound proven by a handshake and outbound by each guest's own console. **That lane is the regression guard for the two defects that made routed NAT work on one node only**, and neither is visible to a single-node run.
|
||||
|
||||
Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.
|
||||
|
||||
Two nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
|
||||
|
||||
### `multinode` — behaviour that only exists on more than one node
|
||||
|
||||
`VPCSetup`, `SpansMultipleNodes`, `SpreadPlacement`, `EveryRunningInstanceReported`, `BastionSSH`, and its own NAT Gateway lane: `PreNATIsolation`, `NATGatewayInternet`, `NATCleanupOrdering`.
|
||||
|
||||
**`TestMultinodeJetStreamReplicas` is a whole-cluster sweep, not a named list, and it lives here on purpose.** It audits every KV bucket the cluster has and fails any that is replicated across fewer nodes than the cluster has, capped at JetStream's ceiling of five. Naming buckets would only ever cover the ones somebody remembered — a replica count is set where a bucket is created, and there is a bucket-creating call in most services — so the sweep is the assertion and the three daemon-owned buckets are named only so that a run enumerating nothing still fails. Because `multinode` is in `E2E_SUITES_NIGHTLY_MULTI`, this runs in every multi-node permutation cell, and no cell can be added that quietly opts out of it.
|
||||
|
||||
### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three)
|
||||
|
||||
**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do.
|
||||
|
||||
Three entry points, in the order they run:
|
||||
|
||||
- **`TestKVBucketsAreReplicatedAcrossTheCluster`** is read-only and runs first, so a cluster that is already wrong is reported as wrong rather than as a failure to survive a node loss. It sweeps every bucket, then separately requires the leader-lease buckets to be among those checked — they were the worst case of the defect, and a regression reaching only them would otherwise be one line in a long list rather than the headline.
|
||||
- **`TestKVReplicasCommandReportsHealthy`** runs `spx admin kv replicas --json` on **every** node, not one. The command reads cluster-global Raft metadata, so all nodes must give the same answer; a node that disagrees is reporting on a cluster it is not fully part of, which is worth knowing before a deployment is gated on its exit status.
|
||||
- **`TestKVBucketsSurviveANodeLoss`** produces the fault rather than inspecting for it. It takes away **the node leading the most buckets** — the worst one to lose, and the one a test picking arbitrarily would usually miss — then requires every survivor to still answer for every bucket, brings the node back, and requires it to carry its share again.
|
||||
|
||||
**Configured, placed and working replicas are three different facts, and all three are checked.** A stream can carry the right replica count while its Raft group is short of peers, or hold the right peers while one of them is offline or still catching up, and either reads as healthy right up until the node it is really on goes away. The audit reports the leader and a count of peers NATS itself calls current and reachable, and the assertion is against that count rather than against the configured one. The last assertion is that no bucket came back with *fewer* replicas than it had: a cluster that "repaired" itself by lowering a count would pass every other assertion here and have quietly traded the durability this suite is about for a green run.
|
||||
|
||||
**Its own suite and its own cell, for lbrecovery's reasons.** It takes a node away, so anything sharing the environment reports that outage as its own failure, and the question it answers — "is our internal state actually replicated" — has to read as itself rather than as one more red subtest somewhere else. Three nodes is the floor: on two, a majority is two, so losing either leaves no quorum for any stream and there is nothing to assert beyond "Raft needs a majority", which is true of NATS and not a property of ours.
|
||||
|
||||
### `instancerecovery` — a guest whose node goes away (multi-node)
|
||||
|
||||
**Asserted from both sides, and the pair is the point.** `InstanceAutoRecovery` takes a node down and requires the guest to come back on exactly one survivor, identified by the qemu process rather than by what the API says, then brings the node back and requires it to drop the local copy instead of relaunching it. `InstanceRecoveryRefusesStorageFault` freezes predastore cluster-wide first, so the guest is paused by `werror=stop` when its node goes away, and requires that **nothing moves** — a store that refuses every node cannot be fixed by moving a guest, and the relaunch would cost it the request QEMU is holding. A reconciler that cannot tell a host failure from a storage failure passes the first and fails the second. Neither turns recovery on, because there is no setting for it: both wait for every node to report the reconciler active on a cluster nothing configured, so a build that reintroduced a switch fails here.
|
||||
|
||||
**`InstancePartitionRecovery` is the fault this feature exists for, and the only one of the three that produces it rather than approximating it.** It drops NATS on 4222 and 4248 between one node and its peers and nothing else, so that node keeps its services, keeps its QEMU and keeps answering its own local clients while it cannot reach consensus — half healthy, which is what a real network fault looks like and what a service stop cannot imitate. It then requires that the node gives up the volume on a clock it can evaluate without reaching anything, that a survivor takes the guest over, and that the rejoining node forgets its copy rather than relaunching it. **The assertion it exists for is the one no single sample can reach:** a watch samples every node every three seconds for the whole run and fails on any sample naming two hosts, because two guests writing one volume through one object store is data loss already under way rather than a race that resolves. The evidence for *why* the guest stopped is the surrender and the fence in the node's own journal — a guest killed for any other reason satisfies the process count and means the opposite.
|
||||
|
||||
**The partition gets its own nft table and a systemd deadman timer, for reasons worth keeping.** The node's real firewall is `inet spinifex_filter`, so a test that flushed or edited it would leave the node unfirewalled with no sign of which rules it lost; a separate table at priority -300 says exactly "these packets, dropped" and heals in one atomic delete. `systemd-run --on-active` removes it independently, so a cancelled workflow or a panicking test does not leave a node cut off from its cluster until somebody notices. It also proves SSH still works and proves a peer port is actually unreachable before asserting anything — a rule set that installed cleanly and dropped nothing would otherwise read as the system tolerating a partition.
|
||||
|
||||
**The storage-fault victim launches with cloud-init user-data that writes continuously, and that is load bearing.** An idle guest issues no I/O that reaches the object store, so a version of this test without a workload left three guests running happily through twelve minutes of dead predastore and proved nothing. Driving the load from user-data rather than over SSH is what lets it run on a routed-NAT cell, where guests have no public address. The pause itself takes minutes — 4m47s when measured — so the budget is generous on purpose.
|
||||
|
||||
**Its own suite and its own nightly cell, for storagefault's reasons.** It freezes a cluster-wide service and removes a node, so anything sharing the environment reports that outage as its own failure; and it is slow by nature, which is why it is not in a permutation cell budgeted at thirty-five minutes. Both behaviours were first proven by hand against a real hypervisor power-off on the three-node OCI cluster.
|
||||
|
||||
### `lbrecovery` — a load balancer whose node goes away (multi-node, needs three)
|
||||
|
||||
**The customer-visible half of host failure, asserted from outside the cluster rather than from the records.** `ALBSurvivesItsHostFailing` stands up an internet-facing ALB with two HTTP backends, discovers which node the load balancer's guest landed on, and takes that node away. It then requires the four things a customer of a load balancer actually holds: the name keeps resolving, the address behind it does not move, requests through the name are answered again within a bounded time, and the backends — which never moved — are healthy again from the recovered load balancer's point of view.
|
||||
|
||||
**The backends are placed around the load balancer, not beside it.** A load balancer is launched over a queue group and lands wherever a node answers, so its node is discovered and the backends are then launched and relaunched until both are somewhere else. A backend on the node about to be taken away would be recovered too, and the run could not then tell an ALB that came back serving from one that came back with nothing to serve.
|
||||
|
||||
**DNS is queried against a node's own northstar, not through the runner's resolver.** Go's resolver dials port 53 on a named node — which is open on every node deliberately, because northstar serves public names — so the assertion is about what this cluster answers rather than about what the machine running the tests was configured to forward to. The same resolver drives the HTTP probes, so traffic goes through the load balancer's name end to end and not through an address resolved once at the start. `dig` is deliberately not used: it is proven present in the guest image and nowhere else.
|
||||
|
||||
**The assertion no single sample can reach is the name.** A watch queries every surviving node every five seconds for the whole run, and fails on any sample where the record is missing, where a node refuses the query, or where it answers an address that is not the load balancer's. An answer naming another address is the worst of the three, because the traffic goes there; an empty answer outlives the fix for as long as anything cached it. An address that moves is asserted separately and before any traffic, since a passing probe against a renumbered address would hide it.
|
||||
|
||||
**Then the node comes back, and has to give up what it no longer owns.** The returning node must not relaunch its local copy, and must no longer carry the tap that claims the load balancer's logical port — OVN binds a port to one chassis, so a second claimant does not split the traffic, it contends for it and the address answers from whichever won last. That same stale tap is also what keeps the external address's host ingress from being pruned, since the prune waits on evidence the guest sits elsewhere. The last assertion is that the name still serves traffic with the node back in the cluster, which is the end-user form of the same question.
|
||||
|
||||
**`DescribeLoadBalancers` is required to keep reporting `active` throughout, and that is AWS parity rather than an oversight.** AWS never marks a load balancer degraded because a host under it failed: the state describes the customer's configuration, not our hardware. The honest signals about the outage are target health and the traffic itself, which is why both are asserted and the state is not trusted as evidence of anything.
|
||||
|
||||
**Its own suite and its own cell, for instancerecovery's reasons plus one.** It takes a node away, so anything sharing the environment reports that outage as its own failure; it needs its own VPC, public subnet and a backend placement no shared fixture describes; and a failure here has to read as "the load balancer did not survive its host" rather than as one more red subtest in a suite about load balancer features.
|
||||
|
||||
### `iam` — everything IAM and STS
|
||||
|
||||
Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.
|
||||
|
||||
### `cert` — the cluster CA and TLS identity
|
||||
|
||||
CA installed and downloadable, fingerprint matches config, hostnames present in DNS SANs, OpenSSL verification, `curl` without a CA flag, and a shared issuer across nodes.
|
||||
|
||||
### `lb` — load balancers (multi-node)
|
||||
|
||||
Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `ModifyListener`, and NLB UDP.
|
||||
|
||||
### `eks`, `ecs`, `ecr` — container services
|
||||
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
|
||||
### `rds` — managed databases
|
||||
|
||||
The create/describe/connect path against a live cluster: a DB VM that actually boots, TLS enforced with plaintext refused, clients connecting from the same and another subnet, class changes replacing the VM while keeping overrides, deferred parameter-group attachment windows, and delete-while-creating leaving nothing behind.
|
||||
|
||||
### Storage and durability
|
||||
|
||||
- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
|
||||
- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
|
||||
- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH.
|
||||
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest reset once its budget expires. Proves a real reboot by requiring a *new* boot ID.
|
||||
- **`diskperf`** — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires `external_mode=pool` for guest SSH.
|
||||
|
||||
### Other
|
||||
|
||||
- **`quota`** — every enforced dimension refuses with the same `quotaExceeded` code; a launch is charged for every instance it asks for; one instance wider than the cap is refused.
|
||||
- **`gpu`** — device visible in the guest, driver access, launch.
|
||||
- **`bedrock`** — `ListFoundationModels`/`GetFoundationModel` against a running cluster. Wire-format parity is unit-tested, not here.
|
||||
- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios.
|
||||
- **`teardown`** — sweeps leftovers.
|
||||
- **`harness`** — shared primitives, not a suite. `harness/vpc_egress.go` carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on.
|
||||
- **`manifest-check`, `manifest-lint`** — manifest validation, not cluster behaviour.
|
||||
|
||||
## Keeping this honest
|
||||
|
||||
Add a row when a suite is added, and correct one when a suite's claim changes. An index that has drifted is worse than none, because it will be trusted. If a suite's package comment and this file disagree, check the test bodies — both are prose and either can be stale.
|
||||
@@ -6,29 +6,31 @@ require (
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/aws/aws-sdk-go-v2 v1.47.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.4
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.5
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/containerd/containerd/v2 v2.3.5
|
||||
github.com/containerd/containerd/v2 v2.4.0
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/klauspost/compress v1.20.0
|
||||
github.com/klauspost/cpuid/v2 v2.4.0
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/minio/highwayhash v1.0.4
|
||||
github.com/mulgadc/bluebottle v1.20.0
|
||||
github.com/mulgadc/northstar v1.20.0
|
||||
github.com/mulgadc/predastore v1.20.0
|
||||
github.com/mulgadc/viperblock v1.20.0
|
||||
github.com/nats-io/nats-server/v2 v2.14.6
|
||||
github.com/nats-io/nats.go v1.53.1
|
||||
github.com/mulgadc/bluebottle v1.21.0
|
||||
github.com/mulgadc/northstar v1.21.0
|
||||
github.com/mulgadc/predastore v1.21.0
|
||||
github.com/mulgadc/viperblock v1.21.0
|
||||
github.com/nats-io/nats-server/v2 v2.15.0
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
github.com/opencontainers/runtime-spec v1.3.0
|
||||
github.com/oracle/oci-go-sdk/v65 v65.126.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.8.1
|
||||
github.com/pelletier/go-toml/v2 v2.4.3
|
||||
github.com/pterm/pterm v0.12.83
|
||||
@@ -56,23 +58,22 @@ require (
|
||||
atomicgo.dev/keyboard v0.2.9 // indirect
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect
|
||||
github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.2-default-no-op // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 // indirect
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 // indirect
|
||||
github.com/aws/smithy-go v1.28.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
@@ -90,16 +91,17 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/cgroups/v3 v3.1.3 // indirect
|
||||
github.com/containerd/console v1.0.5 // indirect
|
||||
github.com/containerd/containerd/api v1.11.1 // indirect
|
||||
github.com/containerd/containerd/api v1.12.0 // indirect
|
||||
github.com/containerd/continuity v0.5.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/fifo v1.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/log v0.2.0 // indirect
|
||||
github.com/containerd/log/otel v0.1.0 // indirect
|
||||
github.com/containerd/platforms v1.0.0-rc.5 // indirect
|
||||
github.com/containerd/plugin v1.1.0 // indirect
|
||||
github.com/containerd/ttrpc v1.2.8 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||
github.com/containerd/ttrpc v1.2.9 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.3.0 // indirect
|
||||
github.com/dgraph-io/badger/v4 v4.9.6 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -114,8 +116,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/gofrs/flock v0.10.0 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -123,37 +124,34 @@ require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/hashicorp/go-metrics v0.7.0 // indirect
|
||||
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/raft v1.7.3 // indirect
|
||||
github.com/hashicorp/raft-boltdb/v2 v2.3.1 // indirect
|
||||
github.com/hashicorp/raft v1.8.0 // indirect
|
||||
github.com/hashicorp/raft-boltdb/v2 v2.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/reedsolomon v1.14.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/mdlayher/packet v1.1.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mdlayher/socket v0.6.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/sys/capability v0.4.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/sys/user v0.4.1 // indirect
|
||||
github.com/moby/sys/userns v0.2.1 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
@@ -163,17 +161,18 @@ require (
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/quic-go v0.62.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.24.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/quic-go/quic-go v0.63.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/sony/gobreaker/v2 v2.4.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
@@ -181,8 +180,8 @@ require (
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 // indirect
|
||||
@@ -194,7 +193,6 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
@@ -202,14 +200,12 @@ require (
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/vuln v1.6.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
tags.cncf.io/container-device-interface v1.1.0 // indirect
|
||||
tags.cncf.io/container-device-interface/specs-go v1.1.0 // indirect
|
||||
tags.cncf.io/container-device-interface v1.1.1 // indirect
|
||||
tags.cncf.io/container-device-interface/specs-go v1.1.1 // indirect
|
||||
)
|
||||
|
||||
// go.opencensus.io -> grpc@v1.33.2 drags in the pre-split monolithic genproto,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -33,6 +35,44 @@ type OperationCoverage struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// CoverageJSONSchemaVersion identifies the generated coverage.json schema.
|
||||
// Increment it only for an incompatible change to the JSON document.
|
||||
const CoverageJSONSchemaVersion = 1
|
||||
|
||||
// coverageJSON is the generated, machine-readable form of the operation
|
||||
// inventory. It deliberately reports dispatch state only: an operation in
|
||||
// implemented has a registered, non-stubbed and non-refused dispatcher, but
|
||||
// that alone does not establish behavioural AWS compatibility.
|
||||
type coverageJSON struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ModelSource coverageJSONModelSource `json:"model_source"`
|
||||
Services []coverageJSONService `json:"services"`
|
||||
}
|
||||
|
||||
type coverageJSONModelSource struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
CommitDate string `json:"commit_date"`
|
||||
}
|
||||
|
||||
type coverageJSONService struct {
|
||||
Service string `json:"service"`
|
||||
APIVersion string `json:"api_version"`
|
||||
Opaque bool `json:"opaque"`
|
||||
Note string `json:"note"`
|
||||
Operations coverageJSONOperations `json:"operations"`
|
||||
}
|
||||
|
||||
type coverageJSONOperations struct {
|
||||
Modelled []string `json:"modelled"`
|
||||
Registered []string `json:"registered"`
|
||||
Implemented []string `json:"implemented"`
|
||||
Stubbed []string `json:"stubbed"`
|
||||
Unsupported []string `json:"unsupported"`
|
||||
Missing []string `json:"missing"`
|
||||
Extra []string `json:"extra"`
|
||||
}
|
||||
|
||||
// CompareOperations compares one embedded service model with an authoritative
|
||||
// gateway dispatch inventory.
|
||||
func CompareOperations(service Service, dispatch DispatchInventory) (OperationCoverage, error) {
|
||||
@@ -127,7 +167,7 @@ func toSet(values []string) map[string]bool {
|
||||
// terminal. The published pages carry the per-operation detail.
|
||||
func RenderCoverageSummary(coverages []OperationCoverage) string {
|
||||
var report strings.Builder
|
||||
fmt.Fprintf(&report, "AWS model operation coverage (aws-sdk-go %s)\n\n", SourceSDKVersion)
|
||||
fmt.Fprintf(&report, "AWS model operation coverage (%s)\n\n", ModelSourceDescription())
|
||||
for _, coverage := range sortedCoverages(coverages) {
|
||||
if coverage.Opaque {
|
||||
fmt.Fprintf(&report, "%-24s %-12s not enumerable\n", coverage.Service, coverage.APIVersion)
|
||||
@@ -140,6 +180,62 @@ func RenderCoverageSummary(coverages []OperationCoverage) string {
|
||||
return report.String()
|
||||
}
|
||||
|
||||
// RenderCoverageJSON renders a deterministic, versioned inventory suitable
|
||||
// for tools. The operation lists describe only the generated model-versus-
|
||||
// dispatch comparison; consumers must establish behavioural support from
|
||||
// their own evidence rather than inferring it from dispatch registration.
|
||||
func RenderCoverageJSON(coverages []OperationCoverage) (string, error) {
|
||||
document := coverageJSON{
|
||||
SchemaVersion: CoverageJSONSchemaVersion,
|
||||
ModelSource: coverageJSONModelSource{
|
||||
Repository: ModelRepository,
|
||||
Commit: ModelCommit,
|
||||
CommitDate: ModelCommitDate,
|
||||
},
|
||||
Services: make([]coverageJSONService, 0, len(coverages)),
|
||||
}
|
||||
for _, coverage := range sortedCoverages(coverages) {
|
||||
document.Services = append(document.Services, coverageJSONService{
|
||||
Service: string(coverage.Service),
|
||||
APIVersion: coverage.APIVersion,
|
||||
Opaque: coverage.Opaque,
|
||||
Note: coverage.Note,
|
||||
Operations: coverageJSONOperations{
|
||||
Modelled: copiedOperations(coverage.Modelled),
|
||||
Registered: copiedOperations(coverage.Registered),
|
||||
Implemented: copiedOperations(coverage.Implemented),
|
||||
Stubbed: copiedOperations(coverage.Stubbed),
|
||||
Unsupported: copiedOperations(coverage.Unsupported),
|
||||
Missing: copiedOperations(coverage.Missing),
|
||||
Extra: copiedOperations(coverage.Extra),
|
||||
},
|
||||
})
|
||||
}
|
||||
contents, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("awsmodel: marshal coverage JSON: %w", err)
|
||||
}
|
||||
return string(contents) + "\n", nil
|
||||
}
|
||||
|
||||
// WriteCoverageJSON writes the generated machine-readable inventory to path.
|
||||
func WriteCoverageJSON(path string, coverages []OperationCoverage) error {
|
||||
contents, err := RenderCoverageJSON(coverages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
return fmt.Errorf("awsmodel: write coverage JSON: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copiedOperations represents an empty operation set as [] rather than null,
|
||||
// so consumers can use every array field without a special case.
|
||||
func copiedOperations(operations []string) []string {
|
||||
return append([]string{}, operations...)
|
||||
}
|
||||
|
||||
// RenderInternalReport renders the full comparison, including the operations
|
||||
// the published pages leave out. It is not a site page: the docs build renders
|
||||
// only the slugs it is configured with, and this file is not one of them.
|
||||
@@ -152,7 +248,7 @@ func RenderInternalReport(coverages []OperationCoverage, pages PageSet) string {
|
||||
report.WriteString("# AWS Model Operation Coverage (internal)\n\n")
|
||||
report.WriteString("Generated by `make aws-model-coverage`. This file is not published: it carries the counts and the unimplemented operations that the pages under this directory deliberately leave out.\n\n")
|
||||
report.WriteString("**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.\n\n")
|
||||
fmt.Fprintf(&report, "Pinned model source: `aws-sdk-go %s`.\n\n", SourceSDKVersion)
|
||||
fmt.Fprintf(&report, "Pinned model source: `%s`.\n\n", ModelSourceDescription())
|
||||
|
||||
for _, coverage := range sortedCoverages(coverages) {
|
||||
page := pages.Services[coverage.Service]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package awsmodel_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
)
|
||||
|
||||
func TestRenderCoverageJSONIsDeterministicAndPreservesDispatchStates(t *testing.T) {
|
||||
sts, err := awsmodel.CompareOperations(awsmodel.STS, awsmodel.DispatchInventory{
|
||||
Registered: []string{"AssumeRole", "GetCallerIdentity", "GetSessionToken"},
|
||||
Stubbed: []string{"GetSessionToken"},
|
||||
Unsupported: []string{"GetCallerIdentity"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
iam, err := awsmodel.CompareOperations(awsmodel.IAM, awsmodel.DispatchInventory{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
contents, err := awsmodel.RenderCoverageJSON([]awsmodel.OperationCoverage{sts, iam})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ModelSource struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
CommitDate string `json:"commit_date"`
|
||||
} `json:"model_source"`
|
||||
Services []struct {
|
||||
Service string `json:"service"`
|
||||
Operations struct {
|
||||
Implemented []string `json:"implemented"`
|
||||
Stubbed []string `json:"stubbed"`
|
||||
Unsupported []string `json:"unsupported"`
|
||||
Registered []string `json:"registered"`
|
||||
} `json:"operations"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(contents), &document); err != nil {
|
||||
t.Fatalf("generated JSON is invalid: %v\n%s", err, contents)
|
||||
}
|
||||
if document.SchemaVersion != awsmodel.CoverageJSONSchemaVersion {
|
||||
t.Errorf("schema version = %d, want %d", document.SchemaVersion, awsmodel.CoverageJSONSchemaVersion)
|
||||
}
|
||||
if source := document.ModelSource; source.Repository != awsmodel.ModelRepository || source.Commit != awsmodel.ModelCommit || source.CommitDate != awsmodel.ModelCommitDate {
|
||||
t.Errorf("model source = %+v, want %s at %s (%s)", source, awsmodel.ModelRepository, awsmodel.ModelCommit, awsmodel.ModelCommitDate)
|
||||
}
|
||||
if !strings.Contains(contents, `"registered": []`) {
|
||||
t.Errorf("an empty operation set must be an array, not null:\n%s", contents)
|
||||
}
|
||||
if len(document.Services) != 2 || document.Services[0].Service != "iam" || document.Services[1].Service != "sts" {
|
||||
t.Fatalf("services are not deterministic: %#v", document.Services)
|
||||
}
|
||||
states := document.Services[1].Operations
|
||||
if got, want := strings.Join(states.Implemented, ","), "AssumeRole"; got != want {
|
||||
t.Errorf("implemented = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := strings.Join(states.Stubbed, ","), "GetSessionToken"; got != want {
|
||||
t.Errorf("stubbed = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := strings.Join(states.Unsupported, ","), "GetCallerIdentity"; got != want {
|
||||
t.Errorf("unsupported = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := strings.Join(states.Registered, ","), "AssumeRole,GetCallerIdentity,GetSessionToken"; got != want {
|
||||
t.Errorf("registered = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCoverageJSONWritesTheSameDocument(t *testing.T) {
|
||||
coverage, err := awsmodel.CompareOperations(awsmodel.STS, awsmodel.DispatchInventory{Registered: []string{"AssumeRole"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := awsmodel.RenderCoverageJSON([]awsmodel.OperationCoverage{coverage})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "coverage.json")
|
||||
if err := awsmodel.WriteCoverageJSON(path, []awsmodel.OperationCoverage{coverage}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("written JSON differs from renderer:\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ func TestCompareOperations(t *testing.T) {
|
||||
if len(coverage.Implemented) != 1 || coverage.Implemented[0] != "AssumeRole" {
|
||||
t.Fatalf("implemented = %v, want [AssumeRole]", coverage.Implemented)
|
||||
}
|
||||
if len(coverage.Missing) != 5 {
|
||||
t.Fatalf("missing count = %d, want 5: %v", len(coverage.Missing), coverage.Missing)
|
||||
if len(coverage.Missing) != 8 {
|
||||
t.Fatalf("missing count = %d, want 8: %v", len(coverage.Missing), coverage.Missing)
|
||||
}
|
||||
if len(coverage.Extra) != 1 || coverage.Extra[0] != "ExtraOperation" {
|
||||
t.Fatalf("extra = %v, want [ExtraOperation]", coverage.Extra)
|
||||
@@ -39,8 +39,8 @@ func TestRenderCoverageSummary(t *testing.T) {
|
||||
}
|
||||
report := RenderCoverageSummary([]OperationCoverage{coverage})
|
||||
for _, want := range []string{
|
||||
"aws-sdk-go " + SourceSDKVersion,
|
||||
"1 of 8 implemented ( 12.5%)",
|
||||
ModelSourceDescription(),
|
||||
"1 of 11 implemented ( 9.1%)",
|
||||
} {
|
||||
if !strings.Contains(report, want) {
|
||||
t.Errorf("summary does not contain %q:\n%s", want, report)
|
||||
|
||||
@@ -85,8 +85,8 @@ var loadEC2ErrorCatalog = sync.OnceValues(func() (*ec2ErrorCatalog, error) {
|
||||
|
||||
// ValidateEC2ErrorResponse validates an EC2 Query API error envelope against
|
||||
// the checked-in catalog curated from AWS's EC2 error-code reference. The AWS
|
||||
// api-2.json model declares no operation errors, so this is intentionally a
|
||||
// separate oracle.
|
||||
// EC2 model declares no operation errors, so this is intentionally a separate
|
||||
// oracle.
|
||||
func ValidateEC2ErrorResponse(status int, body []byte) ([]Violation, error) {
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
|
||||
+13
-14
@@ -2,22 +2,21 @@ package awsmodel
|
||||
|
||||
import "embed"
|
||||
|
||||
// modelFS contains only the curated EC2 error catalog. The large upstream
|
||||
// api-2.json files are resolved from the pinned aws-sdk-go module cache at
|
||||
// runtime; they are intentionally not source-controlled here.
|
||||
// modelFS holds the Smithy service models, gzipped, from the api-models-aws
|
||||
// commit named by ModelCommit, and the curated EC2 error catalog.
|
||||
//
|
||||
//go:embed models/ec2/error-codes.json
|
||||
//go:embed models/*.json.gz models/ec2/error-codes.json
|
||||
var modelFS embed.FS
|
||||
|
||||
var modelFiles = map[Service]string{
|
||||
ACM: "models/apis/acm/2015-12-08/api-2.json",
|
||||
EC2: "models/apis/ec2/2016-11-15/api-2.json",
|
||||
ECR: "models/apis/ecr/2015-09-21/api-2.json",
|
||||
ECS: "models/apis/ecs/2014-11-13/api-2.json",
|
||||
EKS: "models/apis/eks/2017-11-01/api-2.json",
|
||||
ElasticLoadBalancingV2: "models/apis/elasticloadbalancingv2/2015-12-01/api-2.json",
|
||||
IAM: "models/apis/iam/2010-05-08/api-2.json",
|
||||
RDS: "models/apis/rds/2014-10-31/api-2.json",
|
||||
S3: "models/apis/s3/2006-03-01/api-2.json",
|
||||
STS: "models/apis/sts/2011-06-15/api-2.json",
|
||||
ACM: "models/acm-2015-12-08.json.gz",
|
||||
EC2: "models/ec2-2016-11-15.json.gz",
|
||||
ECR: "models/ecr-2015-09-21.json.gz",
|
||||
ECS: "models/ecs-2014-11-13.json.gz",
|
||||
EKS: "models/eks-2017-11-01.json.gz",
|
||||
ElasticLoadBalancingV2: "models/elastic-load-balancing-v2-2015-12-01.json.gz",
|
||||
IAM: "models/iam-2010-05-08.json.gz",
|
||||
RDS: "models/rds-2014-10-31.json.gz",
|
||||
S3: "models/s3-2006-03-01.json.gz",
|
||||
STS: "models/sts-2011-06-15.json.gz",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EncodedRequest is an operation input serialized for the service's protocol,
|
||||
// ready to be addressed to an endpoint and signed.
|
||||
type EncodedRequest struct {
|
||||
Method string
|
||||
// Path is escaped; it may carry a query string of its own.
|
||||
Path string
|
||||
Query url.Values
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// URL joins the request's path and query onto endpoint.
|
||||
func (r *EncodedRequest) URL(endpoint string) string {
|
||||
target := strings.TrimSuffix(endpoint, "/") + r.Path
|
||||
if len(r.Query) == 0 {
|
||||
return target
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(r.Path, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return target + separator + r.Query.Encode()
|
||||
}
|
||||
|
||||
// EncodeRequest serializes input, as produced by GenerateRequests, for the
|
||||
// service's protocol. S3's rest-xml is not supported.
|
||||
func EncodeRequest(service Service, operationName string, input map[string]any) (*EncodedRequest, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation, ok := model.Operation(operationName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName)
|
||||
}
|
||||
encoder := requestEncoder{model: model}
|
||||
switch model.metadata.Protocol {
|
||||
case "query", "ec2":
|
||||
return encoder.query(operation, input)
|
||||
case "json":
|
||||
return encoder.json(operation, input)
|
||||
case "rest-json":
|
||||
return encoder.restJSON(operation, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("awsmodel: encoding %s requests is not implemented", model.metadata.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
type requestEncoder struct {
|
||||
model *Model
|
||||
}
|
||||
|
||||
func (e requestEncoder) query(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
values := url.Values{
|
||||
"Action": {operation.Name},
|
||||
"Version": {e.model.metadata.APIVersion},
|
||||
}
|
||||
if operation.Input != nil {
|
||||
if err := e.queryStructure(values, "", operation.Input.Shape, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &EncodedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/",
|
||||
Header: http.Header{"Content-Type": {"application/x-www-form-urlencoded; charset=utf-8"}},
|
||||
Body: []byte(values.Encode()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e requestEncoder) queryStructure(values url.Values, prefix, shapeName string, input map[string]any) error {
|
||||
shape := e.model.shapes[shapeName]
|
||||
for _, member := range slices.Sorted(maps.Keys(input)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s has no member %q", shapeName, member)
|
||||
}
|
||||
if err := e.queryValue(values, prefix+e.queryName(member, ref), ref, input[member]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryName is the parameter name for a member: the ec2 protocol capitalises
|
||||
// its XML name unless ec2QueryName gives one; awsQuery uses the XML name.
|
||||
func (e requestEncoder) queryName(member string, ref ShapeRef) string {
|
||||
if e.model.metadata.Protocol != "ec2" {
|
||||
if ref.LocationName != "" {
|
||||
return ref.LocationName
|
||||
}
|
||||
return member
|
||||
}
|
||||
if ref.QueryName != "" {
|
||||
return ref.QueryName
|
||||
}
|
||||
name := member
|
||||
if ref.LocationName != "" {
|
||||
name = ref.LocationName
|
||||
}
|
||||
runes := []rune(name)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func (e requestEncoder) queryValue(values url.Values, name string, ref ShapeRef, value any) error {
|
||||
shape := e.model.shapes[ref.Shape]
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a structure, got %T", name, value)
|
||||
}
|
||||
return e.queryStructure(values, name+".", ref.Shape, fields)
|
||||
case "list":
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a list, got %T", name, value)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
// awsQuery sends an empty list as an empty parameter; ec2 cannot.
|
||||
if e.model.metadata.Protocol != "ec2" {
|
||||
values.Set(name, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
itemPrefix := name + "."
|
||||
if e.model.metadata.Protocol != "ec2" && !ref.Flattened && !shape.Flattened {
|
||||
memberName := "member"
|
||||
if shape.Member.LocationName != "" {
|
||||
memberName = shape.Member.LocationName
|
||||
}
|
||||
itemPrefix += memberName + "."
|
||||
}
|
||||
for i, item := range items {
|
||||
if err := e.queryValue(values, itemPrefix+strconv.Itoa(i+1), *shape.Member, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "map":
|
||||
entries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a map, got %T", name, value)
|
||||
}
|
||||
entryPrefix := name + "."
|
||||
if e.model.metadata.Protocol != "ec2" && !ref.Flattened && !shape.Flattened {
|
||||
entryPrefix += "entry."
|
||||
}
|
||||
keyName, valueName := "key", "value"
|
||||
if shape.Key.LocationName != "" {
|
||||
keyName = shape.Key.LocationName
|
||||
}
|
||||
if shape.Value.LocationName != "" {
|
||||
valueName = shape.Value.LocationName
|
||||
}
|
||||
for i, key := range slices.Sorted(maps.Keys(entries)) {
|
||||
entry := entryPrefix + strconv.Itoa(i+1) + "."
|
||||
values.Set(entry+keyName, key)
|
||||
if err := e.queryValue(values, entry+valueName, *shape.Value, entries[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
scalar, err := scalarString(value, ref, shape, "iso8601")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
values.Set(name, scalar)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e requestEncoder) json(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
body := []byte("{}")
|
||||
if operation.Input != nil {
|
||||
document, err := e.jsonValue(*operation.Input, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, err = json.Marshal(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
metadata := e.model.metadata
|
||||
return &EncodedRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: "/",
|
||||
Header: http.Header{
|
||||
"Content-Type": {"application/x-amz-json-" + metadata.JSONVersion},
|
||||
"X-Amz-Target": {metadata.TargetPrefix + "." + operation.Name},
|
||||
},
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// jsonValue converts a generated value into its JSON document form.
|
||||
func (e requestEncoder) jsonValue(ref ShapeRef, value any) (any, error) {
|
||||
shape := e.model.shapes[ref.Shape]
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: want a structure, got %T", ref.Shape, value)
|
||||
}
|
||||
document := make(map[string]any, len(fields))
|
||||
for member, field := range fields {
|
||||
memberRef, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s has no member %q", ref.Shape, member)
|
||||
}
|
||||
encoded, err := e.jsonValue(memberRef, field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document[member] = encoded
|
||||
}
|
||||
return document, nil
|
||||
case "list":
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: want a list, got %T", ref.Shape, value)
|
||||
}
|
||||
document := make([]any, len(items))
|
||||
for i, item := range items {
|
||||
encoded, err := e.jsonValue(*shape.Member, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document[i] = encoded
|
||||
}
|
||||
return document, nil
|
||||
case "map":
|
||||
entries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: want a map, got %T", ref.Shape, value)
|
||||
}
|
||||
document := make(map[string]any, len(entries))
|
||||
for key, entry := range entries {
|
||||
encoded, err := e.jsonValue(*shape.Value, entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document[key] = encoded
|
||||
}
|
||||
return document, nil
|
||||
case "timestamp":
|
||||
stamp, ok := value.(time.Time)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: want a timestamp, got %T", ref.Shape, value)
|
||||
}
|
||||
if format := timestampFormatFor(ref, shape, "unixTimestamp"); format != "unixTimestamp" {
|
||||
return formatTimestamp(stamp, format), nil
|
||||
}
|
||||
return stamp.Unix(), nil
|
||||
case "blob":
|
||||
data, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: want a blob, got %T", ref.Shape, value)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
request := &EncodedRequest{Method: operation.HTTP.Method, Query: url.Values{}, Header: http.Header{}}
|
||||
path := operation.HTTP.RequestURI
|
||||
if operation.Input == nil {
|
||||
request.Path = path
|
||||
return request, nil
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
body := map[string]any{}
|
||||
for _, member := range slices.Sorted(maps.Keys(input)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
|
||||
}
|
||||
value := input[member]
|
||||
target := e.model.shapes[ref.Shape]
|
||||
switch ref.Location {
|
||||
case "uri":
|
||||
text, err := scalarString(value, ref, target, "iso8601")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
path = strings.Replace(path, "{"+member+"+}", escapeGreedyLabel(text), 1)
|
||||
path = strings.Replace(path, "{"+member+"}", url.PathEscape(text), 1)
|
||||
case "querystring":
|
||||
if err := e.restQuery(request.Query, ref, target, value); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
case "header":
|
||||
text, err := e.headerValue(ref, target, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
request.Header.Set(ref.LocationName, text)
|
||||
case "headers":
|
||||
entries, _ := value.(map[string]any)
|
||||
for key, entry := range entries {
|
||||
request.Header.Set(ref.LocationName+key, fmt.Sprint(entry))
|
||||
}
|
||||
default:
|
||||
encoded, err := e.jsonValue(ref, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := member
|
||||
if ref.LocationName != "" {
|
||||
name = ref.LocationName
|
||||
}
|
||||
body[name] = encoded
|
||||
}
|
||||
}
|
||||
request.Path = path
|
||||
if payload := shape.Payload; payload != "" {
|
||||
if document, ok := body[payload]; ok {
|
||||
body = nil
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = encoded
|
||||
}
|
||||
}
|
||||
if len(body) > 0 {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = encoded
|
||||
}
|
||||
if request.Body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (e requestEncoder) restQuery(query url.Values, ref ShapeRef, shape *Shape, value any) error {
|
||||
switch shape.Type {
|
||||
case "list":
|
||||
items, _ := value.([]any)
|
||||
for _, item := range items {
|
||||
text, err := scalarString(item, *shape.Member, e.model.shapes[shape.Member.Shape], "iso8601")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Add(ref.LocationName, text)
|
||||
}
|
||||
return nil
|
||||
case "map":
|
||||
entries, _ := value.(map[string]any)
|
||||
for key, entry := range entries {
|
||||
query.Add(key, fmt.Sprint(entry))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
text, err := scalarString(value, ref, shape, "iso8601")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Set(ref.LocationName, text)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e requestEncoder) headerValue(ref ShapeRef, shape *Shape, value any) (string, error) {
|
||||
if shape.Type == "list" {
|
||||
items, _ := value.([]any)
|
||||
texts := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
text, err := scalarString(item, *shape.Member, e.model.shapes[shape.Member.Shape], "rfc822")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
texts[i] = text
|
||||
}
|
||||
return strings.Join(texts, ","), nil
|
||||
}
|
||||
return scalarString(value, ref, shape, "rfc822")
|
||||
}
|
||||
|
||||
// escapeGreedyLabel escapes a greedy label's segments but keeps its slashes.
|
||||
func escapeGreedyLabel(value string) string {
|
||||
segments := strings.Split(value, "/")
|
||||
for i, segment := range segments {
|
||||
segments[i] = url.PathEscape(segment)
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func scalarString(value any, ref ShapeRef, shape *Shape, defaultTimestamp string) (string, error) {
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case bool:
|
||||
return strconv.FormatBool(value), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10), nil
|
||||
case float64:
|
||||
return strconv.FormatFloat(value, 'g', -1, 64), nil
|
||||
case []byte:
|
||||
return base64.StdEncoding.EncodeToString(value), nil
|
||||
case time.Time:
|
||||
return formatTimestamp(value, timestampFormatFor(ref, shape, defaultTimestamp)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported scalar %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func timestampFormatFor(ref ShapeRef, shape *Shape, defaultFormat string) string {
|
||||
if ref.TimestampFormat != "" {
|
||||
return ref.TimestampFormat
|
||||
}
|
||||
if shape.TimestampFormat != "" {
|
||||
return shape.TimestampFormat
|
||||
}
|
||||
return defaultFormat
|
||||
}
|
||||
|
||||
func formatTimestamp(value time.Time, format string) string {
|
||||
switch format {
|
||||
case "rfc822":
|
||||
return value.UTC().Format(http.TimeFormat)
|
||||
case "unixTimestamp":
|
||||
return strconv.FormatInt(value.Unix(), 10)
|
||||
default:
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package awsmodel_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func encodedForm(t *testing.T, request *awsmodel.EncodedRequest) url.Values {
|
||||
t.Helper()
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/", request.Path)
|
||||
form, err := url.ParseQuery(string(request.Body))
|
||||
require.NoError(t, err)
|
||||
return form
|
||||
}
|
||||
|
||||
func TestEncodeRequestAWSQueryWrapsListMembers(t *testing.T) {
|
||||
request, err := awsmodel.EncodeRequest(awsmodel.IAM, "CreateRole", map[string]any{
|
||||
"RoleName": "app",
|
||||
"AssumeRolePolicyDocument": "{}",
|
||||
"MaxSessionDuration": int64(3600),
|
||||
"Tags": []any{map[string]any{"Key": "team", "Value": "core"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, url.Values{
|
||||
"Action": {"CreateRole"},
|
||||
"Version": {"2010-05-08"},
|
||||
"RoleName": {"app"},
|
||||
"AssumeRolePolicyDocument": {"{}"},
|
||||
"MaxSessionDuration": {"3600"},
|
||||
"Tags.member.1.Key": {"team"},
|
||||
"Tags.member.1.Value": {"core"},
|
||||
}, encodedForm(t, request))
|
||||
}
|
||||
|
||||
func TestEncodeRequestEC2UsesQueryNamesWithoutMemberWrapper(t *testing.T) {
|
||||
request, err := awsmodel.EncodeRequest(awsmodel.EC2, "RunInstances", map[string]any{
|
||||
"ImageId": "ami-0123456789abcdef0",
|
||||
"MinCount": int64(1),
|
||||
"MaxCount": int64(1),
|
||||
"SecurityGroupIds": []any{"sg-0123456789abcdef0"},
|
||||
"TagSpecifications": []any{map[string]any{
|
||||
"ResourceType": "instance",
|
||||
"Tags": []any{map[string]any{"Key": "Name", "Value": "web"}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, url.Values{
|
||||
"Action": {"RunInstances"},
|
||||
"Version": {"2016-11-15"},
|
||||
"ImageId": {"ami-0123456789abcdef0"},
|
||||
"MinCount": {"1"},
|
||||
"MaxCount": {"1"},
|
||||
"SecurityGroupId.1": {"sg-0123456789abcdef0"},
|
||||
"TagSpecification.1.ResourceType": {"instance"},
|
||||
"TagSpecification.1.Tag.1.Key": {"Name"},
|
||||
"TagSpecification.1.Tag.1.Value": {"web"},
|
||||
}, encodedForm(t, request))
|
||||
}
|
||||
|
||||
func TestEncodeRequestJSONTargetsOperation(t *testing.T) {
|
||||
tests := []struct {
|
||||
service awsmodel.Service
|
||||
operation string
|
||||
target string
|
||||
}{
|
||||
{awsmodel.ECS, "ListClusters", "AmazonEC2ContainerServiceV20141113.ListClusters"},
|
||||
{awsmodel.ECR, "DescribeRepositories", "AmazonEC2ContainerRegistry_V20150921.DescribeRepositories"},
|
||||
{awsmodel.ACM, "ListCertificates", "CertificateManager.ListCertificates"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.service), func(t *testing.T) {
|
||||
request, err := awsmodel.EncodeRequest(test.service, test.operation, map[string]any{"maxResults": int64(5)})
|
||||
if test.service == awsmodel.ACM {
|
||||
request, err = awsmodel.EncodeRequest(test.service, test.operation, map[string]any{"MaxItems": int64(5)})
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, test.target, request.Header.Get("X-Amz-Target"))
|
||||
require.Equal(t, "application/x-amz-json-1.1", request.Header.Get("Content-Type"))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(request.Body, &body))
|
||||
require.Len(t, body, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRequestRestJSONPlacesMembers(t *testing.T) {
|
||||
describe, err := awsmodel.EncodeRequest(awsmodel.EKS, "DescribeCluster", map[string]any{"name": "prod cluster"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodGet, describe.Method)
|
||||
require.Equal(t, "https://eks.example/clusters/prod%20cluster", describe.URL("https://eks.example"))
|
||||
|
||||
list, err := awsmodel.EncodeRequest(awsmodel.EKS, "ListClusters", map[string]any{"maxResults": int64(5), "include": []any{"all"}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, url.Values{"maxResults": {"5"}, "include": {"all"}}, list.Query)
|
||||
require.Empty(t, list.Body)
|
||||
|
||||
create, err := awsmodel.EncodeRequest(awsmodel.EKS, "CreateCluster", map[string]any{
|
||||
"name": "prod",
|
||||
"roleArn": "arn:aws:iam::123456789012:role/eks",
|
||||
"resourcesVpcConfig": map[string]any{"subnetIds": []any{"subnet-1"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodPost, create.Method)
|
||||
require.Equal(t, "/clusters", create.Path)
|
||||
require.Equal(t, "application/json", create.Header.Get("Content-Type"))
|
||||
require.JSONEq(t, `{"name":"prod","roleArn":"arn:aws:iam::123456789012:role/eks","resourcesVpcConfig":{"subnetIds":["subnet-1"]}}`, string(create.Body))
|
||||
}
|
||||
|
||||
func TestEncodeRequestRejectsUnmodelledMember(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.IAM, "GetRole", map[string]any{"RoleName": "app", "Bogus": "x"})
|
||||
require.ErrorContains(t, err, `no member "Bogus"`)
|
||||
}
|
||||
|
||||
func TestEncodeRequestS3IsNotSupported(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.S3, "ListBuckets", map[string]any{})
|
||||
require.ErrorContains(t, err, "not implemented")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func isUnmodelledCommonError(service Service, code string) bool {
|
||||
case IAM, ElasticLoadBalancingV2:
|
||||
return isUnmodelledQueryError(code)
|
||||
case STS:
|
||||
// STS also uses these two runtime input errors even though api-2.json
|
||||
// STS also uses these two runtime input errors even though the model
|
||||
// does not declare them on AssumeRole/GetSessionToken.
|
||||
return isUnmodelledQueryError(code) || code == "ValidationError" || code == "InvalidParameterValue"
|
||||
case ECS:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package awsmodel
|
||||
|
||||
// Hooks for the external test package.
|
||||
var ParseSmithyModel = parseSmithyModel
|
||||
|
||||
func (m *Model) OperationErrorCodes(operation *Operation) []string {
|
||||
return m.operationErrorCodes(operation)
|
||||
}
|
||||
+83
-122
@@ -1,26 +1,28 @@
|
||||
// Package awsmodel loads the AWS api-2.json service definitions used by the
|
||||
// conformance suite.
|
||||
// Package awsmodel loads the AWS Smithy service models used by the
|
||||
// conformance suite and translates them into the api-2.json shaped structs
|
||||
// its validators read.
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SourceSDKVersion is the aws-sdk-go release whose module-cache models are
|
||||
// used by the conformance suite.
|
||||
const SourceSDKVersion = "v1.55.8"
|
||||
// ModelRepository is where the embedded models come from. The commit is
|
||||
// recorded in model_source.go by scripts/sync-aws-models.sh.
|
||||
const ModelRepository = "github.com/aws/api-models-aws"
|
||||
|
||||
const sourceSDKModule = "github.com/aws/aws-sdk-go"
|
||||
// ModelSourceDescription names the pinned model source for reports.
|
||||
func ModelSourceDescription() string {
|
||||
return fmt.Sprintf("%s@%.12s, %s", ModelRepository, ModelCommit, ModelCommitDate)
|
||||
}
|
||||
|
||||
// Service identifies an AWS service model loaded from the SDK module cache.
|
||||
// Service identifies an AWS service model embedded in this package.
|
||||
type Service string
|
||||
|
||||
const (
|
||||
@@ -37,77 +39,79 @@ const (
|
||||
)
|
||||
|
||||
// Metadata describes the service and wire protocol represented by a model.
|
||||
// Protocol uses the api-2.json names: json, rest-json, rest-xml, query, ec2.
|
||||
// JSONVersion and TargetPrefix are set only for the json protocol.
|
||||
type Metadata struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
EndpointPrefix string `json:"endpointPrefix"`
|
||||
Protocol string `json:"protocol"`
|
||||
Protocols []string `json:"protocols"`
|
||||
ServiceAbbreviation string `json:"serviceAbbreviation"`
|
||||
ServiceFullName string `json:"serviceFullName"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
SignatureVersion string `json:"signatureVersion"`
|
||||
UID string `json:"uid"`
|
||||
APIVersion string
|
||||
EndpointPrefix string
|
||||
JSONVersion string
|
||||
Protocol string
|
||||
ServiceFullName string
|
||||
ServiceID string
|
||||
SigningName string
|
||||
TargetPrefix string
|
||||
}
|
||||
|
||||
// Operation describes an AWS API operation and the shapes used by its input,
|
||||
// output and declared errors.
|
||||
type Operation struct {
|
||||
Name string `json:"name"`
|
||||
HTTP HTTP `json:"http"`
|
||||
Input *ShapeRef `json:"input"`
|
||||
Output *ShapeRef `json:"output"`
|
||||
Errors []ShapeRef `json:"errors"`
|
||||
Name string
|
||||
HTTP HTTP
|
||||
Input *ShapeRef
|
||||
Output *ShapeRef
|
||||
Errors []ShapeRef
|
||||
}
|
||||
|
||||
// HTTP describes an operation's HTTP binding.
|
||||
type HTTP struct {
|
||||
Method string `json:"method"`
|
||||
RequestURI string `json:"requestUri"`
|
||||
ResponseCode int `json:"responseCode"`
|
||||
Method string
|
||||
RequestURI string
|
||||
ResponseCode int
|
||||
}
|
||||
|
||||
// Shape describes a value in an AWS service model. Depending on Type, it can
|
||||
// refer to structure members, a list member, or map keys and values.
|
||||
type Shape struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Members map[string]ShapeRef `json:"members"`
|
||||
Member *ShapeRef `json:"member"`
|
||||
Key *ShapeRef `json:"key"`
|
||||
Value *ShapeRef `json:"value"`
|
||||
Enum []string `json:"enum"`
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
Pattern string `json:"pattern"`
|
||||
LocationName string `json:"locationName"`
|
||||
TimestampFormat string `json:"timestampFormat"`
|
||||
Payload string `json:"payload"`
|
||||
Flattened bool `json:"flattened"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
Exception bool `json:"exception"`
|
||||
Fault bool `json:"fault"`
|
||||
Error *ErrorInfo `json:"error"`
|
||||
Type string
|
||||
Required []string
|
||||
Members map[string]ShapeRef
|
||||
Member *ShapeRef
|
||||
Key *ShapeRef
|
||||
Value *ShapeRef
|
||||
Enum []string
|
||||
Min *float64
|
||||
Max *float64
|
||||
Pattern string
|
||||
LocationName string
|
||||
TimestampFormat string
|
||||
Payload string
|
||||
Flattened bool
|
||||
Sensitive bool
|
||||
Union bool
|
||||
Exception bool
|
||||
Fault bool
|
||||
Error *ErrorInfo
|
||||
}
|
||||
|
||||
// ErrorInfo describes an error shape's code and HTTP classification.
|
||||
type ErrorInfo struct {
|
||||
Code string `json:"code"`
|
||||
HTTPStatusCode int `json:"httpStatusCode"`
|
||||
SenderFault bool `json:"senderFault"`
|
||||
Code string
|
||||
HTTPStatusCode int
|
||||
SenderFault bool
|
||||
}
|
||||
|
||||
// ShapeRef names another shape and carries any wire binding specific to the
|
||||
// place where it is referenced.
|
||||
type ShapeRef struct {
|
||||
Shape string `json:"shape"`
|
||||
ResultWrapper string `json:"resultWrapper"`
|
||||
Location string `json:"location"`
|
||||
LocationName string `json:"locationName"`
|
||||
QueryName string `json:"queryName"`
|
||||
TimestampFormat string `json:"timestampFormat"`
|
||||
Flattened bool `json:"flattened"`
|
||||
Streaming bool `json:"streaming"`
|
||||
XMLAttribute bool `json:"xmlAttribute"`
|
||||
Shape string
|
||||
ResultWrapper string
|
||||
Location string
|
||||
LocationName string
|
||||
QueryName string
|
||||
TimestampFormat string
|
||||
Flattened bool
|
||||
Streaming bool
|
||||
XMLAttribute bool
|
||||
}
|
||||
|
||||
// Model is an indexed AWS service definition.
|
||||
@@ -118,13 +122,6 @@ type Model struct {
|
||||
shapes map[string]*Shape
|
||||
}
|
||||
|
||||
type modelDocument struct {
|
||||
Version string `json:"version"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Operations map[string]*Operation `json:"operations"`
|
||||
Shapes map[string]*Shape `json:"shapes"`
|
||||
}
|
||||
|
||||
type loadResult struct {
|
||||
model *Model
|
||||
err error
|
||||
@@ -132,31 +129,6 @@ type loadResult struct {
|
||||
|
||||
var modelCache sync.Map
|
||||
|
||||
var modelRoot = sync.OnceValues(resolveModelRoot)
|
||||
|
||||
func resolveModelRoot() (string, error) {
|
||||
if root := os.Getenv("GOMODCACHE"); root != "" {
|
||||
return filepath.Join(root, sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
if output, err := exec.Command("go", "env", "GOMODCACHE").Output(); err == nil {
|
||||
if root := string(output); root != "" {
|
||||
return filepath.Join(stringTrimSpace(root), sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
}
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("awsmodel: resolve Go module cache: %w", err)
|
||||
}
|
||||
return filepath.Join(currentUser.HomeDir, "go", "pkg", "mod", sourceSDKModule+"@"+SourceSDKVersion), nil
|
||||
}
|
||||
|
||||
func stringTrimSpace(value string) string {
|
||||
for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == ' ' || value[len(value)-1] == '\t') {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Services returns the supported service identifiers in stable order.
|
||||
func Services() []Service {
|
||||
services := make([]Service, 0, len(modelFiles))
|
||||
@@ -167,45 +139,21 @@ func Services() []Service {
|
||||
return services
|
||||
}
|
||||
|
||||
// Load parses and indexes the cached SDK model for service. Each service is
|
||||
// Load parses and indexes the embedded model for service. Each service is
|
||||
// parsed at most once and the resulting Model is safe for concurrent reads.
|
||||
func Load(service Service) (*Model, error) {
|
||||
path, ok := modelFiles[service]
|
||||
file, ok := modelFiles[service]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("awsmodel: unsupported service %q", service)
|
||||
}
|
||||
|
||||
loader, _ := modelCache.LoadOrStore(service, sync.OnceValue(func() loadResult {
|
||||
root, err := modelRoot()
|
||||
contents, err := readModel(file)
|
||||
if err != nil {
|
||||
return loadResult{err: err}
|
||||
return loadResult{err: fmt.Errorf("awsmodel: read %s model: %w", service, err)}
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(root, path))
|
||||
if err != nil {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: read cached %s model at %s: %w", service, filepath.Join(root, path), err)}
|
||||
}
|
||||
|
||||
var document modelDocument
|
||||
if err := json.Unmarshal(contents, &document); err != nil {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: parse %s model: %w", service, err)}
|
||||
}
|
||||
if document.Version != "2.0" {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: %s model version is %q, want %q", service, document.Version, "2.0")}
|
||||
}
|
||||
if len(document.Operations) == 0 || len(document.Shapes) == 0 {
|
||||
return loadResult{err: fmt.Errorf("awsmodel: %s model has no operations or shapes", service)}
|
||||
}
|
||||
|
||||
model := &Model{
|
||||
service: service,
|
||||
metadata: document.Metadata,
|
||||
operations: document.Operations,
|
||||
shapes: document.Shapes,
|
||||
}
|
||||
if err := model.validateReferences(); err != nil {
|
||||
return loadResult{err: err}
|
||||
}
|
||||
return loadResult{model: model}
|
||||
model, err := parseSmithyModel(service, contents)
|
||||
return loadResult{model: model, err: err}
|
||||
}))
|
||||
load, ok := loader.(func() loadResult)
|
||||
if !ok {
|
||||
@@ -215,10 +163,23 @@ func Load(service Service) (*Model, error) {
|
||||
return result.model, result.err
|
||||
}
|
||||
|
||||
func readModel(file string) ([]byte, error) {
|
||||
compressed, err := modelFS.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(compressed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// Service returns the identifier used to load the model.
|
||||
func (m *Model) Service() Service { return m.service }
|
||||
|
||||
// Metadata returns the service metadata from api-2.json.
|
||||
// Metadata returns the service metadata.
|
||||
func (m *Model) Metadata() Metadata { return m.metadata }
|
||||
|
||||
// Operation resolves an operation by its model key, such as DescribeInstances.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user