Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5529572761 | |||
| 5770a10090 | |||
| 382b9c2619 | |||
| 40a46c54cf | |||
| f99c861810 | |||
| ba4b8df554 | |||
| b3b066562e | |||
| 47fa76e09d | |||
| c4f92608e1 | |||
| 8409799cbf | |||
| db97a5a101 | |||
| 076949430c | |||
| 98a3f2b721 | |||
| d7f095fd39 | |||
| ff87f65a43 | |||
| 25143ed61a | |||
| 4de0195bda | |||
| 99357ad52b | |||
| 857fbf8a3f | |||
| 265f6fad19 | |||
| 261229b654 | |||
| b244de318c | |||
| 7c56855526 | |||
| cfc7b76cca | |||
| 144a449b82 | |||
| d6bfb4cf49 | |||
| 4a6ce6cbbc | |||
| 414a60697a | |||
| e6dcf8159c | |||
| 4e8e854fc0 | |||
| 15b712f8f5 | |||
| c395722344 | |||
| a69ed27dc4 | |||
| bcbe415288 | |||
| 33b9dbec40 | |||
| 9a8b63b127 | |||
| 9d7407a1f5 | |||
| 8fec37c313 | |||
| e276ccd4a1 | |||
| 11fdd89dbf | |||
| 2a0805b264 | |||
| 7d190fc1f1 | |||
| 4fbde4a665 | |||
| cf17866fed | |||
| 6af550fe52 | |||
| 46c0c44cc2 | |||
| c6841ce1f0 | |||
| 70007405c1 | |||
| 66bc117203 | |||
| 1743b60246 | |||
| ade060f384 | |||
| 92f1f97ad6 | |||
| f29f19a32f | |||
| 847a93fe3d | |||
| 687d390379 | |||
| 1ab714b125 | |||
| 6a873d3c3c | |||
| 10247a958c | |||
| 6f2035821f | |||
| 04c80f3897 | |||
| 211e2b8cfc | |||
| 74243730fa | |||
| 2230e92ec2 | |||
| c7513c0394 | |||
| c782585352 | |||
| c98715f7a3 | |||
| 14ba518186 | |||
| 36d5788923 | |||
| dc636cac63 | |||
| 8a1045b4e7 | |||
| 51d855aa80 | |||
| a06eccfdab | |||
| 520b2d1689 | |||
| 7bbf78df78 | |||
| 2b4ddad78a | |||
| 39fdd132ee | |||
| 524a7a519f | |||
| a3ea962d1e | |||
| 5f21fd27e3 | |||
| 1344a8e3ca | |||
| 810aa019bf | |||
| 610ee9bab0 | |||
| 04c000e7b9 | |||
| f4fe9cecb7 | |||
| e4b7e520ca | |||
| 45fdbc0444 | |||
| 5b2ca9373b | |||
| c4b284b8c9 | |||
| 363aa1c4ab | |||
| 485b828dde | |||
| 62b83e10fe | |||
| f67a326b97 | |||
| 5f60b2c7c7 | |||
| 666ea4bb4b | |||
| b3d1198d52 | |||
| 9a57fa77f9 | |||
| 8ddec0921f | |||
| ba0e96082a | |||
| a15084afd1 | |||
| e3fe9a634e | |||
| 69b717b790 | |||
| 6a91a8419b | |||
| 72ee50f6f3 | |||
| 957a5eac2a | |||
| c85376c5a2 | |||
| 143229ef6b | |||
| 95e51008af | |||
| 2b56d4e675 | |||
| e0f9a11987 | |||
| 9045f133c5 | |||
| 0465bd0a41 | |||
| df48c21ce1 | |||
| 43e8d65247 | |||
| e9b9600bbc | |||
| 9fe8c8b232 | |||
| 410faf3c83 | |||
| ba2621ded5 | |||
| bfad1dc004 | |||
| b3be052311 | |||
| 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,79 @@
|
||||
<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 Exoscale instance: the instance sits on the zone's public network with one interface, eth0, holding its own address and the default route. Manual Elastic IPs are delivered to eth0 and routed by the host into OVN through the spx-nat transit veth, and an Exoscale block storage volume backs /var/lib/spinifex.">
|
||||
<rect x="0" y="0" width="1020" height="836" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<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 EXOSCALE — 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 · Elastic IPs</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 Exoscale 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="260.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="176.0" 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="315.0" y="126" width="171.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="400.8" 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 Elastic IP</text>
|
||||
<rect x="495.6" y="126" width="205.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="598.4" 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" y1="170" x2="510" 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">EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK 91.92.116.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">gateway 91.92.116.1 · security group open to all</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">standard.huge</text>
|
||||
<text x="212" 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 · 8 vCPU / 32 GiB · Ubuntu 26.04</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" 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">eth0 — PUBLIC INTERFACE</text>
|
||||
<text x="482" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">the only NIC</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">91.92.116.250/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 · API :9999 · SSH · host masquerade</text>
|
||||
<rect x="518" y="298" width="434" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="538" 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">ELASTIC IPs — MANUAL</text>
|
||||
<text x="932" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">attached to the instance</text>
|
||||
<text x="538" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">89.145.163.1/32 → delivered to eth0</text>
|
||||
<text x="538" 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">the host routes each /32 into OVN, never owns it</text>
|
||||
<line x1="735" y1="406" x2="735" 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 · EIP/32 routed in · masquerade out eth0 for the rest</text>
|
||||
<line x1="510" y1="464" x2="510" 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="93.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="132.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw :9999</text>
|
||||
<rect x="188.0" y="530" width="58.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="217.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
|
||||
<rect x="255.0" y="530" width="44.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="277.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
|
||||
<rect x="308.0" y="530" width="44.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="330.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">NATS</text>
|
||||
<rect x="361.0" y="530" width="86.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="404.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
|
||||
<rect x="456.0" y="530" width="86.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="499.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
|
||||
<rect x="551.0" y="530" width="79.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="590.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">northstar</text>
|
||||
<rect x="639.0" y="530" width="72.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="675.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">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 (nested) · 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" fill-opacity="1.0" 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 172.31.0.0/16</text>
|
||||
<text x="300" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 172.31.0.4 → 89.145.163.1</text>
|
||||
<rect x="86" y="686" width="496" height="34" rx="9" fill="none" fill-opacity="1.0" 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 10.0.0.0/16</text>
|
||||
<text x="300" y="708" 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.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 STORAGE VOLUME</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 GiB+</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">virtio-blk → /dev/vdb</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">found by-id, defaults,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 manual Exoscale Elastic IP, delivered to eth0 addressed to itself — no private/public pair to translate.</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">The host routes it into OVN, so inbound and outbound are the same address and the guest sees its private IP only.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,123 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="796" viewBox="0 0 1020 796" role="img" aria-label="Spinifex across three Exoscale instances in one zone, spread by an anti-affinity group. Each has a public eth0 carrying its own Elastic IPs, a private-network eth1 for the cluster mesh, its own block storage volume and its share of the guests. Geneve, NATS JetStream, predastore erasure coding and the OVN raft join them east-west over the private network. This topology is not yet tested.">
|
||||
<rect x="0" y="0" width="1020" height="796" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<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 EXOSCALE — 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">anti-affinity group · one zone · not yet tested</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 Exoscale 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="260.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="176.0" 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="315.0" y="126" width="171.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="400.8" 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 Elastic IP</text>
|
||||
<rect x="495.6" y="126" width="205.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="598.4" 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" y1="170" x2="510" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28" y="188" width="964" height="440" 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">EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK + PRIVATE NETWORK</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">one security group · one anti-affinity group</text>
|
||||
<rect x="48" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="64" y="260" 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="332" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
|
||||
<line x1="64" y1="272" x2="332" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<rect x="64" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="78" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.116.250</text>
|
||||
<text x="78" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
|
||||
<rect x="64" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="78" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.11</text>
|
||||
<text x="78" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
|
||||
<rect x="64" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="78" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.163.1</text>
|
||||
<text x="78" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
|
||||
<rect x="64" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="89.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
|
||||
<rect x="124.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="152.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
|
||||
<rect x="190.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="212.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
|
||||
<rect x="64" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="86.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
|
||||
<rect x="117.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="159.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
|
||||
<rect x="210.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="252.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
|
||||
<rect x="64" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="76" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-23d2… 172.31.0.4 → 89.145.163.1</text>
|
||||
<rect x="64" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="76" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
|
||||
<rect x="360" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="376" y="260" 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="644" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
|
||||
<line x1="376" y1="272" x2="644" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<rect x="376" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="390" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.117.14</text>
|
||||
<text x="390" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
|
||||
<rect x="376" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="390" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.12</text>
|
||||
<text x="390" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
|
||||
<rect x="376" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="390" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.160.107</text>
|
||||
<text x="390" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
|
||||
<rect x="376" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="401.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
|
||||
<rect x="436.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="464.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
|
||||
<rect x="502.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="524.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
|
||||
<rect x="376" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="398.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
|
||||
<rect x="429.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="471.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
|
||||
<rect x="522.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="564.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
|
||||
<rect x="376" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="388" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-6b91… 172.31.0.9 → 89.145.160.107</text>
|
||||
<rect x="376" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="388" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
|
||||
<rect x="672" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
|
||||
<text x="688" y="260" 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="956" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
|
||||
<line x1="688" y1="272" x2="956" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
|
||||
<rect x="688" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="702" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.116.88</text>
|
||||
<text x="702" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
|
||||
<rect x="688" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="702" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.13</text>
|
||||
<text x="702" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
|
||||
<rect x="688" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
|
||||
<text x="702" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.162.131</text>
|
||||
<text x="702" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
|
||||
<rect x="688" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="713.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
|
||||
<rect x="748.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="776.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
|
||||
<rect x="814.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="836.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
|
||||
<rect x="688" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="710.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
|
||||
<rect x="741.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="783.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
|
||||
<rect x="834.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="876.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
|
||||
<rect x="688" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="700" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-0ae4… 172.31.1.6 → 89.145.162.131</text>
|
||||
<rect x="688" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="700" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
|
||||
<line x1="510" y1="628" x2="510" y2="644" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
|
||||
<rect x="28" y="646" width="964" height="70" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="48" y="676" 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, OVER THE EXOSCALE PRIVATE NETWORK</text>
|
||||
<text x="972" y="676" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">no Elastic IP is involved · MTU 1500</text>
|
||||
<rect x="46" y="684" width="124.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="108.0" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Geneve UDP 6081</text>
|
||||
<rect x="179.0" y="684" width="137.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="247.8" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">NATS JetStream R3</text>
|
||||
<rect x="325.6" y="684" width="144.4" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="397.8" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">predastore RS(2,1)</text>
|
||||
<rect x="479.0" y="684" width="151.2" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="554.6" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">OVN Northbound raft</text>
|
||||
<rect x="639.2" y="684" width="56.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
|
||||
<text x="667.2" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">OVSDB</text>
|
||||
<text x="28" y="744" 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' private-network interfaces — it never touches eth0 or an Elastic IP.</text>
|
||||
<text x="28" y="762" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Each node carries the Elastic IPs of its own guests. Moving one between nodes is an Exoscale API call, not yet automated.</text>
|
||||
<text x="28" y="780" 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; the anti-affinity group keeps them on different hypervisors.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
name: Dev Prerelease
|
||||
|
||||
# Publishes the dev channel that install.mulgadc.com already serves. setup.sh has
|
||||
# understood INSTALL_SPINIFEX_CHANNEL=dev since it was written and the install
|
||||
# Worker resolves that channel to the newest GitHub prerelease, but nothing has
|
||||
# ever produced one -- so /download/dev/amd64 returned 404. This is the producer.
|
||||
#
|
||||
# A separate workflow rather than conditionals in release.yml, because a dev build
|
||||
# is a different artifact and not a release with flags off: amd64 only, no ISO, no
|
||||
# AMI refresh, no bare-metal gate, and nothing that touches a self-hosted runner.
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
# Nothing here changes the binary, and a prerelease per docs commit would
|
||||
# churn the channel our contacts are installing from.
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Supersede rather than queue: on a run of merges to dev only the newest commit is
|
||||
# worth publishing, and the older build would land after it and become the channel.
|
||||
concurrency:
|
||||
group: dev-release
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
# Full history and tags, because the tag name is git describe and a shallow
|
||||
# clone has no tag to describe against.
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 0
|
||||
|
||||
# dev, not each repository's default branch, which is what release.yml takes.
|
||||
# A dev build of spinifex against main of the others is a pairing nobody runs.
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
ref: dev
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
ref: dev
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Northstar
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/northstar
|
||||
ref: dev
|
||||
path: northstar
|
||||
|
||||
- name: Checkout Bluebottle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/bluebottle
|
||||
ref: dev
|
||||
path: bluebottle
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
|
||||
# The tag carries the describe string so the commit is recoverable from the
|
||||
# version alone, and the -dev suffix is what release.yml's own convention
|
||||
# already treats as a prerelease. VERSION must equal the tag: the install
|
||||
# Worker derives the asset name from the tag it resolves.
|
||||
- name: Resolve the version
|
||||
id: version
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Describing against a dev tag would nest it, growing the name every release.
|
||||
TAG="$(git describe --tags --always --exclude '*-dev')-dev"
|
||||
case "$TAG" in
|
||||
v*) ;;
|
||||
*) echo "::error::git describe gave '$TAG', which is not a v-prefixed version"; exit 1 ;;
|
||||
esac
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::notice::$TAG already published; nothing to do"
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing $TAG"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.tag }}
|
||||
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
|
||||
|
||||
# --prerelease on create, never a later promote step. release.yml sets it in
|
||||
# a job that runs after its verification gates, which leaves a window where
|
||||
# the release is not flagged; GitHub's releases/latest excludes prereleases
|
||||
# only, so in that window a dev build is what every customer curl installs.
|
||||
- name: Publish the prerelease
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
working-directory: spinifex
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A file, not an inline string: the notes are Markdown, and leading
|
||||
# whitespace from indenting a shell literal turns them into code blocks.
|
||||
cat > "$RUNNER_TEMP/notes.md" <<EOF
|
||||
Automated dev-channel build of \`$GITHUB_SHA\`.
|
||||
|
||||
**Not a release.** amd64 only, no ISO and no system images, and it has not been through the release verification gates. Install it with:
|
||||
|
||||
\`\`\`
|
||||
curl -sfL https://install.mulgadc.com | sudo bash -s -- --channel dev
|
||||
\`\`\`
|
||||
EOF
|
||||
sed -i 's/^ //' "$RUNNER_TEMP/notes.md"
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--prerelease \
|
||||
--title "$TAG" \
|
||||
--notes-file "$RUNNER_TEMP/notes.md" \
|
||||
"dist/spinifex-$TAG-linux-amd64.tar.gz" \
|
||||
"dist/spinifex-$TAG-linux-amd64.tar.gz.sha256" \
|
||||
"scripts/setup.sh"
|
||||
|
||||
# The assertion that matters. A prerelease must never become the latest
|
||||
# release, because latest is what an unqualified curl | bash installs -- so
|
||||
# this checks the published state rather than trusting the flag we passed.
|
||||
- name: Assert the stable channel is untouched
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
latest="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
|
||||
if [ "$latest" = "$TAG" ]; then
|
||||
echo "::error::$TAG became the latest release: every customer curl | bash would install it"
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease
|
||||
exit 1
|
||||
fi
|
||||
echo "latest is still $latest"
|
||||
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" --jq 'map(select(.prerelease))[0].tag_name')"
|
||||
[ "$dev" = "$TAG" ] || { echo "::error::the dev channel resolves to $dev, not $TAG"; exit 1; }
|
||||
|
||||
# Kept rather than unbounded: the Worker reads the first page of releases to
|
||||
# resolve the channel, so enough prereleases would eventually push the real
|
||||
# ones off it, and each build carries two assets.
|
||||
#
|
||||
# Twenty, not five. A pinned dev tag is how an external evaluator installs a
|
||||
# specific build, and five is about a week of them -- short enough that the tag
|
||||
# someone was given stops resolving while they are still using it.
|
||||
- name: Prune old dev prereleases
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[20:] | .[].tag_name' \
|
||||
> stale.txt
|
||||
while read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
echo "deleting $tag"
|
||||
gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
done < stale.txt
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
INSTALLER_BIN="${RUNNER_TEMP}/bm-bins/spinifex-installer" \
|
||||
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
|
||||
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
|
||||
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always 2>/dev/null || echo dev)"
|
||||
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always --exclude '*-dev' 2>/dev/null || echo dev)"
|
||||
|
||||
- name: Write ePDU credentials
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
name: E2E Cloud Vendor Nightly
|
||||
|
||||
# Dispatched by the same Cloudflare Worker that drives e2e-nightly.yml, once per
|
||||
# ref, rather than by a `schedule:` here. GitHub registers a schedule only from
|
||||
# the default branch, so a cron in this file could never run `dev` and a feature
|
||||
# branch as separate nightlies, and it would always execute the default branch'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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
topologies:
|
||||
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal). One name runs one topology."
|
||||
required: false
|
||||
workbooks:
|
||||
description: "Space-separated workbooks (blank = the driver's default list)"
|
||||
required: false
|
||||
source:
|
||||
description: "tree (build this ref) or release (the published installer)"
|
||||
required: false
|
||||
default: tree
|
||||
channel:
|
||||
description: "With source=release: latest, or dev for the newest prerelease"
|
||||
required: false
|
||||
default: latest
|
||||
keep_on_fail:
|
||||
description: "Leave a failed topology up to inspect. It keeps billing."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
auth:
|
||||
description: "How nodes reach the OCI API: instance_principal (needs the tenancy dynamic group) or api_key (needs the credential hook). The vm-single-principal topology always uses instance_principal."
|
||||
required: false
|
||||
default: instance_principal
|
||||
type: choice
|
||||
options: [instance_principal, api_key]
|
||||
no_external_pool:
|
||||
description: "Form without the OCI allocator. Skips every workbook needing a public address. Not applied to vm-single-principal."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
runner:
|
||||
description: "Runner label (blank = the OCI_E2E_RUNNER variable, else ubuntu-26.04)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Every topology is built in one OCI compartment under one set of resource names,
|
||||
# so two runs at once would each see the other's instances and the sweep in
|
||||
# e2e-oci.sh could destroy a cluster the other run was still testing. Queue the
|
||||
# second rather than cancelling it: a dispatch is usually a verification run.
|
||||
concurrency:
|
||||
group: e2e-oci
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
oci:
|
||||
# Hosted by default: this job only drives Terraform and SSH, and the work
|
||||
# happens on OCI, so nothing here needs a hypervisor. Overridable because the
|
||||
# argument for a per-provider self-hosted runner is control, not capacity --
|
||||
# a dedicated label keeps a two-hour cloud wait off the hypervisor labels and
|
||||
# lifts the six-hour cap a hosted job carries.
|
||||
runs-on: ${{ inputs.runner || vars.OCI_E2E_RUNNER || 'ubuntu-26.04' }}
|
||||
# Three topologies including a bare-metal build, each with its own install,
|
||||
# formation and workbook pass. The sweep still runs if this fires.
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
# Set here rather than in a job-level env block: the runner context is not
|
||||
# available at job level, only from a step onwards, and a workflow that
|
||||
# references it there is rejected as invalid before any job starts.
|
||||
#
|
||||
# Outside github.workspace on purpose. The checkout wipes untracked files, so
|
||||
# on a persistent runner it would delete the Terraform state of a killed run
|
||||
# before the next sweep could read it, leaving instances nothing can find.
|
||||
- name: Put the Terraform state outside the checkout
|
||||
run: echo "OCI_STATE_ROOT=$RUNNER_WORKSPACE/oci-state" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
# mulga alone. It is the only private repository of the six, and the
|
||||
# five that build-install-artifacts.sh clones are public, so the builder
|
||||
# needs no credential and this token is not widened to reach them.
|
||||
repositories: mulga
|
||||
|
||||
# build-install-artifacts.sh lives in mulga. The workbooks and their driver
|
||||
# are in spinifex, so only the one directory is needed here.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
path: mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
|
||||
- uses: hashicorp/setup-terraform@v4
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
# Written to the runner's own filesystem and nowhere else. The OCI API
|
||||
# credential is not here at all: it reaches Terraform as OCI_PRIVATE_KEY in
|
||||
# the environment, so no key file is ever created.
|
||||
- name: Install the throwaway SSH key
|
||||
env:
|
||||
OCI_E2E_SSH_PRIVATE_KEY: ${{ secrets.OCI_E2E_SSH_PRIVATE_KEY }}
|
||||
OCI_E2E_SSH_PUBLIC_KEY: ${{ secrets.OCI_E2E_SSH_PUBLIC_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$OCI_E2E_SSH_PRIVATE_KEY" > ~/.ssh/oci-spx
|
||||
printf '%s\n' "$OCI_E2E_SSH_PUBLIC_KEY" > ~/.ssh/oci-spx.pub
|
||||
chmod 600 ~/.ssh/oci-spx
|
||||
ssh-keygen -y -f ~/.ssh/oci-spx > /dev/null
|
||||
|
||||
- name: Run the OCI topologies
|
||||
working-directory: spinifex/scripts/terraform/oci-spx
|
||||
env:
|
||||
OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }}
|
||||
OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }}
|
||||
OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }}
|
||||
OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }}
|
||||
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
|
||||
TF_VAR_compartment_ocid: ${{ secrets.OCI_COMPARTMENT_OCID }}
|
||||
MULGA_ROOT: ${{ github.workspace }}/mulga
|
||||
SPX_REF: ${{ github.ref_name }}
|
||||
OCI_TOPOLOGIES: ${{ inputs.topologies }}
|
||||
OCI_SOURCE: ${{ inputs.source }}
|
||||
OCI_CHANNEL: ${{ inputs.channel }}
|
||||
# Independent of the pool, deliberately. These were one switch, which made
|
||||
# the combination we actually need -- an API key with the allocator running
|
||||
# -- impossible to ask for, so public-IP allocation went untested.
|
||||
OCI_INSTANCE_PRINCIPAL: ${{ inputs.auth == 'api_key' && '0' || '1' }}
|
||||
# Skipping the pool records the allocator and every public-address workbook
|
||||
# as SKIPPED, so a green run with this set is not a claim about guest
|
||||
# networking. It exists for a formation-only check, not as a way past a
|
||||
# missing credential.
|
||||
OCI_NO_EXTERNAL_POOL: ${{ inputs.no_external_pool && '1' || '0' }}
|
||||
# Only an API-key run needs one: an instance principal is the node's own
|
||||
# certificate, so there is nothing to hand over. Unset is not an error, and
|
||||
# the allocator gate then fails rather than passing a node that cannot
|
||||
# authenticate.
|
||||
OCI_CREDENTIAL_HOOK: ${{ inputs.auth == 'api_key' && vars.OCI_CREDENTIAL_HOOK || '' }}
|
||||
# vm-single-principal builds in its own tenancy. The dynamic group it needs
|
||||
# is a tenancy-root resource, and the shared test tenancy grants us only
|
||||
# compartment rights, so that topology can never pass there however correct
|
||||
# the code is. Unset skips it rather than spending a provision to fail.
|
||||
OCI_PRINCIPAL_TENANCY_OCID: ${{ secrets.OCI_PRINCIPAL_TENANCY_OCID }}
|
||||
OCI_PRINCIPAL_USER_OCID: ${{ secrets.OCI_PRINCIPAL_USER_OCID }}
|
||||
OCI_PRINCIPAL_FINGERPRINT: ${{ secrets.OCI_PRINCIPAL_FINGERPRINT }}
|
||||
OCI_PRINCIPAL_PRIVATE_KEY: ${{ secrets.OCI_PRINCIPAL_PRIVATE_KEY }}
|
||||
OCI_PRINCIPAL_COMPARTMENT_OCID: ${{ secrets.OCI_PRINCIPAL_COMPARTMENT_OCID }}
|
||||
OCI_PRINCIPAL_REGION: ${{ vars.OCI_PRINCIPAL_REGION }}
|
||||
# Service limits are per tenancy, so the shape the other topologies use
|
||||
# can have zero quota in this one. Ours has no E6 cores at all, and the
|
||||
# apply then fails before anything about the principal is exercised.
|
||||
OCI_PRINCIPAL_SHAPE: ${{ vars.OCI_PRINCIPAL_SHAPE }}
|
||||
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci
|
||||
OCI_KEEP_ON_FAIL: ${{ inputs.keep_on_fail && '1' || '0' }}
|
||||
# Through the environment, never interpolated into the script below: a
|
||||
# dispatch input is attacker-controlled text as far as the shell is
|
||||
# concerned, and a quote in it would otherwise end the string.
|
||||
WORKBOOKS_INPUT: ${{ inputs.workbooks }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# WORKBOOKS is exported only when the dispatch named some: the harness
|
||||
# distinguishes unset (the driver's default list) from empty (run none).
|
||||
if [ -n "$WORKBOOKS_INPUT" ]; then export WORKBOOKS="$WORKBOOKS_INPUT"; fi
|
||||
./e2e-oci.sh 2>&1 | tee "$RUNNER_TEMP/e2e-oci.log"
|
||||
exit "${PIPESTATUS[0]}"
|
||||
|
||||
# Before the sweep, which destroys the state the tables are read from, and
|
||||
# always(): a run whose verdict is a failure is exactly the one somebody
|
||||
# wants a table for. The successful path writes the same tables itself, so
|
||||
# this step is for the run that did not get there.
|
||||
#
|
||||
# The markdown lives in the script rather than here. A heredoc in a YAML
|
||||
# string is the one place in this repository nothing can lint or run.
|
||||
- name: Publish the result tables
|
||||
if: always()
|
||||
working-directory: spinifex/scripts/terraform/oci-spx
|
||||
env:
|
||||
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
|
||||
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci
|
||||
OCI_TOPOLOGIES: ${{ inputs.topologies }}
|
||||
OCI_SOURCE: ${{ inputs.source }}
|
||||
OCI_CHANNEL: ${{ inputs.channel }}
|
||||
SPX_REF: ${{ github.ref_name }}
|
||||
# The same two the run step passes. The header states what was and was not
|
||||
# tested, so a summary rendered without them would claim more than the run.
|
||||
OCI_INSTANCE_PRINCIPAL: ${{ inputs.auth == 'api_key' && '0' || '1' }}
|
||||
OCI_NO_EXTERNAL_POOL: ${{ inputs.no_external_pool && '1' || '0' }}
|
||||
run: ./e2e-oci.sh --summary-only
|
||||
|
||||
# The sweep below deletes the per-topology state directories, so anything
|
||||
# still only in one of them at that point is unrecoverable -- and a failing
|
||||
# workbook writes its assertion output to exactly there. Copying into the
|
||||
# artifact directory first is what makes a failure diagnosable by whoever
|
||||
# reads the run page, rather than only on the runner that produced it.
|
||||
- name: Collect the per-topology logs
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
dest="$RUNNER_TEMP/e2e-oci"
|
||||
mkdir -p "$dest"
|
||||
# The topologies this run drove, taken from the per-topology log the
|
||||
# harness writes rather than from the dispatch input: a run that names
|
||||
# none gets the harness's own default, and reading that back here would
|
||||
# mean a second copy of the list to disagree with the first. A state
|
||||
# directory left by an earlier run is then not collected, so the artifact
|
||||
# cannot show a topology this run never attempted.
|
||||
topologies=""
|
||||
for log in "$dest"/*.log; do
|
||||
[ -f "$log" ] || continue
|
||||
name="${log##*/}"; name="${name%.log}"
|
||||
[ -d "$OCI_STATE_ROOT/.validate-$name" ] && topologies="$topologies $name"
|
||||
done
|
||||
echo "collecting:${topologies:- nothing}"
|
||||
for topology in $topologies; do
|
||||
for file in "$OCI_STATE_ROOT/.validate-$topology"/*.log \
|
||||
"$OCI_STATE_ROOT/.validate-$topology"/*.txt \
|
||||
"$OCI_STATE_ROOT/.validate-$topology"/*.tsv; do
|
||||
[ -f "$file" ] || continue
|
||||
# The hook handles a private key and the one thing known about an
|
||||
# operator's executable is that it is not ours to publish.
|
||||
case "${file##*/}" in credential-hook.log) continue ;; esac
|
||||
cp -- "$file" "$dest/$topology-${file##*/}"
|
||||
done
|
||||
done
|
||||
ls -l "$dest"
|
||||
|
||||
# always(), and before the artifact upload: an OCI bare-metal host left
|
||||
# running by a cancelled or timed-out job is the most expensive way this
|
||||
# workflow can fail. The sweep works from the Terraform state files, so it
|
||||
# can only destroy what this run created.
|
||||
- name: Sweep anything left behind
|
||||
if: always() && !inputs.keep_on_fail
|
||||
working-directory: spinifex/scripts/terraform/oci-spx
|
||||
env:
|
||||
OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }}
|
||||
OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }}
|
||||
OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }}
|
||||
OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }}
|
||||
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
|
||||
TF_VAR_compartment_ocid: ${{ secrets.OCI_COMPARTMENT_OCID }}
|
||||
# The sweep destroys by state file, so it needs whichever tenancy each
|
||||
# topology built in. Without these a leftover principal deployment is
|
||||
# swept against a tenancy that never held it and reported clean.
|
||||
OCI_PRINCIPAL_TENANCY_OCID: ${{ secrets.OCI_PRINCIPAL_TENANCY_OCID }}
|
||||
OCI_PRINCIPAL_USER_OCID: ${{ secrets.OCI_PRINCIPAL_USER_OCID }}
|
||||
OCI_PRINCIPAL_FINGERPRINT: ${{ secrets.OCI_PRINCIPAL_FINGERPRINT }}
|
||||
OCI_PRINCIPAL_PRIVATE_KEY: ${{ secrets.OCI_PRINCIPAL_PRIVATE_KEY }}
|
||||
OCI_PRINCIPAL_COMPARTMENT_OCID: ${{ secrets.OCI_PRINCIPAL_COMPARTMENT_OCID }}
|
||||
OCI_PRINCIPAL_REGION: ${{ vars.OCI_PRINCIPAL_REGION }}
|
||||
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci-sweep
|
||||
run: ./e2e-oci.sh --sweep-only
|
||||
|
||||
# Explicit, not left to the runner's own cleanup: a hosted runner is
|
||||
# discarded after the job, but a self-hosted one keeps $HOME, so the key
|
||||
# would outlive the run that needed it and sit there between nightlies.
|
||||
- name: Remove the throwaway SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/oci-spx ~/.ssh/oci-spx.pub
|
||||
|
||||
# Named extensions rather than a directory glob: the artifact directory also
|
||||
# holds the distro tarball and the test archive, which are hundreds of
|
||||
# megabytes and reproducible from the ref. Everything here is a file the
|
||||
# collect step above chose, so terraform.tfstate cannot reach an artifact.
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-oci-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/e2e-oci.log
|
||||
${{ runner.temp }}/e2e-oci/*.log
|
||||
${{ runner.temp }}/e2e-oci/verdict.txt
|
||||
${{ runner.temp }}/e2e-oci/summary.md
|
||||
${{ runner.temp }}/e2e-oci/*.txt
|
||||
${{ runner.temp }}/e2e-oci/*.tsv
|
||||
${{ runner.temp }}/e2e-oci-sweep/*.log
|
||||
retention-days: 14
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.27.0"
|
||||
go-version-file: spinifex/go.mod
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
allow-serial-runners: true
|
||||
|
||||
issues:
|
||||
max-same-issues: 10
|
||||
|
||||
@@ -56,7 +59,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:
|
||||
@@ -143,15 +145,11 @@ linters:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -ST1000 # package comment
|
||||
- -ST1003 # naming conventions (Id→ID etc.) — too noisy to retrofit
|
||||
- -ST1012 # error var naming — errname covers this + error type suffixes
|
||||
- -ST1013 # HTTP status constants — usestdlibvars is broader
|
||||
- -ST1016 # receiver name consistency (too noisy to retrofit)
|
||||
- -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.
|
||||
@@ -57,7 +57,7 @@ build-ui:
|
||||
cd spinifex/services/spinifexui/frontend && pnpm build
|
||||
|
||||
# GO commands
|
||||
VERSION ?= $(shell git describe --tags --always --dirty)
|
||||
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSION) -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=$(COMMIT)
|
||||
|
||||
@@ -149,21 +149,39 @@ 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.
|
||||
# manifest-check manifest-lint test-package-check removed, can add back later
|
||||
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 lint govulncheck test-cover diff-coverage 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/install-node.sh scripts/smoke-test.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 +203,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
|
||||
@@ -242,6 +262,17 @@ test-cover:
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
|
||||
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
|
||||
|
||||
# Refresh the README coverage badge. Measured as awesome-go documents
|
||||
# (-coverpkg=./... over ./...), with cmd/ entrypoints left out of the total.
|
||||
coverage-badge:
|
||||
@echo -e "\n....Measuring whole-module coverage for the README badge...."
|
||||
LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 600s -covermode=atomic -coverpkg=./... -coverprofile=coverage-badge.out ./...
|
||||
@grep -v '/cmd/' coverage-badge.out > coverage-badge-nocmd.out
|
||||
@pct=$$(go tool cover -func=coverage-badge-nocmd.out | tail -1 | awk '{print $$NF}' | tr -d '%'); \
|
||||
color=$$(awk -v p="$$pct" 'BEGIN { print (p >= 80) ? "brightgreen" : (p >= 70) ? "yellow" : "red" }'); \
|
||||
sed -i -E "s|badge/coverage-[0-9.]+%25-[a-z]+|badge/coverage-$${pct}%25-$${color}|" README.md; \
|
||||
echo "Coverage $${pct}% ($${color}); README badge updated"
|
||||
|
||||
# Run unit tests with race detector
|
||||
test-race:
|
||||
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
|
||||
@@ -298,6 +329,17 @@ bench:
|
||||
@echo -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
|
||||
LOG_IGNORE=1 go test -benchmem -run=. -bench=. ./...
|
||||
|
||||
# Fuzz every Fuzz* target for FUZZTIME each; -fuzz takes one target per run.
|
||||
# Plain `go test` already replays the seeds and testdata/fuzz on preflight.
|
||||
FUZZTIME ?= 30s
|
||||
fuzz:
|
||||
@set -e; grep -rlE '^func Fuzz\w+\(' --include='*_test.go' spinifex cmd internal | while read -r file; do \
|
||||
for name in $$(grep -oE '^func Fuzz\w+' "$$file" | cut -d' ' -f2); do \
|
||||
echo -e "\n....Fuzzing $$name ($$(dirname $$file)) for $(FUZZTIME)...."; \
|
||||
LOG_IGNORE=1 go test -run='^$$' -fuzz="^$$name$$" -fuzztime=$(FUZZTIME) ./$$(dirname $$file); \
|
||||
done; \
|
||||
done
|
||||
|
||||
# Fast iteration: build + install binary + restart all services.
|
||||
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
|
||||
# input timestamps drive a rebuild only if anything actually changed. On a fresh
|
||||
@@ -433,7 +475,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover coverage-badge test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
<a href="https://pkg.go.dev/github.com/mulgadc/spinifex"><img src="https://img.shields.io/badge/pkg.go.dev-reference-007D9C?style=flat-square&logo=go&logoColor=white" alt="Go Reference"></a>
|
||||
<a href="https://github.com/mulgadc/spinifex/blob/main/Makefile"><img src="https://img.shields.io/badge/coverage-82.9%25-brightgreen?style=flat-square" alt="Coverage"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
|
||||
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/home-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -870,7 +870,7 @@ func fireInstallCallback() {
|
||||
return
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url) //nolint:noctx // installer has no context; best-effort fire-and-forget
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
slog.Warn("fireInstallCallback: request failed", "url", url, "err", err)
|
||||
return
|
||||
|
||||
@@ -220,7 +220,8 @@ func newAgent(cfg config, cp controlPlane, probe *engineProbe) (*Agent, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Does not wait for IMDS: the register loop rides out a datapath still coming up.
|
||||
// New builds the agent from cfg, failing if no gateway URL is set. It does not
|
||||
// wait for IMDS: the register loop rides out a datapath still coming up.
|
||||
func New(cfg config) (*Agent, error) {
|
||||
if cfg.GatewayURL == "" {
|
||||
return nil, fmt.Errorf("no gateway URL configured (RDS_GATEWAY_URL)")
|
||||
@@ -365,16 +366,16 @@ func unescapeMountField(field string) string {
|
||||
|
||||
// Boot-retry bounds. Tight to start, since the usual cause is a control plane
|
||||
// still creating this instance's record, and capped so an outage does not turn
|
||||
// a booting fleet into a retry storm.
|
||||
const (
|
||||
// a booting fleet into a retry storm. Vars so tests can shorten the wait.
|
||||
var (
|
||||
retryMin = 1 * time.Second
|
||||
retryMax = 30 * time.Second
|
||||
|
||||
// With the production backoff, attempt five is about 15 seconds into the
|
||||
// failure and still precedes the first 30-second heartbeat.
|
||||
retryErrorAttempt = 5
|
||||
)
|
||||
|
||||
// With the production backoff, attempt five is about 15 seconds into the
|
||||
// failure and still precedes the first 30-second heartbeat.
|
||||
const retryErrorAttempt = 5
|
||||
|
||||
// Wrapping an error in this stops retryObserved. For the failures the control
|
||||
// plane decides from its own record, no retry can change the answer, and looping
|
||||
// on one only keeps the caller blocked.
|
||||
|
||||
@@ -948,8 +948,8 @@ func TestMariaDBProbe_LiveButUnreachableEngineResetsTheRollbackDeadline(t *testi
|
||||
recovery := &fakeRecovery{restored: true}
|
||||
guard := newTestGuard(recovery, probe)
|
||||
// Bounded so the deadline-resetting loop cannot run forever if it stops
|
||||
// resetting; the assertion is that the rollback was never reached.
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
// resetting; four rollback budgets, so a missed reset would roll back.
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
guard.Run(ctx)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain shortens the boot-retry backoff for the whole package: the tests
|
||||
// assert which calls are retried, not how long the agent waits between them.
|
||||
func TestMain(m *testing.M) {
|
||||
retryMin, retryMax = time.Millisecond, time.Millisecond
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -460,8 +460,8 @@ func TestParamGuard_LeavesARecoveringEngineAlone(t *testing.T) {
|
||||
engine := &fakeRecovery{restored: true}
|
||||
guard := newTestGuard(engine, stubProbe(t, 1, nil))
|
||||
// Bounded so the deadline-resetting loop cannot run forever if it stops
|
||||
// resetting; the assertion is that the rollback was never reached.
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
// resetting; four rollback budgets, so a missed reset would roll back.
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
guard.Run(ctx)
|
||||
|
||||
|
||||
@@ -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.")
|
||||
@@ -414,7 +425,7 @@ func init() {
|
||||
adminJoinCmd.MarkFlagRequired("host")
|
||||
adminJoinCmd.MarkFlagRequired("token")
|
||||
|
||||
imagesImportCmd.Flags().String("tmp-dir", os.TempDir(), "Temporary directory for image import processing")
|
||||
imagesImportCmd.Flags().String("tmp-dir", "", "Scratch directory for extracting the image (default: <spinifex-dir>/images); must be disk-backed, not tmpfs")
|
||||
|
||||
imagesImportCmd.Flags().String("name", "", "Import specified image by name")
|
||||
imagesImportCmd.Flags().String("ami-name", "", "Override the registered AMI name (DescribeImages name). Defaults to ami-{distro}-{version}-{arch}. Use for locally-built appliances (e.g. --ami-name spinifex-eks-node).")
|
||||
@@ -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,11 +479,112 @@ 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
|
||||
|
||||
var imageFile string
|
||||
var imageStat os.FileInfo
|
||||
var err error
|
||||
|
||||
forceCmd, _ := cmd.Flags().GetBool("force")
|
||||
@@ -503,6 +619,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 +719,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,45 +736,75 @@ 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)
|
||||
}
|
||||
|
||||
// An empty --tmp-dir scratches beside the catalog: disk-backed, unlike a
|
||||
// tmpfs /tmp, and already root:spinifex 0770.
|
||||
scratchRoot := ostmpDir
|
||||
if scratchRoot == "" {
|
||||
scratchRoot = imageDir
|
||||
}
|
||||
|
||||
extractedImagePath, cleanupScratch, err := extractToScratch(imageFile, scratchRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// os.Exit skips deferred calls, so scratch is removed before any exit.
|
||||
volumeId, err := registerExtractedImage(image, amiNameOverride, imagePath, extractedImagePath, sourceDigest)
|
||||
cleanupScratch()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// extractToScratch extracts imageFile into a fresh hidden directory under
|
||||
// scratchRoot and returns the extracted path with a cleanup that removes that
|
||||
// directory. On error the directory is already removed.
|
||||
func extractToScratch(imageFile, scratchRoot string) (string, func(), error) {
|
||||
scratchDir, err := os.MkdirTemp(scratchRoot, ".import-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("create scratch dir under %s: %w", scratchRoot, err)
|
||||
}
|
||||
cleanup := func() {
|
||||
if err := os.RemoveAll(scratchDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not remove scratch dir %s: %v\n", scratchDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
|
||||
tmpDir, err := os.MkdirTemp(ostmpDir, "spinifex-image-tmp-*")
|
||||
|
||||
extracted, err := utils.ExtractDiskImageFromFile(imageFile, scratchDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not create temp dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("extract image: %w", err)
|
||||
}
|
||||
return extracted, cleanup, nil
|
||||
}
|
||||
|
||||
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
|
||||
// registerExtractedImage writes the AMI manifest into imagePath, imports
|
||||
// extractedImagePath into storage and registers the AMI, returning its ID.
|
||||
func registerExtractedImage(image utils.Images, amiNameOverride, imagePath, extractedImagePath string, sourceDigest ebsmetadata.ImageDigest) (string, error) {
|
||||
imageStat, err := os.Stat(extractedImagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
imageStat, err = os.Stat(extractedImagePath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not stat image: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("stat image: %w", err)
|
||||
}
|
||||
|
||||
// Describe the image/AMI as the control-plane document it will become.
|
||||
@@ -660,7 +820,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 +829,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)
|
||||
@@ -682,24 +843,19 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// Save as JSON
|
||||
jsonData, err := json.Marshal(ami)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not marshal manifest: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
manifestFilename := fmt.Sprintf("%s/%s.json", imagePath, ami.Name)
|
||||
// Write to file
|
||||
err = os.WriteFile(manifestFilename, jsonData, 0600)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not write manifest: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
appConfig, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not connect to the cluster: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("connect to the cluster: %w", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
node := appConfig.Nodes[appConfig.Node]
|
||||
@@ -716,8 +872,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
Progress: os.Stdout,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not import image: %v\n", err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("import image: %w", err)
|
||||
}
|
||||
|
||||
// admin.ImportImage only wrote the provider's half of the snapshot (its
|
||||
@@ -725,8 +880,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// DescribeSnapshots and GetAMISourceVolumeID cannot resolve the import.
|
||||
metaStore := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
|
||||
if err := registerImportedAMISnapshot(metaStore, node.Predastore.Bucket, ami, node.AZ, node.Viperblock.EncryptionKeyFile != ""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Imported %s but could not register its snapshot: %v\n", volumeId, err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("imported %s but could not register its snapshot: %w", volumeId, err)
|
||||
}
|
||||
|
||||
// The document is what DescribeImages enumerates, so it is written last:
|
||||
@@ -734,11 +888,10 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// partway leaves no half-registered image behind.
|
||||
ami.State = "available"
|
||||
if err := ebsmetadata.NewStore(metaStore, node.Predastore.Bucket).PutAMI(context.Background(), ami); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Imported %s but could not register it: %v\n", volumeId, err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("imported %s but could not register it: %w", volumeId, err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
|
||||
return volumeId, nil
|
||||
}
|
||||
|
||||
// registerImportedAMISnapshot writes the EC2 control plane's snapshot document
|
||||
@@ -1001,6 +1154,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 +1237,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 +1288,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 +1354,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 +1392,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 +1414,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 +1682,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 +1751,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 +1904,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 +2659,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 +2878,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)
|
||||
@@ -2758,7 +2994,6 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
|
||||
endpointHost = h
|
||||
}
|
||||
}
|
||||
endpointURL := "https://" + net.JoinHostPort(endpointHost, "9999")
|
||||
|
||||
credPath := filepath.Join(homeDir, ".aws", "credentials")
|
||||
configPath := filepath.Join(homeDir, ".aws", "config")
|
||||
@@ -2773,12 +3008,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
|
||||
if region == "" {
|
||||
region = "ap-southeast-2"
|
||||
}
|
||||
if err := admin.UpdateAWSINIFile(configPath, "profile "+profileName, map[string]string{
|
||||
"region": region,
|
||||
"endpoint_url": endpointURL,
|
||||
"ca_bundle": certPath,
|
||||
"output": "json",
|
||||
}); err != nil {
|
||||
if err := admin.WriteAWSProfileConfig(configPath, profileName, region, certPath, endpointHost); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not update AWS config: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -3032,18 +3262,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
|
||||
@@ -3294,11 +3517,11 @@ func resolvePublicPoolFlags(source, poolRange, bindBridge, gateway, defaultBindB
|
||||
if gateway == "" {
|
||||
return "", "", "", "", fmt.Errorf("--external-gateway is required with --external-source=static")
|
||||
}
|
||||
parts := strings.SplitN(poolRange, "-", 2)
|
||||
if len(parts) != 2 || net.ParseIP(parts[0]) == nil || net.ParseIP(parts[1]) == nil {
|
||||
var ok bool
|
||||
start, end, ok = strings.Cut(poolRange, "-")
|
||||
if !ok || net.ParseIP(start) == nil || net.ParseIP(end) == nil {
|
||||
return "", "", "", "", fmt.Errorf("--external-pool must be start-end IPs (e.g., 192.168.1.150-192.168.1.250), got: %s", poolRange)
|
||||
}
|
||||
start, end = parts[0], parts[1]
|
||||
default:
|
||||
return "", "", "", "", fmt.Errorf("--external-source must be 'static' or 'dhcp', got: %s", source)
|
||||
}
|
||||
@@ -3744,7 +3967,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)
|
||||
|
||||
@@ -3,7 +3,8 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
@@ -166,11 +167,7 @@ func printAccountQuota(resp *gateway.AccountQuotaResponse) {
|
||||
fmt.Printf("%-16s %10s %s\n", "DIMENSION", "LIMIT", "SOURCE")
|
||||
fmt.Printf("%-16s %10s %s\n", "---------", "-----", "------")
|
||||
|
||||
names := make([]string, 0, len(resp.Limits))
|
||||
for name := range resp.Limits {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
names := slices.Sorted(maps.Keys(resp.Limits))
|
||||
for _, name := range names {
|
||||
limit := strconv.Itoa(resp.Limits[name])
|
||||
if resp.Limits[name] == handlers_quota.Unlimited {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func readRawDiskFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "unit-test-disk-image.raw"))
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func gzipData(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
_, err := gw.Write(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func qcow2Data(t *testing.T, raw []byte) []byte {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("qemu-img"); err != nil {
|
||||
t.Skip("qemu-img not found")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
rawPath := filepath.Join(dir, "src.raw")
|
||||
qcowPath := filepath.Join(dir, "src.qcow2")
|
||||
require.NoError(t, os.WriteFile(rawPath, raw, 0o600))
|
||||
out, err := exec.Command("qemu-img", "convert", "-f", "raw", "-O", "qcow2", rawPath, qcowPath).CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
data, err := os.ReadFile(qcowPath)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func dirNames(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// The catalog dir doubles as the download cache, so the source lives there
|
||||
// as it does for a --name import, with the images dir as the scratch root.
|
||||
func TestExtractToScratch_LeavesNoIntermediateInCatalog(t *testing.T) {
|
||||
raw := readRawDiskFixture(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
source string
|
||||
payload func(t *testing.T) []byte
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "gzip raw is decompressed into scratch",
|
||||
source: "disk.raw.gz",
|
||||
payload: func(t *testing.T) []byte { return gzipData(t, raw) },
|
||||
},
|
||||
{
|
||||
name: "qcow2 is converted into scratch",
|
||||
source: "disk.qcow2",
|
||||
payload: func(t *testing.T) []byte { return qcow2Data(t, raw) },
|
||||
},
|
||||
{
|
||||
name: "intermediate that fails the disk sniff is removed",
|
||||
source: "disk.raw.gz",
|
||||
payload: func(t *testing.T) []byte { return gzipData(t, []byte("not a disk image")) },
|
||||
wantErr: "extract image",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
imagesDir := t.TempDir()
|
||||
catalogDir := filepath.Join(imagesDir, "debian", "13", "x86_64")
|
||||
require.NoError(t, os.MkdirAll(catalogDir, 0o770))
|
||||
source := filepath.Join(catalogDir, tc.source)
|
||||
require.NoError(t, os.WriteFile(source, tc.payload(t), 0o600))
|
||||
|
||||
extracted, cleanup, err := extractToScratch(source, imagesDir)
|
||||
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
assert.Nil(t, cleanup)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
scratchDir := filepath.Dir(extracted)
|
||||
assert.Equal(t, imagesDir, filepath.Dir(scratchDir))
|
||||
assert.True(t, strings.HasPrefix(filepath.Base(scratchDir), ".import-"), "extracted into %s", extracted)
|
||||
assert.FileExists(t, extracted)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"debian"}, dirNames(t, imagesDir), "scratch dir must be removed")
|
||||
assert.Equal(t, []string{tc.source}, dirNames(t, catalogDir), "catalog must hold only the source")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ func TestCanaryRoundTrip_WritesADistinctValueEachRun(t *testing.T) {
|
||||
// A deleted bucket stands in for the unreachable-store case: the probe must
|
||||
// report an error rather than treating a failed write as success.
|
||||
func TestCanaryRoundTrip_FailsWhenBucketIsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
kv, js := newCanaryBucket(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -420,11 +421,7 @@ func hashStoredMsg(m *jetstream.RawStreamMsg) string {
|
||||
field([]byte(m.Subject))
|
||||
binary.BigEndian.PutUint64(n[:], uint64(m.Time.UnixNano()))
|
||||
h.Write(n[:])
|
||||
keys := make([]string, 0, len(m.Header))
|
||||
for k := range m.Header {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
keys := slices.Sorted(maps.Keys(m.Header))
|
||||
for _, k := range keys {
|
||||
field([]byte(k))
|
||||
for _, v := range m.Header[k] {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -153,11 +154,7 @@ func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) {
|
||||
byName[d.Name] = append(byName[d.Name], v)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(byName))
|
||||
for name := range byName {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
names := slices.Sorted(maps.Keys(byName))
|
||||
|
||||
var res kvCompareResult
|
||||
for _, name := range names {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -215,11 +215,15 @@ func TestKVDigestClusteredReplicas(t *testing.T) {
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Each attempt gets its own deadline: before a meta leader is elected a
|
||||
// create blocks for the client's default timeout instead of failing fast.
|
||||
var kv jetstream.KeyValue
|
||||
require.Eventually(t, func() bool {
|
||||
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
|
||||
defer cancel()
|
||||
kv, err = js.CreateKeyValue(attemptCtx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
|
||||
return err == nil
|
||||
}, 30*time.Second, 250*time.Millisecond, "clustered KV create: %v", err)
|
||||
}, 30*time.Second, 20*time.Millisecond, "clustered KV create: %v", err)
|
||||
for i := range 5 {
|
||||
_, err := kv.PutString(ctx, fmt.Sprintf("k%d", i%2), fmt.Sprintf("v%d", i))
|
||||
require.NoError(t, err)
|
||||
@@ -279,10 +283,12 @@ func TestKVDigestIgnoresExpiry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: time.Second})
|
||||
const ttl = 200 * time.Millisecond
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: ttl})
|
||||
require.NoError(t, err)
|
||||
_, err = kv.PutString(ctx, "k", "v")
|
||||
require.NoError(t, err)
|
||||
putAt := time.Now()
|
||||
|
||||
work := t.TempDir()
|
||||
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
|
||||
@@ -291,7 +297,9 @@ func TestKVDigestIgnoresExpiry(t *testing.T) {
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
// The server stamped the message before the put returned, so it is now
|
||||
// older than the TTL.
|
||||
time.Sleep(time.Until(putAt.Add(ttl + 50*time.Millisecond)))
|
||||
d, err := digestStore(ctx, work, nil, false, metas)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, d, 1)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,14 +438,10 @@ func parseWeightsS3URI(uri string) (bucket, prefix string, err error) {
|
||||
if trimmed == uri {
|
||||
return "", "", fmt.Errorf("invalid --s3-uri %q: expected s3://bucket/prefix", uri)
|
||||
}
|
||||
parts := strings.SplitN(trimmed, "/", 2)
|
||||
bucket = parts[0]
|
||||
bucket, prefix, _ = strings.Cut(trimmed, "/")
|
||||
if bucket == "" {
|
||||
return "", "", fmt.Errorf("invalid --s3-uri %q: missing bucket", uri)
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
prefix = parts[1]
|
||||
}
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
@@ -854,7 +850,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 +926,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 +963,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 +977,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 +1010,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 +1084,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 +1182,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 +1191,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)
|
||||
|
||||
@@ -283,11 +283,9 @@ func truncateForTable(s string, maxRunes int) string {
|
||||
}
|
||||
|
||||
// formatQueryResults renders 'ochre vector query' output: a truncated table
|
||||
// by default, or the full response as indented JSON when asJSON is set
|
||||
// (D10's "full text via source ref" -- here, via --json).
|
||||
// by default, or the full response as indented JSON when asJSON is set.
|
||||
func formatQueryResults(results []handlers_ochrevector.QueryResult, asJSON bool) (string, error) {
|
||||
if asJSON {
|
||||
//nolint:musttag // QueryResult is an internal, non-wire Go type (predates this CLI); it round-trips fine on its default field names.
|
||||
data, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode results as JSON: %w", err)
|
||||
@@ -317,7 +315,6 @@ func parseFilterFlag(raw string) (*handlers_ochrevector.Filter, error) {
|
||||
return nil, nil
|
||||
}
|
||||
var f handlers_ochrevector.Filter
|
||||
//nolint:musttag // Filter is the existing filter-AST type (D9); it round-trips fine on its default field names.
|
||||
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
||||
return nil, fmt.Errorf("decode --filter: %w", err)
|
||||
}
|
||||
|
||||
@@ -188,13 +188,14 @@ func describePrincipal(svc handlers_iam.IAMService, accountID, userName string)
|
||||
}
|
||||
|
||||
// principalPolicyActions returns the actions an inline policy allows, shortened
|
||||
// to the method name. An undecodable document reports nothing rather than
|
||||
// guessing, and the listing shows the user with no grants.
|
||||
// to the method name. A NotAction grant is one entry naming what it excludes.
|
||||
// An undecodable document reports nothing rather than guessing.
|
||||
func principalPolicyActions(document string) []string {
|
||||
var doc struct {
|
||||
Statement []struct {
|
||||
Effect string `json:"Effect"`
|
||||
Action handlers_iam.StringOrArr `json:"Action"`
|
||||
Effect string `json:"Effect"`
|
||||
Action handlers_iam.StringOrArr `json:"Action"`
|
||||
NotAction handlers_iam.StringOrArr `json:"NotAction"`
|
||||
} `json:"Statement"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(document), &doc); err != nil {
|
||||
@@ -209,6 +210,13 @@ func principalPolicyActions(document string) []string {
|
||||
for _, action := range stmt.Action {
|
||||
actions = append(actions, strings.TrimPrefix(action, adminPrincipalService+":"))
|
||||
}
|
||||
if len(stmt.NotAction) > 0 {
|
||||
excluded := make([]string, 0, len(stmt.NotAction))
|
||||
for _, action := range stmt.NotAction {
|
||||
excluded = append(excluded, strings.TrimPrefix(action, adminPrincipalService+":"))
|
||||
}
|
||||
actions = append(actions, "NotAction("+strings.Join(excluded, ", ")+")")
|
||||
}
|
||||
}
|
||||
sort.Strings(actions)
|
||||
return actions
|
||||
|
||||
@@ -102,6 +102,18 @@ func TestPrincipalPolicyActionsRoundTrip(t *testing.T) {
|
||||
cmd.PrincipalPolicyActions(cmd.PrincipalPolicyDocument(grants)))
|
||||
}
|
||||
|
||||
// A NotAction grant covers everything but its list, so rendering only Action
|
||||
// would show the principal as holding nothing.
|
||||
func TestPrincipalPolicyActionsRenderNotAction(t *testing.T) {
|
||||
document := `{"Version":"2012-10-17","Statement":[
|
||||
{"Effect":"Allow","NotAction":["spinifex:DeleteAccount","spinifex:CreateAccount"],"Resource":"*"},
|
||||
{"Effect":"Allow","Action":"spinifex:ListAccounts","Resource":"*"},
|
||||
{"Effect":"Deny","NotAction":"spinifex:ListAccounts","Resource":"*"}]}`
|
||||
|
||||
assert.Equal(t, []string{"ListAccounts", "NotAction(DeleteAccount, CreateAccount)"},
|
||||
cmd.PrincipalPolicyActions(document))
|
||||
}
|
||||
|
||||
// A document nobody can parse is reported as no grants rather than as a guess.
|
||||
func TestPrincipalPolicyActionsIgnoreAnUndecodableDocument(t *testing.T) {
|
||||
assert.Empty(t, cmd.PrincipalPolicyActions("{not json"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,26 +35,29 @@ func fakeDrainResponder(t *testing.T, nc *nats.Conn, released int, errMsg string
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesSumsReleased(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
fakeDrainResponder(t, nc, 7, "")
|
||||
|
||||
released, responders := drainDHCPLeases(nc, 2*time.Second)
|
||||
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
|
||||
assert.Equal(t, 7, released)
|
||||
assert.Equal(t, 1, responders)
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesReportsResponderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
fakeDrainResponder(t, nc, 0, "list leases for drain: boom")
|
||||
|
||||
// An erroring responder still counts as a responder but contributes
|
||||
// zero released leases.
|
||||
released, responders := drainDHCPLeases(nc, 2*time.Second)
|
||||
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
|
||||
assert.Equal(t, 0, released)
|
||||
assert.Equal(t, 1, responders)
|
||||
}
|
||||
|
||||
func TestDrainDHCPLeasesNoResponders(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
// No vpcd subscribed: the collection window elapses with zero replies.
|
||||
@@ -281,7 +284,7 @@ func captureSlog(t *testing.T, fn func()) string {
|
||||
// at WARN level, and names only the running instance, not the stopped one.
|
||||
func TestWarnIfGuestsLeftRunningNamesRunningGuests(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 200 * time.Millisecond
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
@@ -312,7 +315,7 @@ func TestWarnIfGuestsLeftRunningNamesRunningGuests(t *testing.T) {
|
||||
// running guests produces no warning.
|
||||
func TestWarnIfGuestsLeftRunningNoGuestsIsSilent(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 200 * time.Millisecond
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
@@ -510,7 +513,7 @@ func TestCollectLocalShutdownACKExhaustedReturnsWithinBudget(t *testing.T) {
|
||||
// must log at ERROR, not WARN, and still name only the running instance.
|
||||
func TestReportGuestsLeftRunningSevereLogsErrorAndNamesGuests(t *testing.T) {
|
||||
origTimeout := guestEnumerationTimeout
|
||||
guestEnumerationTimeout = 200 * time.Millisecond
|
||||
guestEnumerationTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
|
||||
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
@@ -29,6 +29,13 @@ func SetFirewallApplyHelper(path string) func() {
|
||||
// InitCLILogger exposes initCLILogger for testing.
|
||||
var InitCLILogger = initCLILogger
|
||||
|
||||
// OutputStylingEnabled, IsTerminal and SetOutputStyling expose the spx colour guard.
|
||||
var (
|
||||
OutputStylingEnabled = outputStylingEnabled
|
||||
IsTerminal = isTerminal
|
||||
SetOutputStyling = setOutputStyling
|
||||
)
|
||||
|
||||
// CLILogLevel exposes the CLI logger's level var for testing.
|
||||
var CLILogLevel = cliLogLevel
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
// Package cmd defines the spx command-line interface: the cobra commands that
|
||||
// install, configure, start and administer a Spinifex node and its cluster.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/pterm/pterm"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -50,6 +55,8 @@ func Execute() {
|
||||
cliLogLevel.Set(slog.LevelError)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: cliLogLevel})))
|
||||
|
||||
setOutputStyling(outputStylingEnabled(os.Getenv("NO_COLOR"), os.Getenv("TERM"), isTerminal(os.Stdout)))
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -57,6 +64,28 @@ func Execute() {
|
||||
}
|
||||
}
|
||||
|
||||
// outputStylingEnabled reports whether spx may emit ANSI colour and text styling:
|
||||
// only on a terminal that is not TERM=dumb, with NO_COLOR unset or empty.
|
||||
func outputStylingEnabled(noColor, termName string, stdoutIsTerminal bool) bool {
|
||||
return noColor == "" && termName != "dumb" && stdoutIsTerminal
|
||||
}
|
||||
|
||||
// isTerminal reports whether w is a file attached to a terminal.
|
||||
func isTerminal(w io.Writer) bool {
|
||||
f, ok := w.(*os.File)
|
||||
return ok && term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// setOutputStyling switches pterm's colour and bold/underline styling for every
|
||||
// printer at once. pterm forces colour on at init regardless of the terminal.
|
||||
func setOutputStyling(enabled bool) {
|
||||
if enabled {
|
||||
pterm.EnableStyling()
|
||||
} else {
|
||||
pterm.DisableStyling()
|
||||
}
|
||||
}
|
||||
|
||||
// initCLILogger retunes the CLI logger from SPX_LOG_LEVEL, then --verbose so an
|
||||
// explicit flag wins over the environment. There is no root --debug flag: four
|
||||
// service subcommands already define one with a service-specific meaning, and a
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
"github.com/pterm/pterm"
|
||||
)
|
||||
|
||||
func TestOutputStylingEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor string
|
||||
term string
|
||||
terminal bool
|
||||
want bool
|
||||
}{
|
||||
{name: "terminal keeps colour", term: "xterm-256color", terminal: true, want: true},
|
||||
{name: "unset TERM on a terminal keeps colour", terminal: true, want: true},
|
||||
{name: "TERM=dumb drops colour on a terminal", term: "dumb", terminal: true, want: false},
|
||||
{name: "pipe drops colour", terminal: false, want: false},
|
||||
{name: "NO_COLOR drops colour on a terminal", noColor: "1", terminal: true, want: false},
|
||||
{name: "any non-empty NO_COLOR counts, even 0", noColor: "0", terminal: true, want: false},
|
||||
{name: "NO_COLOR on a pipe", noColor: "1", terminal: false, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := cmd.OutputStylingEnabled(tt.noColor, tt.term, tt.terminal); got != tt.want {
|
||||
t.Fatalf("OutputStylingEnabled(%q, %q, %v) = %v, want %v", tt.noColor, tt.term, tt.terminal, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalFalseForNonTTYWriters(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for name, wr := range map[string]io.Writer{"pipe": w, "regular file": f, "buffer": &bytes.Buffer{}} {
|
||||
if cmd.IsTerminal(wr) {
|
||||
t.Errorf("IsTerminal(%s) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderNodesTable renders a table shaped like `spx get nodes` through the same
|
||||
// pterm.DefaultTable the CLI uses, after applying the styling decision.
|
||||
func renderNodesTable(t *testing.T, noColor, termName string, terminal bool) string {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { cmd.SetOutputStyling(true) })
|
||||
cmd.SetOutputStyling(cmd.OutputStylingEnabled(noColor, termName, terminal))
|
||||
|
||||
var buf bytes.Buffer
|
||||
data := pterm.TableData{
|
||||
{"NAME", "STATUS", "IP"},
|
||||
{"node1", "Ready", "10.0.0.1"},
|
||||
{"node-long-name", "NotReady", "10.0.0.22"},
|
||||
}
|
||||
if err := pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(data).WithWriter(&buf).Render(); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestTableOutputPlainWhenNotTerminal(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
out := renderNodesTable(t, "", "xterm", cmd.IsTerminal(w))
|
||||
assertPlainAlignedTable(t, out)
|
||||
}
|
||||
|
||||
func TestTableOutputPlainWithNoColorOnTerminal(t *testing.T) {
|
||||
out := renderNodesTable(t, "1", "xterm", true)
|
||||
assertPlainAlignedTable(t, out)
|
||||
}
|
||||
|
||||
func TestTableOutputPlainOnDumbTerminal(t *testing.T) {
|
||||
out := renderNodesTable(t, "", "dumb", true)
|
||||
assertPlainAlignedTable(t, out)
|
||||
}
|
||||
|
||||
func TestTableOutputStyledOnTerminal(t *testing.T) {
|
||||
out := renderNodesTable(t, "", "xterm", true)
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("table on a terminal carries no ANSI styling, so the plain-output tests prove nothing:\n%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPlainAlignedTable(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
if strings.ContainsRune(out, '\x1b') {
|
||||
t.Fatalf("output contains an escape byte:\n%q", out)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
var rows []string
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, "node") || strings.HasPrefix(l, "NAME") {
|
||||
rows = append(rows, l)
|
||||
}
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want header and two node rows, got %d:\n%s", len(rows), out)
|
||||
}
|
||||
|
||||
// Columns line up only if the width calculation saw the same bytes it printed.
|
||||
statusCol := strings.Index(rows[0], "STATUS")
|
||||
ipCol := strings.Index(rows[0], "IP")
|
||||
for _, row := range rows[1:] {
|
||||
fields := strings.Fields(row)
|
||||
var status string
|
||||
for _, f := range fields {
|
||||
if strings.Contains(f, "Ready") {
|
||||
status = f
|
||||
}
|
||||
}
|
||||
if got := strings.Index(row, status); got != statusCol {
|
||||
t.Errorf("STATUS column at %d in %q, header at %d", got, row, statusCol)
|
||||
}
|
||||
if got := strings.Index(row, "10.0.0."); got != ipCol {
|
||||
t.Errorf("IP column at %d in %q, header at %d", got, row, ipCol)
|
||||
}
|
||||
}
|
||||
|
||||
// grep -w Ready must match: the byte before the word is not a word character.
|
||||
ready := strings.Index(rows[1], "Ready")
|
||||
if ready <= 0 || rows[1][ready-1] != ' ' {
|
||||
t.Errorf("Ready is not preceded by a space in %q", rows[1])
|
||||
}
|
||||
}
|
||||
@@ -225,42 +225,6 @@ var predastoreStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var predastoreStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the predastore service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping predastore service...")
|
||||
|
||||
// Only the pid directory matters here: Stop finds the running process
|
||||
// through it, and start wrote the file there.
|
||||
service, err := service.New("predastore", &predastore.Config{
|
||||
BasePath: viper.GetString("predastore-base-path"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create predastore service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop predastore service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Predastore service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var predastoreStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the predastore service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Add your status logic here
|
||||
fmt.Println("Predastore service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// Repeat for viperblock.
|
||||
var viperblockStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
@@ -413,37 +377,6 @@ var viperblockStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var viperblockStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the viperblock service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping viperblock service...")
|
||||
|
||||
service, err := service.New("viperblock", &viperblockd.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create viperblock service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop viperblock service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Viperblock service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var viperblockStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the viperblock service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Viperblock service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
var qemunbdCmd = &cobra.Command{
|
||||
Use: "qemunbd",
|
||||
Short: "Manage the qemunbd service",
|
||||
@@ -518,37 +451,6 @@ var qemunbdStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var qemunbdStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the qemunbd service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping qemunbd service...")
|
||||
|
||||
service, err := service.New("qemunbd", &qemunbdd.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create qemunbd service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop qemunbd service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("qemunbd service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var qemunbdStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the qemunbd service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("qemunbd service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// Repeat for nats.
|
||||
var natsStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
@@ -589,37 +491,6 @@ var natsStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var natsStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the nats service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping nats service...")
|
||||
|
||||
service, err := service.New("nats", &nats.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create nats service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop nats service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Nats service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var natsStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the nats service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Nats service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// Repeat for spinifex.
|
||||
var spinifexStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
@@ -682,37 +553,6 @@ var spinifexStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var spinifexStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the spinifex service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping spinifex service...")
|
||||
|
||||
service, err := service.New("spinifex", &config.ClusterConfig{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create spinifex service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop spinifex service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Spinifex service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var spinifexStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the spinifex service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Spinifex service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// AWS GW
|
||||
|
||||
var awsgwStartCmd = &cobra.Command{
|
||||
@@ -785,37 +625,6 @@ var awsgwStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var awsgwStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the awsgw service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping awsgw service...")
|
||||
|
||||
service, err := service.New("awsgw", &config.ClusterConfig{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create awsgw service: %w", err)
|
||||
}
|
||||
|
||||
if err = service.Stop(); err != nil {
|
||||
return fmt.Errorf("stop awsgw service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("AWSGW service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var awsgwStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the awsgw service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("AWSGW service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// consoleRegion is the region the console signs with. awsgw pins signature
|
||||
// verification to this same node field, so reading it from anywhere else —
|
||||
// including the cluster-wide [aws] region — would let the two drift and fail
|
||||
@@ -876,50 +685,6 @@ var spinifexUIStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var spinifexUIStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the spinifex-ui service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping spinifex-ui service...")
|
||||
|
||||
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create spinifex-ui service: %w", err)
|
||||
}
|
||||
|
||||
if err = svc.Stop(); err != nil {
|
||||
return fmt.Errorf("stop spinifex-ui service: %w", err)
|
||||
}
|
||||
fmt.Println("spinifex-ui service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var spinifexUIStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the spinifex-ui service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create spinifex-ui service: %w", err)
|
||||
}
|
||||
|
||||
status, err := svc.Status()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get spinifex-ui service status: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("spinifex-ui service status:", status)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
|
||||
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. There is no silent
|
||||
// alias or auto-rewrite; the operator must remove the key before vpcd will start.
|
||||
@@ -1025,12 +790,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)
|
||||
@@ -1044,35 +812,6 @@ var vpcdStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var vpcdStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the vpcd service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping vpcd service...")
|
||||
|
||||
svc, err := service.New("vpcd", &vpcd.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create vpcd service: %w", err)
|
||||
}
|
||||
|
||||
if err = svc.Stop(); err != nil {
|
||||
return fmt.Errorf("stop vpcd service: %w", err)
|
||||
}
|
||||
fmt.Println("vpcd service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var vpcdStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the vpcd service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("vpcd service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
var northstarCmd = &cobra.Command{
|
||||
Use: "northstar",
|
||||
Short: "Manage the northstar (DNS) service",
|
||||
@@ -1195,35 +934,6 @@ func resolveNorthstarConfigPath(nodePath, override string) string {
|
||||
return nodePath
|
||||
}
|
||||
|
||||
var northstarStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the northstar service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping northstar service...")
|
||||
|
||||
svc, err := service.New("northstar", &northstar.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create northstar service: %w", err)
|
||||
}
|
||||
|
||||
if err = svc.Stop(); err != nil {
|
||||
return fmt.Errorf("stop northstar service: %w", err)
|
||||
}
|
||||
fmt.Println("northstar service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var northstarStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the northstar service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("northstar service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
var qmpCollectorCmd = &cobra.Command{
|
||||
Use: "qmp-collector",
|
||||
Short: "Manage the qmp-collector (guest metrics) service",
|
||||
@@ -1267,34 +977,6 @@ var qmpCollectorStartCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var qmpCollectorStopCmd = &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the qmp-collector service",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping qmp-collector service...")
|
||||
|
||||
svc, err := service.New("qmp-collector", &qmpcollector.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create qmp-collector service: %w", err)
|
||||
}
|
||||
if err = svc.Stop(); err != nil {
|
||||
return fmt.Errorf("stop qmp-collector service: %w", err)
|
||||
}
|
||||
fmt.Println("qmp-collector service stopped")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var qmpCollectorStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Get status of the qmp-collector service",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("qmp-collector service status: ...")
|
||||
},
|
||||
}
|
||||
|
||||
// registerPredastoreNamespacedFlags defines predastore's host, base-path and
|
||||
// config-path flags and binds them to viper. It must only run once (from
|
||||
// init()); pflag panics on a redefined flag.
|
||||
@@ -1424,8 +1106,6 @@ func init() {
|
||||
bindPredastoreCollisionEnv()
|
||||
|
||||
predastoreCmd.AddCommand(predastoreStartCmd)
|
||||
predastoreCmd.AddCommand(predastoreStopCmd)
|
||||
predastoreCmd.AddCommand(predastoreStatusCmd)
|
||||
|
||||
serviceCmd.AddCommand(viperblockCmd)
|
||||
|
||||
@@ -1451,8 +1131,6 @@ func init() {
|
||||
viper.BindPFlag("viperblock-debug", viperblockCmd.PersistentFlags().Lookup("debug"))
|
||||
|
||||
viperblockCmd.AddCommand(viperblockStartCmd)
|
||||
viperblockCmd.AddCommand(viperblockStopCmd)
|
||||
viperblockCmd.AddCommand(viperblockStatusCmd)
|
||||
|
||||
serviceCmd.AddCommand(qemunbdCmd)
|
||||
|
||||
@@ -1461,15 +1139,11 @@ func init() {
|
||||
viper.BindPFlag("qemunbd-debug", qemunbdCmd.PersistentFlags().Lookup("debug"))
|
||||
|
||||
qemunbdCmd.AddCommand(qemunbdStartCmd)
|
||||
qemunbdCmd.AddCommand(qemunbdStopCmd)
|
||||
qemunbdCmd.AddCommand(qemunbdStatusCmd)
|
||||
|
||||
// Nats
|
||||
serviceCmd.AddCommand(natsCmd)
|
||||
|
||||
natsCmd.AddCommand(natsStartCmd)
|
||||
natsCmd.AddCommand(natsStopCmd)
|
||||
natsCmd.AddCommand(natsStatusCmd)
|
||||
|
||||
// Add NATS flags
|
||||
natsCmd.PersistentFlags().Int("port", 4222, "NATS server port")
|
||||
@@ -1491,8 +1165,6 @@ func init() {
|
||||
serviceCmd.AddCommand(spinifexCmd)
|
||||
|
||||
spinifexCmd.AddCommand(spinifexStartCmd)
|
||||
spinifexCmd.AddCommand(spinifexStopCmd)
|
||||
spinifexCmd.AddCommand(spinifexStatusCmd)
|
||||
|
||||
spinifexCmd.PersistentFlags().String("wal-dir", "", "Write-ahead log (WAL) directory. Place on high-speed NVMe disk, or tmpfs for development.")
|
||||
viper.BindEnv("wal-dir", "SPINIFEX_WAL_DIR")
|
||||
@@ -1515,8 +1187,6 @@ func init() {
|
||||
bindAwsgwCollisionEnv()
|
||||
|
||||
awsgwCmd.AddCommand(awsgwStartCmd)
|
||||
awsgwCmd.AddCommand(awsgwStopCmd)
|
||||
awsgwCmd.AddCommand(awsgwStatusCmd)
|
||||
|
||||
// spinifex-ui
|
||||
serviceCmd.AddCommand(spinifexUICmd)
|
||||
@@ -1546,15 +1216,11 @@ func init() {
|
||||
viper.BindPFlag("spinifex-ui-ochre-enabled", spinifexUICmd.PersistentFlags().Lookup("ochre-enabled"))
|
||||
|
||||
spinifexUICmd.AddCommand(spinifexUIStartCmd)
|
||||
spinifexUICmd.AddCommand(spinifexUIStopCmd)
|
||||
spinifexUICmd.AddCommand(spinifexUIStatusCmd)
|
||||
|
||||
// vpcd
|
||||
serviceCmd.AddCommand(vpcdCmd)
|
||||
|
||||
vpcdCmd.AddCommand(vpcdStartCmd)
|
||||
vpcdCmd.AddCommand(vpcdStopCmd)
|
||||
vpcdCmd.AddCommand(vpcdStatusCmd)
|
||||
|
||||
// northstar
|
||||
serviceCmd.AddCommand(northstarCmd)
|
||||
@@ -1564,13 +1230,9 @@ func init() {
|
||||
viper.BindPFlag("northstar-config", northstarCmd.PersistentFlags().Lookup("northstar-config"))
|
||||
|
||||
northstarCmd.AddCommand(northstarStartCmd)
|
||||
northstarCmd.AddCommand(northstarStopCmd)
|
||||
northstarCmd.AddCommand(northstarStatusCmd)
|
||||
|
||||
// qmp-collector
|
||||
serviceCmd.AddCommand(qmpCollectorCmd)
|
||||
|
||||
qmpCollectorCmd.AddCommand(qmpCollectorStartCmd)
|
||||
qmpCollectorCmd.AddCommand(qmpCollectorStopCmd)
|
||||
qmpCollectorCmd.AddCommand(qmpCollectorStatusCmd)
|
||||
}
|
||||
|
||||
@@ -62,16 +62,8 @@ func writeMalformedToml(t *testing.T) string {
|
||||
return writeSpinifexToml(t, "[section\nkey = value without quotes\n")
|
||||
}
|
||||
|
||||
// isolateRuntimeDir redirects the pid-file fallback (utils.pidPath, via
|
||||
// XDG_RUNTIME_DIR) to an empty temp dir, so a stop command with no explicit
|
||||
// directory override can't resolve against a real, shared, ambient pid dir.
|
||||
func isolateRuntimeDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
// TestServiceStartCmdsReturnErrorOnMissingConfig exercises each service
|
||||
// start/stop command's RunE directly and asserts a fatal precondition
|
||||
// start command's RunE directly and asserts a fatal precondition
|
||||
// failure surfaces as a returned error, not a silent zero-exit return.
|
||||
// Cases with a setup func drive further into RunE toward a specific branch.
|
||||
func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
|
||||
@@ -339,99 +331,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
|
||||
},
|
||||
wantErr: "start qmp-collector service",
|
||||
},
|
||||
// The stop commands below resolve their pid directory to an isolated,
|
||||
// always-empty temp dir, so Stop() safely fails to find a pid file
|
||||
// rather than risking a real running process via an ambient path.
|
||||
{
|
||||
name: "predastore stop (no running process)",
|
||||
cmd: predastoreStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
viper.Set("predastore-base-path", t.TempDir())
|
||||
},
|
||||
wantErr: "stop predastore service",
|
||||
},
|
||||
{
|
||||
name: "viperblock stop (no running process)",
|
||||
cmd: viperblockStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop viperblock service",
|
||||
},
|
||||
{
|
||||
name: "qemunbd stop (no running process)",
|
||||
cmd: qemunbdStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop qemunbd service",
|
||||
},
|
||||
{
|
||||
name: "nats stop (no running process)",
|
||||
cmd: natsStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop nats service",
|
||||
},
|
||||
{
|
||||
name: "spinifex stop (no running process)",
|
||||
cmd: spinifexStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop spinifex service",
|
||||
},
|
||||
{
|
||||
name: "awsgw stop (no running process)",
|
||||
cmd: awsgwStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop awsgw service",
|
||||
},
|
||||
{
|
||||
name: "spinifex-ui stop (no running process)",
|
||||
cmd: spinifexUIStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop spinifex-ui service",
|
||||
},
|
||||
{
|
||||
name: "vpcd stop (no running process)",
|
||||
cmd: vpcdStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop vpcd service",
|
||||
},
|
||||
{
|
||||
name: "northstar stop (no running process)",
|
||||
cmd: northstarStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop northstar service",
|
||||
},
|
||||
{
|
||||
name: "qmp-collector stop (no running process)",
|
||||
cmd: qmpCollectorStopCmd,
|
||||
setup: func(t *testing.T) {
|
||||
resetGlobalViper(t)
|
||||
isolateRuntimeDir(t)
|
||||
},
|
||||
wantErr: "stop qmp-collector service",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -451,36 +350,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess drives the
|
||||
// status command's success path: with no pid file at an isolated
|
||||
// XDG_RUNTIME_DIR, Status reports "stopped" and RunE must return nil.
|
||||
func TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess(t *testing.T) {
|
||||
isolateRuntimeDir(t)
|
||||
|
||||
require.NotNil(t, spinifexUIStatusCmd.RunE)
|
||||
err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir drives the status
|
||||
// command's other branch: a genuine read failure (permission denied, not
|
||||
// ENOENT) must surface as an error rather than the benign "stopped" case.
|
||||
func TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root bypasses directory permission checks")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "spinifex-ui.pid"), []byte("123"), 0o600))
|
||||
require.NoError(t, os.Chmod(dir, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
||||
t.Setenv("XDG_RUNTIME_DIR", dir)
|
||||
|
||||
require.NotNil(t, spinifexUIStatusCmd.RunE)
|
||||
err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil)
|
||||
require.ErrorContains(t, err, "get spinifex-ui service status")
|
||||
}
|
||||
|
||||
// TestServiceStartExitsNonZeroOnFailure proves a fatal service-start error
|
||||
// terminates the process non-zero (systemd's Restart=on-failure depends on
|
||||
// it). os.Exit can't be observed in-process, so this re-execs the test
|
||||
|
||||
@@ -10,7 +10,7 @@ email = "{{.OperatorEmail}}"
|
||||
|
||||
[aws]
|
||||
region = "{{.Region}}"
|
||||
internal_suffix = "spinifex.internal"
|
||||
services_domain = "services.internal"
|
||||
|
||||
[network]
|
||||
ipsec_enabled = {{.IPSecEnabled}}
|
||||
|
||||
@@ -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: `<spinifex-dir>/images`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. Compressed and qcow2 images are extracted into a hidden `.import-*` scratch directory under `--tmp-dir`, which is removed when the import succeeds or fails, so the catalog directory keeps only the downloaded source (for `--name`) and the AMI manifest. Point `--tmp-dir` at a disk-backed filesystem with room for the full raw image, never at a tmpfs such as `/tmp`. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
|
||||
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
|
||||
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
|
||||
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,10 +71,16 @@ ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | uefi
|
||||
spx admin images import --name ubuntu-26.04-x86_64
|
||||
```
|
||||
|
||||
Verify the import and note the AMI ID:
|
||||
|
||||
```bash
|
||||
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
|
||||
```
|
||||
|
||||
### 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 +89,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
|
||||
@@ -131,7 +174,7 @@ aws ec2 attach-internet-gateway \
|
||||
|
||||
### Create a Subnet
|
||||
|
||||
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a *public* subnet.
|
||||
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a _public_ subnet.
|
||||
|
||||
```bash
|
||||
SUBNET_ID=$(aws ec2 create-subnet \
|
||||
@@ -312,17 +355,17 @@ https://YOUR_NODE_IP:3000
|
||||
|
||||
On first load, your browser will show a TLS warning — Spinifex generates a self-signed certificate at install time. This is expected.
|
||||
|
||||
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: *Advanced → Proceed to ...*, Firefox: *Advanced → Accept the Risk and Continue*).
|
||||
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: _Advanced → Proceed to ..._, Firefox: _Advanced → Accept the Risk and Continue_).
|
||||
2. On the login page, click **Download Certificate** and save `spinifex-ca.pem` to your machine.
|
||||
3. Install the certificate as a **trusted root** on your workstation, following the steps for your platform below.
|
||||
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
|
||||
|
||||
Step 3, by platform:
|
||||
|
||||
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
|
||||
- **macOS:** open `spinifex-ca.pem` in Keychain Access → _System_ keychain → set _Trust_ to **Always Trust**.
|
||||
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
|
||||
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
|
||||
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
|
||||
- **Windows:** double-click the file → _Install Certificate_ → _Local Machine_ → _Trusted Root Certification Authorities_.
|
||||
- **Browser-only (Firefox):** _Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import_ and tick _Trust this CA to identify websites_.
|
||||
|
||||
> **Why this is required:** the UI logs in by reading your AWS credentials through a trusted TLS channel. Browsers refuse to send credentials over an untrusted connection, so the certificate must be installed as trusted — temporary "Proceed anyway" exceptions won't work for login.
|
||||
|
||||
@@ -340,6 +383,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>"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
|
||||
|
||||
**Spinifex specifics**
|
||||
|
||||
- **Auth mode is API-only.** `authentication_mode` must be `API`; the legacy `CONFIG_MAP` and `API_AND_CONFIG_MAP` modes are rejected. Grant access with access entries, not an `aws-auth` ConfigMap.
|
||||
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
|
||||
- **Security groups are auto-managed.** Spinifex creates the cluster and node-group security groups deterministically (e.g. `eks-cluster-<name>-nodegroup-sg`); `vpc_config.security_group_ids` is ignored. To expose a workload you add an ingress rule to the auto-managed node-group SG.
|
||||
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
|
||||
- **Default Kubernetes version is `1.32`.**
|
||||
@@ -262,7 +262,7 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
|
||||
|
||||
**Workload unreachable from outside the cluster.** The node-group security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to `eks-cluster-<name>-nodegroup-sg`, as shown in the CLI tab.
|
||||
|
||||
**`create-cluster` rejected with an authentication-mode error.** `authentication_mode` must be `API`. Remove any `CONFIG_MAP` / `API_AND_CONFIG_MAP` setting and grant access with access entries instead.
|
||||
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
|
||||
|
||||
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
|
||||
|
||||
|
||||
@@ -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 **392 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 |
|
||||
| [ECR](/coverage/ecr) | 22 |
|
||||
| [EC2](/coverage/ec2) | 127 |
|
||||
| [ECR](/coverage/ecr) | 23 |
|
||||
| [ECS](/coverage/ecs) | 31 |
|
||||
| [EKS](/coverage/eks) | 34 |
|
||||
| [ELBv2](/coverage/elbv2) | 34 |
|
||||
| [IAM](/coverage/iam) | 76 |
|
||||
| [RDS](/coverage/rds) | 26 |
|
||||
| [S3](/coverage/s3) | 19 |
|
||||
| [EKS](/coverage/eks) | 27 |
|
||||
| [ELBv2](/coverage/elbv2) | 36 |
|
||||
| [IAM](/coverage/iam) | 81 |
|
||||
| [RDS](/coverage/rds) | 27 |
|
||||
| [S3](/coverage/s3) | 26 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **381** |
|
||||
| **Total** | **392** |
|
||||
|
||||
@@ -18,12 +18,16 @@ 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
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
|
||||
### AMI Launch Permissions Are Refused
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
@@ -110,6 +114,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
| `DescribeVolumes` |
|
||||
| `DescribeVolumesModifications` |
|
||||
| `DescribeVpcAttribute` |
|
||||
| `DescribeVpcEndpoints` |
|
||||
| `DescribeVpcs` |
|
||||
| `DetachInternetGateway` |
|
||||
| `DetachNetworkInterface` |
|
||||
@@ -132,6 +137,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
| `ModifyInstanceMetadataOptions` |
|
||||
| `ModifyLaunchTemplate` |
|
||||
| `ModifyNetworkInterfaceAttribute` |
|
||||
| `ModifySecurityGroupRules` |
|
||||
| `ModifySubnetAttribute` |
|
||||
| `ModifyVolume` |
|
||||
| `ModifyVpcAttribute` |
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
### Spot Instances Are a Mock
|
||||
|
||||
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
|
||||
|
||||
### AMI Launch Permissions Are Refused
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
@@ -18,7 +18,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **22 operations** in the ECR `2015-09-21` API model.
|
||||
Spinifex implements **23 operations** in the ECR `2015-09-21` API model.
|
||||
|
||||
### Two endpoints, one registry
|
||||
|
||||
@@ -26,7 +26,7 @@ Repository metadata is served over the AWS API on the gateway endpoint. Image da
|
||||
|
||||
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
|
||||
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to. `DescribeRegistry` reports this as a replication configuration with no rules.
|
||||
|
||||
### Operations
|
||||
|
||||
@@ -39,6 +39,7 @@ Registry replication is a stub for the same kind of reason: a deployment is a si
|
||||
| `DeleteRepository` |
|
||||
| `DeleteRepositoryPolicy` |
|
||||
| `DescribeImages` |
|
||||
| `DescribeRegistry` |
|
||||
| `DescribeRepositories` |
|
||||
| `GetAuthorizationToken` |
|
||||
| `GetLifecyclePolicy` |
|
||||
|
||||
@@ -4,4 +4,4 @@ Repository metadata is served over the AWS API on the gateway endpoint. Image da
|
||||
|
||||
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
|
||||
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
|
||||
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to. `DescribeRegistry` reports this as a replication configuration with no rules.
|
||||
|
||||
@@ -18,14 +18,13 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
Spinifex implements **27 operations** in the EKS `2017-11-01` API model.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `AssociateAccessPolicy` |
|
||||
| `AssociateIdentityProviderConfig` |
|
||||
| `CreateAccessEntry` |
|
||||
| `CreateAddon` |
|
||||
| `CreateCluster` |
|
||||
@@ -38,23 +37,17 @@ Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
|
||||
| `DescribeAddon` |
|
||||
| `DescribeAddonVersions` |
|
||||
| `DescribeCluster` |
|
||||
| `DescribeIdentityProviderConfig` |
|
||||
| `DescribeNodegroup` |
|
||||
| `DisassociateAccessPolicy` |
|
||||
| `DisassociateIdentityProviderConfig` |
|
||||
| `ListAccessEntries` |
|
||||
| `ListAccessPolicies` |
|
||||
| `ListAddons` |
|
||||
| `ListAssociatedAccessPolicies` |
|
||||
| `ListClusters` |
|
||||
| `ListIdentityProviderConfigs` |
|
||||
| `ListNodegroups` |
|
||||
| `ListTagsForResource` |
|
||||
| `TagResource` |
|
||||
| `UntagResource` |
|
||||
| `UpdateAccessEntry` |
|
||||
| `UpdateAddon` |
|
||||
| `UpdateClusterConfig` |
|
||||
| `UpdateClusterVersion` |
|
||||
| `UpdateNodegroupConfig` |
|
||||
| `UpdateNodegroupVersion` |
|
||||
|
||||
@@ -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 **81 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` |
|
||||
@@ -65,6 +67,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `GetUser` |
|
||||
| `GetUserPolicy` |
|
||||
| `ListAccessKeys` |
|
||||
| `ListAccountAliases` |
|
||||
| `ListAttachedGroupPolicies` |
|
||||
| `ListAttachedRolePolicies` |
|
||||
| `ListAttachedUserPolicies` |
|
||||
@@ -91,6 +94,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `PutUserPolicy` |
|
||||
| `RemoveRoleFromInstanceProfile` |
|
||||
| `RemoveUserFromGroup` |
|
||||
| `SetDefaultPolicyVersion` |
|
||||
| `TagInstanceProfile` |
|
||||
| `TagOpenIDConnectProvider` |
|
||||
| `TagPolicy` |
|
||||
@@ -104,3 +108,4 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
|
||||
| `UpdateAccessKey` |
|
||||
| `UpdateAssumeRolePolicy` |
|
||||
| `UpdateRole` |
|
||||
| `UpdateRoleDescription` |
|
||||
|
||||
@@ -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` — 23 of 58 modelled operations implemented (39.7%), 10 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`
|
||||
@@ -580,7 +793,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
- `BatchCheckLayerAvailability`
|
||||
- `CompleteLayerUpload`
|
||||
- `DescribeRegistry`
|
||||
- `GetDownloadUrlForLayer`
|
||||
- `GetRegistryPolicy`
|
||||
- `InitiateLayerUpload`
|
||||
@@ -607,27 +819,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,31 +881,52 @@ 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` — 27 of 70 modelled operations implemented (38.6%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `ActivateCertificateAuthority`
|
||||
- `AssociateEncryptionConfig`
|
||||
- `AssociateIdentityProviderConfig`
|
||||
- `CancelUpdate`
|
||||
- `CreateCapability`
|
||||
- `CreateCertificateAuthority`
|
||||
- `CreateEksAnywhereSubscription`
|
||||
- `CreateFargateProfile`
|
||||
- `CreatePodIdentityAssociation`
|
||||
- `DeleteCapability`
|
||||
- `DeleteCertificateAuthority`
|
||||
- `DeleteEksAnywhereSubscription`
|
||||
- `DeleteFargateProfile`
|
||||
- `DeletePodIdentityAssociation`
|
||||
- `DeregisterCluster`
|
||||
- `DescribeAddonConfiguration`
|
||||
- `DescribeCapability`
|
||||
- `DescribeCertificateAuthority`
|
||||
- `DescribeClusterVersions`
|
||||
- `DescribeEksAnywhereSubscription`
|
||||
- `DescribeFargateProfile`
|
||||
- `DescribeIdentityProviderConfig`
|
||||
- `DescribeInsight`
|
||||
- `DescribeInsightsRefresh`
|
||||
- `DescribePodIdentityAssociation`
|
||||
- `DescribeUpdate`
|
||||
- `DisassociateIdentityProviderConfig`
|
||||
- `ListCapabilities`
|
||||
- `ListCertificateAuthorities`
|
||||
- `ListEksAnywhereSubscriptions`
|
||||
- `ListFargateProfiles`
|
||||
- `ListIdentityProviderConfigs`
|
||||
- `ListInsights`
|
||||
- `ListPodIdentityAssociations`
|
||||
- `ListUpdates`
|
||||
- `RegisterCluster`
|
||||
- `StartInsightsRefresh`
|
||||
- `UpdateCapability`
|
||||
- `UpdateClusterConfig`
|
||||
- `UpdateClusterVersion`
|
||||
- `UpdateEksAnywhereSubscription`
|
||||
- `UpdateNodegroupVersion`
|
||||
- `UpdatePodIdentityAssociation`
|
||||
|
||||
### Registered outside the pinned model
|
||||
@@ -684,7 +938,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 +946,91 @@ 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` — 81 of 180 modelled operations implemented (45.0%), 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,10 +1041,10 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `UntagSAMLProvider`
|
||||
- `UntagServerCertificate`
|
||||
- `UpdateAccountPasswordPolicy`
|
||||
- `UpdateDelegationRequest`
|
||||
- `UpdateGroup`
|
||||
- `UpdateLoginProfile`
|
||||
- `UpdateOpenIDConnectProviderThumbprint`
|
||||
- `UpdateRoleDescription`
|
||||
- `UpdateSAMLProvider`
|
||||
- `UpdateServerCertificate`
|
||||
- `UpdateUser`
|
||||
@@ -814,7 +1085,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` — 27 of 164 modelled operations implemented (16.5%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -870,6 +1141,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `DescribeDBClusterSnapshotAttributes`
|
||||
- `DescribeDBClusterSnapshots`
|
||||
- `DescribeDBLogFiles`
|
||||
- `DescribeDBMajorEngineVersions`
|
||||
- `DescribeDBProxies`
|
||||
- `DescribeDBProxyEndpoints`
|
||||
- `DescribeDBProxyTargetGroups`
|
||||
@@ -890,6 +1162,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `DescribePendingMaintenanceActions`
|
||||
- `DescribeReservedDBInstances`
|
||||
- `DescribeReservedDBInstancesOfferings`
|
||||
- `DescribeServerlessV2PlatformVersions`
|
||||
- `DescribeSourceRegions`
|
||||
- `DescribeTenantDatabases`
|
||||
- `DescribeValidDBInstanceModifications`
|
||||
@@ -911,7 +1184,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `ModifyDBShardGroup`
|
||||
- `ModifyDBSnapshot`
|
||||
- `ModifyDBSnapshotAttribute`
|
||||
- `ModifyDBSubnetGroup`
|
||||
- `ModifyEventSubscription`
|
||||
- `ModifyGlobalCluster`
|
||||
- `ModifyIntegration`
|
||||
@@ -973,45 +1245,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 +1299,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 +1352,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`
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **26 operations** in the RDS `2014-10-31` API model.
|
||||
Spinifex implements **27 operations** in the RDS `2014-10-31` API model.
|
||||
|
||||
### Engines
|
||||
|
||||
@@ -54,7 +54,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
|
||||
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
|
||||
| `EnableCloudwatchLogsExports` | Log export is not implemented |
|
||||
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
|
||||
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
|
||||
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
|
||||
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
|
||||
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
|
||||
@@ -67,6 +67,8 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `EnableCustomerOwnedIp` | An Outposts feature |
|
||||
| `ForceFailover` (reboot) | No standby to fail over to |
|
||||
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
|
||||
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
|
||||
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
|
||||
|
||||
### Operations
|
||||
|
||||
@@ -93,6 +95,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `ListTagsForResource` |
|
||||
| `ModifyDBInstance` |
|
||||
| `ModifyDBParameterGroup` |
|
||||
| `ModifyDBSubnetGroup` |
|
||||
| `RebootDBInstance` |
|
||||
| `RemoveTagsFromResource` |
|
||||
| `RestoreDBInstanceFromDBSnapshot` |
|
||||
|
||||
@@ -31,7 +31,7 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
|
||||
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
|
||||
| `EnableCloudwatchLogsExports` | Log export is not implemented |
|
||||
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
|
||||
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
|
||||
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
|
||||
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
|
||||
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
|
||||
@@ -44,3 +44,5 @@ A parameter whose omission would create a false safety, security or availability
|
||||
| `EnableCustomerOwnedIp` | An Outposts feature |
|
||||
| `ForceFailover` (reboot) | No standby to fail over to |
|
||||
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
|
||||
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
|
||||
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
title: "Spinifex on Exoscale"
|
||||
seoTitle: "Run Spinifex on Exoscale with Elastic IPs — Spinifex Docs"
|
||||
description: "Install Spinifex on one Exoscale instance and run EC2, load balancers and EKS on real Exoscale Elastic IPs."
|
||||
category: "Install"
|
||||
tags:
|
||||
- install
|
||||
- exoscale
|
||||
- cloud
|
||||
- elastic ip
|
||||
resources:
|
||||
- title: "Single-Node Install"
|
||||
url: "/docs/install"
|
||||
- title: "Spinifex on Oracle Cloud"
|
||||
url: "/docs/oci-integration"
|
||||
- title: "VPC Networking"
|
||||
url: "/docs/vpc-networking"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
---
|
||||
|
||||
# Running Spinifex on Exoscale
|
||||
|
||||
> [!WARNING]
|
||||
> **Work in progress.** This page is not yet published on docs.mulgadc.com. Do not add it to `scripts/docs-config.json` in `mulga-docs` until the [Limits](#limits) section is settled.
|
||||
|
||||
> One Exoscale instance, the AWS APIs on its address, and guests reachable on real Exoscale Elastic IPs that Spinifex creates and deletes for you.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What you get](#what-you-get)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Install](#install)
|
||||
- [Use it from your laptop](#use-it-from-your-laptop)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Limits](#limits)
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
A single Exoscale instance runs the whole Spinifex stack: EC2, EBS, S3, VPC, load balancers and EKS, behind an endpoint the ordinary AWS CLI, SDKs and Terraform provider talk to unmodified.
|
||||
|
||||
Whenever something needs a public address, such as an instance in a public subnet, an Elastic IP, an internet-facing load balancer or a NAT gateway, Spinifex creates an Exoscale Elastic IP, attaches it to your instance, and deletes it again when the resource goes away. You never manage the addresses by hand.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/exoscale-single-node.svg" alt="Single node on Exoscale — the instance on the zone's public network with one interface, Elastic IPs routed into OVN through the routed-NAT transit veth, the control plane, tenant VPCs and the block storage volume" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
You need:
|
||||
|
||||
- An Exoscale organisation and an **API key** that can create, attach, detach and delete Elastic IPs.
|
||||
- An SSH key pair.
|
||||
- The AWS CLI on your laptop.
|
||||
|
||||
Then create these in one zone, from the Exoscale console or the `exo` CLI. This guide uses `de-fra-1`.
|
||||
|
||||
| Resource | Setting |
|
||||
| --- | --- |
|
||||
| Compute instance | `standard.huge` (8 vCPU / 32 GiB) or larger, template `Linux Ubuntu 26.04 LTS 64-bit`, 100 GiB disk, your SSH key |
|
||||
| Block storage volume | 256 GiB or more, attached to the instance |
|
||||
| Security group | Allow all inbound TCP, UDP and ICMP from `0.0.0.0/0`, attached to the instance |
|
||||
|
||||
Note the instance's **ID** (a UUID, shown on its page in the console) and the volume's **ID**. You do not need to create any Elastic IPs.
|
||||
|
||||
> [!NOTE]
|
||||
> The security group is open on purpose. Guests are filtered by their own Spinifex security groups, and the node by the Spinifex host firewall you turn on in step 5.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Everything below runs on the instance, over SSH as `ubuntu`.
|
||||
|
||||
### 1. Check that the instance can run VMs
|
||||
|
||||
```bash
|
||||
ls -l /dev/kvm && cat /sys/module/kvm_intel/parameters/nested
|
||||
```
|
||||
|
||||
`/dev/kvm` must exist and the second line must print `Y`. If either fails, pick a different instance type before going further.
|
||||
|
||||
### 2. Put the data volume at `/var/lib/spinifex`
|
||||
|
||||
Every guest disk and S3 object lives here, so it belongs on the volume and not on the 100 GiB root disk.
|
||||
|
||||
```bash
|
||||
VOL_ID=<volume ID>
|
||||
DEV=/dev/disk/by-id/virtio-${VOL_ID:0:20}
|
||||
|
||||
sudo mkfs.ext4 -L spinifex-data "$DEV" # only on a new, empty volume
|
||||
sudo mkdir -p /var/lib/spinifex
|
||||
echo "UUID=$(sudo blkid -o value -s UUID "$DEV") /var/lib/spinifex ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab
|
||||
sudo mount /var/lib/spinifex
|
||||
findmnt /var/lib/spinifex # must print a line
|
||||
```
|
||||
|
||||
### 3. Install the `exo` CLI and give it your key
|
||||
|
||||
Spinifex manages Elastic IPs by running `exo`, so it needs the CLI and a key file.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://github.com/exoscale/cli/releases/download/v1.101.0/exoscale-cli_1.101.0_linux_amd64.deb
|
||||
sudo dpkg -i exoscale-cli_1.101.0_linux_amd64.deb
|
||||
|
||||
sudo mkdir -p /etc/spinifex/exoscale
|
||||
sudo tee /etc/spinifex/exoscale/exoscale.toml >/dev/null <<'EOF'
|
||||
defaultaccount = "spinifex"
|
||||
|
||||
[[accounts]]
|
||||
name = "spinifex"
|
||||
key = "EXO..."
|
||||
secret = "..."
|
||||
defaultZone = "de-fra-1"
|
||||
EOF
|
||||
sudo chmod 700 /etc/spinifex/exoscale && sudo chmod 600 /etc/spinifex/exoscale/exoscale.toml
|
||||
```
|
||||
|
||||
Check the key works: `sudo exo --config /etc/spinifex/exoscale/exoscale.toml compute elastic-ip list -z de-fra-1`.
|
||||
|
||||
### 4. Install Spinifex
|
||||
|
||||
```bash
|
||||
curl -sfL https://install.mulgadc.com | sudo bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink
|
||||
|
||||
sudo spx admin init --node node1 --nodes 1 --region eu-central-1 --az eu-central-1a \
|
||||
--ipsec=false --external-mode=nat
|
||||
sudo chown -R spinifex-daemon: /etc/spinifex/exoscale
|
||||
```
|
||||
|
||||
Now tell Spinifex where to get public addresses. Add this to the end of `/etc/spinifex/spinifex.toml`, just above the `[bootstrap]` section, using your instance ID:
|
||||
|
||||
```toml
|
||||
[[network.external_pools]]
|
||||
name = "exo"
|
||||
source = "exoscale"
|
||||
exoscale_zone = "de-fra-1"
|
||||
exoscale_instance_id = "<instance ID>"
|
||||
```
|
||||
|
||||
Leave the `nat-transit` pool that `init` wrote where it is.
|
||||
|
||||
> [!NOTE]
|
||||
> `eu-central-1` is the region name your AWS tools will see. It is your choice and does not have to match the Exoscale zone.
|
||||
|
||||
### 5. Turn on the firewall, then start
|
||||
|
||||
```bash
|
||||
sudo env SETUP_STAGES=firewall /usr/local/share/spinifex/setup.sh --firewall on
|
||||
sudo systemctl start spinifex.target
|
||||
systemctl list-units 'spinifex-*' --no-pager
|
||||
```
|
||||
|
||||
Every `spinifex-*` unit should be `active`. The daemon log confirms the Elastic IP integration is working:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u spinifex-daemon | grep -E 'exo CLI|Exoscale allocator ready'
|
||||
```
|
||||
|
||||
### 6. Import an image
|
||||
|
||||
```bash
|
||||
sudo spx admin images import --name ubuntu-26.04-x86_64 --config /etc/spinifex/spinifex.toml
|
||||
```
|
||||
|
||||
For EKS, also import `spinifex-eks-node`. `sudo spx admin images list --config /etc/spinifex/spinifex.toml` shows the full catalogue.
|
||||
|
||||
---
|
||||
|
||||
## Use it from your laptop
|
||||
|
||||
Copy the cluster's CA and the admin credentials from the instance:
|
||||
|
||||
```bash
|
||||
NODE=<instance public IP>
|
||||
mkdir -p ~/.spinifex
|
||||
ssh ubuntu@$NODE 'sudo cat /etc/spinifex/ca.pem' > ~/.spinifex/exo-ca.pem
|
||||
```
|
||||
|
||||
Copy the `[spinifex]` section of `~/.aws/credentials` on the instance into your own `~/.aws/credentials` as `[spinifex-exo]`, then:
|
||||
|
||||
```bash
|
||||
aws configure set --profile spinifex-exo region eu-central-1
|
||||
export AWS_PROFILE=spinifex-exo AWS_CA_BUNDLE=~/.spinifex/exo-ca.pem
|
||||
export AWS_ENDPOINT_URL=https://$NODE:9999
|
||||
|
||||
aws ec2 describe-regions # proves the connection
|
||||
```
|
||||
|
||||
### Launch an instance
|
||||
|
||||
```bash
|
||||
aws ec2 import-key-pair --key-name demo --public-key-material fileb://~/.ssh/id_ed25519.pub
|
||||
SG=$(aws ec2 describe-security-groups --filters Name=group-name,Values=default --query 'SecurityGroups[0].GroupId' --output text)
|
||||
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr 0.0.0.0/0
|
||||
|
||||
AMI=$(aws ec2 describe-images --filters Name=name,Values='*ubuntu-26.04*' --query 'Images[0].ImageId' --output text)
|
||||
aws ec2 run-instances --image-id "$AMI" --instance-type t3.small --key-name demo \
|
||||
--query 'Instances[0].InstanceId' --output text
|
||||
```
|
||||
|
||||
After about a minute, `describe-instances` shows a public IP. That is a new Exoscale Elastic IP, and it appears in the Exoscale console with a description beginning `spinifex:`.
|
||||
|
||||
```bash
|
||||
ssh ubuntu@<public IP> 'curl -s ifconfig.me' # prints the same address
|
||||
```
|
||||
|
||||
Terminating the instance deletes the Elastic IP at Exoscale.
|
||||
|
||||
### Load balancers and EKS
|
||||
|
||||
The [Terraform workbooks](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks) run unchanged. Pass the region and endpoint:
|
||||
|
||||
```bash
|
||||
cd nginx-alb
|
||||
terraform init
|
||||
terraform apply -var region=eu-central-1 -var spinifex_endpoint=https://$NODE:9999
|
||||
```
|
||||
|
||||
`nginx-alb` builds two web servers behind an internet-facing load balancer. `eks-quickstart` builds a Kubernetes cluster with one worker. Run `terraform destroy` before starting the next one, because of the quota below.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Exoscale allows five Elastic IPs per organisation by default**, and every public address Spinifex hands out uses one. An instance with a public IP uses one, an internet-facing load balancer one, a NAT gateway one, and `eks-quickstart` three. Past the quota, `allocate-address` returns `AddressLimitExceeded` and a launch returns `InsufficientAddressCapacity`. Ask Exoscale support to raise it.
|
||||
>
|
||||
> **Exoscale keeps counting a deleted Elastic IP for a while.** After a `terraform destroy`, `exo limits` still shows the old usage even though `exo compute elastic-ip list` no longer shows the addresses, so an `apply` straight afterwards can hit the quota. Wait until `exo limits` agrees with the list before starting the next workbook.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**A guest's public IP does not answer from the instance itself.** That is expected: traffic from the host to a guest's Elastic IP is dropped. Test from your laptop.
|
||||
|
||||
**SSH or HTTP is refused straight after launch.** Guests take 30 to 60 seconds to boot. Wait and retry.
|
||||
|
||||
**An EKS cluster goes to `FAILED` within seconds, or a load balancer never becomes active.** Run `exo limits`. If Elastic IP usage is at the maximum, the cluster could not get its public endpoint; destroy it, wait for usage to drop, and apply again.
|
||||
|
||||
**The daemon logs an `exo` error at start.** Run the check at the end of step 3. A wrong key, secret or zone is the usual cause.
|
||||
|
||||
**An Elastic IP you created yourself.** Spinifex only ever touches Elastic IPs whose description starts with `spinifex:<instance ID>:`. Anything else in the organisation is left alone, but it still counts against the quota.
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
Proven on one `standard.huge` instance in `de-fra-1` on 2026-09-30: EC2 with a public IP, Elastic IP allocate and release, the `nginx-alb` workbook and the `eks-quickstart` workbook with one worker. Each was reached from the internet and left no Elastic IP behind at Exoscale.
|
||||
|
||||
Not yet tested:
|
||||
|
||||
- **More than one node.** Each node would carry its own guests' Elastic IPs, with cluster traffic over an Exoscale private network.
|
||||
- **Moving an Elastic IP between nodes** with its guest.
|
||||
- **A narrowly scoped API key.** The proof used an unrestricted key.
|
||||
- **Other zones, bare metal and GPU instances.**
|
||||
- **Guest disk performance** on Exoscale block storage.
|
||||
@@ -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.
|
||||
|
||||