Compare commits
403 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab16c75cc | |||
| 85184d45f9 | |||
| 9d665f9dde | |||
| 97f57d3826 | |||
| 3c2f57101f | |||
| 4542b22e87 | |||
| ca965e48d6 | |||
| 09638a874d | |||
| b7dea45ee0 | |||
| 69404b38ce | |||
| 5c14f56428 | |||
| 11b3f37d06 | |||
| c234cee60d | |||
| efe054e4e4 | |||
| 714898022e | |||
| a6ef8e575b | |||
| f5c9ad59cf | |||
| c73e172fac | |||
| 2dae3f00bd | |||
| 71ef7236ca | |||
| 87dd7d242a | |||
| 6ec2f4bd08 | |||
| 82b0bdfd56 | |||
| 97977be286 | |||
| df2e652ca6 | |||
| 84278b794f | |||
| 819a7a8cd3 | |||
| 9f0b8c5d7d | |||
| ecfbd4fd19 | |||
| 9bc15aa306 | |||
| 619f944d80 | |||
| 7b8bb92905 | |||
| 9b9bcc5b8f | |||
| 6538a6cb69 | |||
| 04f63ad8c6 | |||
| 3494ea4eb0 | |||
| 050054ad0f | |||
| bc9f881034 | |||
| 3dad452b09 | |||
| 5911a878c6 | |||
| 94735e7d19 | |||
| b0d58ff0f6 | |||
| a3d1d5f7b3 | |||
| a3994510e0 | |||
| 3da6e3cfa9 | |||
| 138083f6c8 | |||
| f2dcb2f33f | |||
| 7128dc785c | |||
| d663d143a4 | |||
| dd30790d51 | |||
| a7f965526b | |||
| f3cfee3189 | |||
| b2921dbad6 | |||
| 1f5aee54d7 | |||
| b18bad5b93 | |||
| a2107bba8a | |||
| 05d5148611 | |||
| 94600d7b84 | |||
| a25bc6ef19 | |||
| 8735275d84 | |||
| 187b641726 | |||
| 660fe5227e | |||
| 58bdcd43f5 | |||
| eeb7ebc5db | |||
| e799af48e0 | |||
| 1059412e28 | |||
| 0434a823cc | |||
| 40a42cbb73 | |||
| d509df731a | |||
| c01207b342 | |||
| a00550a200 | |||
| 6b75470b0f | |||
| 825f7202b4 | |||
| 853da4cc2a | |||
| 904071099a | |||
| abd93098fc | |||
| 5126a1df8d | |||
| 112738198f | |||
| c7d4040dd3 | |||
| 068aef5589 | |||
| c908662f69 | |||
| 6f0821d02b | |||
| 604693ea0c | |||
| 1d38311e07 | |||
| 75fb15be2b | |||
| 3c5e09a937 | |||
| fd17c35330 | |||
| d7c95bda5e | |||
| 265556dc4e | |||
| 0f89db1893 | |||
| 3b0376c3a6 | |||
| cc3c77fdb7 | |||
| f73e151c77 | |||
| 889ef361eb | |||
| 7fd1019efa | |||
| 0a78324405 | |||
| 7cc90f554c | |||
| f0d48c4274 | |||
| b79460f9ff | |||
| 93a6704944 | |||
| 1e3d82f978 | |||
| 1ca8621e16 | |||
| 56a1ead706 | |||
| acefd1cfd4 | |||
| 1bad55ce54 | |||
| 869184efb5 | |||
| b46fe2531a | |||
| c4b2b13c89 | |||
| 49acc12390 | |||
| 3d1f8340be | |||
| 3a5d823fbd | |||
| 27d7fdd12b | |||
| d6863f3d5e | |||
| 3c6077ef58 | |||
| 728cdd972b | |||
| c3242b6bc4 | |||
| f97ff772a3 | |||
| f4e0b4a01b | |||
| 6736722533 | |||
| 9d51800f58 | |||
| 36425a48eb | |||
| a209805ffc | |||
| b591c04880 | |||
| ad3762bdcc | |||
| 7b9fdeb60c | |||
| 9c98c023ce | |||
| f00a376d0b | |||
| c469eecfa1 | |||
| aa1fc546bd | |||
| 4f991095ee | |||
| a64ee2b2da | |||
| 14efe44125 | |||
| e131caf9ec | |||
| e46b427938 | |||
| 8d6f4df526 | |||
| 1a41731ef5 | |||
| 6f58a3f190 | |||
| 883fac980c | |||
| 72503cd7ae | |||
| 084e93cfd0 | |||
| d8924a2ecc | |||
| 6f02f6bc22 | |||
| 3986549783 | |||
| ff9f9485d2 | |||
| 29ba511b38 | |||
| 8e9aff3299 | |||
| 911b36a696 | |||
| 402a04e1b9 | |||
| 25d02707d0 | |||
| bb9cd8ea23 | |||
| 0e51a6b06a | |||
| d3e21d3360 | |||
| 32effaa16e | |||
| 054485e930 | |||
| c8429f98ae | |||
| 68ae17dcf1 | |||
| 5b39efb078 | |||
| 24429ee8b5 | |||
| 7d6e0780b5 | |||
| 83f1766081 | |||
| 811a0230fc | |||
| 69c8b20b78 | |||
| f0bb064237 | |||
| 1ad03bb518 | |||
| c75f4a0160 | |||
| 01cd3b9ab1 | |||
| b7b639084b | |||
| 7dcad2ba1d | |||
| 7cf0783122 | |||
| 13eae4d40b | |||
| b32111102d | |||
| 71d0f31997 | |||
| f99eb42ca7 | |||
| 56cdc158a6 | |||
| a4ae813e38 | |||
| e0118975d1 | |||
| 36e3481ca3 | |||
| 515c313e07 | |||
| 0547bdd1fe | |||
| e75953f5e2 | |||
| e5a6f3ea60 | |||
| 36da673ecd | |||
| ca63011f25 | |||
| 96b5ad5740 | |||
| e8cb102819 | |||
| ebd0a64fe5 | |||
| 02aba39942 | |||
| db9e93351e | |||
| c8ca746bf0 | |||
| 67bdc9d4af | |||
| 8d2830481d | |||
| 6238988a8f | |||
| 4365fb0958 | |||
| 48c90b795a | |||
| 18684d5c8b | |||
| 708bb1d340 | |||
| 893fbbc243 | |||
| 7723ad4a89 | |||
| ab95566795 | |||
| c5b6beb006 | |||
| e8e05d6898 | |||
| b001c3929d | |||
| 03601ca89b | |||
| 1a2e490ae7 | |||
| 553e7857df | |||
| cbb3e91ada | |||
| 7449090c86 | |||
| 10a2a8116f | |||
| 26e590716e | |||
| e27d909e8d | |||
| ccb6ed287d | |||
| 9a7e914d28 | |||
| 6026d46ba1 | |||
| f0eb28ffc2 | |||
| d33e6f3d70 | |||
| 45ea467fcc | |||
| 7c007ea6b5 | |||
| 820b605c0b | |||
| a56a2baf1f | |||
| 6dfb350be8 | |||
| 5d6f25a695 | |||
| 1b39439cb8 | |||
| 99f9949bdd | |||
| 5598e523d5 | |||
| dda4575888 | |||
| e5b6204875 | |||
| 21aa1296c9 | |||
| ea6b5d770a | |||
| f986cf9748 | |||
| 11f7c99955 | |||
| 1b45c7fc7d | |||
| 868a92bd55 | |||
| af52d881b9 | |||
| c51ea0c29d | |||
| 8cf0cc332f | |||
| 1d6cefd9d2 | |||
| 0c160d7f45 | |||
| 91e4efb0ef | |||
| f5ec8c3bae | |||
| 8795a7b926 | |||
| 39e9fa6fb8 | |||
| 853b5bfd32 | |||
| d7c72e80af | |||
| 475e632404 | |||
| 92e25a9653 | |||
| ec525d3a6d | |||
| 94978f517b | |||
| 4d7239e645 | |||
| acc8dc1994 | |||
| a7ed93c6f3 | |||
| 67075c6600 | |||
| ae223cdf15 | |||
| b73a630f41 | |||
| c283100fcf | |||
| 355decd406 | |||
| b76953bfb4 | |||
| c9e83380df | |||
| e421840541 | |||
| 406ba9431e | |||
| 7882f77a36 | |||
| f7b99f0c50 | |||
| f3ee50e9b8 | |||
| 4f3b54abc2 | |||
| a7925d65f2 | |||
| 0d2e91da37 | |||
| e2c7a64aff | |||
| 588141cf67 | |||
| 9e3076727e | |||
| c1df418c5c | |||
| 5e0d819393 | |||
| 6d3b2f7e5d | |||
| f3c5bce811 | |||
| 9b8863a182 | |||
| 6e807f88cf | |||
| 32f5ec92c0 | |||
| 6a4b823de7 | |||
| 91483d632e | |||
| 5426dc1bb0 | |||
| 15f88caee7 | |||
| 5d194098ca | |||
| 94a80a3be7 | |||
| 3f642524a8 | |||
| de1340a331 | |||
| ae094cb936 | |||
| c2ab63fdef | |||
| 04bea48a39 | |||
| a92ea13798 | |||
| 46670ee8af | |||
| cbc04a2437 | |||
| 216842cc1b | |||
| 8ea40a1973 | |||
| 5bfc680a68 | |||
| 98d5ec92ed | |||
| 2244ba56b0 | |||
| 9e8e944faa | |||
| 3129c9eb70 | |||
| f744aedede | |||
| e5801aa6ba | |||
| 77a4a40ca6 | |||
| ab84905297 | |||
| 2a3ada5e93 | |||
| adfd20dee5 | |||
| 4ffc7785a5 | |||
| e91b67a7c6 | |||
| 051ecbd4ce | |||
| ef68c5bffe | |||
| 64c7305b37 | |||
| d025cab50e | |||
| 1c194da915 | |||
| 4ad5843ff5 | |||
| 7ea0353d2f | |||
| f8fc2b2cbb | |||
| 74671a05a5 | |||
| 90a3da2cc2 | |||
| 06a8da8fb5 | |||
| 7c6f03f0d0 | |||
| e6b48ce170 | |||
| fca9d018fc | |||
| 4a2ae33dd9 | |||
| 9e53443f7a | |||
| bf6b25842f | |||
| 5b442e8eef | |||
| a3c4819c1c | |||
| 70199f410a | |||
| 7cdf630cf7 | |||
| fb8c42e934 | |||
| cc46cf746e | |||
| e65f00c86a | |||
| 85f51b8084 | |||
| 4749a37725 | |||
| 982a7b6b45 | |||
| b81fdb5bc0 | |||
| 71e82b22a7 | |||
| 21c4754336 | |||
| ec29cc2745 | |||
| 5d31f05dba | |||
| 73e5905092 | |||
| edcc53b947 | |||
| 820f620e34 | |||
| 3d73dd180b | |||
| 7bfb032f90 | |||
| 15d6b0aa20 | |||
| a66b1a3d48 | |||
| cbbd81bc76 | |||
| 8c4adf08a3 | |||
| c0b89e6d8c | |||
| dd86805514 | |||
| ad1339505d | |||
| ba7e6a5c67 | |||
| 4d56565c3f | |||
| ae316dba47 | |||
| bdf0522895 | |||
| e347f0a75e | |||
| c2992ebe5e | |||
| ffca7330c5 | |||
| a21a275776 | |||
| 78bc0d989d | |||
| 8c6f66670a | |||
| 1fca43c9f4 | |||
| 34b50b11fb | |||
| 8f81ac29a4 | |||
| eefed70658 | |||
| 216ca9e710 | |||
| fefd5ed9e8 | |||
| d47f2ab4fd | |||
| 58a9d5741f | |||
| 625a78c529 | |||
| 88baf5e202 | |||
| 6379a2098a | |||
| 638828f636 | |||
| 0fbf4537c0 | |||
| 1b5108d379 | |||
| bab7b1dea3 | |||
| 30b08ca984 | |||
| 3467e0cf22 | |||
| 35d0df5b56 | |||
| 648b36d672 | |||
| d6062ed550 | |||
| 93aaf0f991 | |||
| f34452883c | |||
| c21f3dc188 | |||
| daae6ca6b8 | |||
| 32276c7cc7 | |||
| 30394b735b | |||
| a25a4cec34 | |||
| f6c6660f15 | |||
| 704874c0c7 | |||
| 665093343c | |||
| bb9e8db386 | |||
| 0d4a70ef28 | |||
| 946429d792 | |||
| a554fe4ecf | |||
| 2af3bf5287 | |||
| 1f38213d4b | |||
| 1ee7012ba9 | |||
| b6e99947e1 | |||
| 5481ef4361 | |||
| ee06105c56 | |||
| 150bf246a4 | |||
| 9b64c1911f | |||
| c1cdfc07c3 | |||
| c6af189638 | |||
| 5e30d12f76 |
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Only enforce preflight on dev and main
|
||||
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run preflight if Go files were staged
|
||||
if git diff --cached --name-only | grep -q '\.go$'; then
|
||||
echo "Go files staged on $branch — running make preflight..."
|
||||
make preflight
|
||||
else
|
||||
echo "No Go files staged, skipping preflight."
|
||||
fi
|
||||
@@ -0,0 +1,225 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#161b22"/>
|
||||
<stop offset="45%" style="stop-color:#1f2028"/>
|
||||
<stop offset="100%" style="stop-color:#161b22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC262633"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D14"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#F87171"/>
|
||||
<stop offset="50%" style="stop-color:#DC2626"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="620" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<!-- Title bar -->
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ARCHITECTURE</text>
|
||||
<text x="24" y="52" font-family="'Segoe UI', system-ui, sans-serif" font-size="16" fill="#f5f5f5" font-weight="500">AWS SDK → Gateway → NATS → Service daemons</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">stateless · message-driven · horizontally scalable</text>
|
||||
|
||||
<!-- ═══ CLIENT LAYER ════════════════════════════════════ -->
|
||||
<rect x="16" y="72" width="868" height="78" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="32" y="93" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700" letter-spacing="1">CLIENTS</text>
|
||||
<text x="32" y="108" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Standard AWS tooling — unmodified, no shims, no custom SDK</text>
|
||||
|
||||
<g transform="translate(32, 120)" font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<rect x="0" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="43" y="15" fill="#7ee787" text-anchor="middle">aws-cli</text>
|
||||
|
||||
<rect x="94" y="0" width="96" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="142" y="15" fill="#7ee787" text-anchor="middle">Terraform</text>
|
||||
|
||||
<rect x="198" y="0" width="76" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="236" y="15" fill="#7ee787" text-anchor="middle">boto3</text>
|
||||
|
||||
<rect x="282" y="0" width="98" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="331" y="15" fill="#7ee787" text-anchor="middle">aws-sdk-go</text>
|
||||
|
||||
<rect x="388" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="431" y="15" fill="#7ee787" text-anchor="middle">kubectl</text>
|
||||
|
||||
<rect x="482" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="525" y="15" fill="#7ee787" text-anchor="middle">Packer</text>
|
||||
|
||||
<rect x="576" y="0" width="120" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
|
||||
<text x="636" y="15" fill="#7ee787" text-anchor="middle">your application</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ HTTPS + SigV4 connector ═══════════════════════════ -->
|
||||
<line x1="450" y1="150" x2="450" y2="178" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="390" y="154" width="120" height="18" rx="9" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
|
||||
<text x="450" y="166" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#F87171" text-anchor="middle">HTTPS · SigV4 · :9999</text>
|
||||
|
||||
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
|
||||
<rect x="16" y="178" width="868" height="68" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
|
||||
<text x="32" y="200" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY</text>
|
||||
<text x="32" y="215" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
|
||||
|
||||
<g transform="translate(32, 224)" font-family="'SF Mono', ui-monospace, monospace" font-size="9">
|
||||
<rect x="0" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="39" y="11" fill="#FCA5A5" text-anchor="middle">TLS 1.3</text>
|
||||
|
||||
<rect x="86" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="125" y="11" fill="#FCA5A5" text-anchor="middle">SigV4 auth</text>
|
||||
|
||||
<rect x="172" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="221" y="11" fill="#FCA5A5" text-anchor="middle">policy eval</text>
|
||||
|
||||
<rect x="278" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="327" y="11" fill="#FCA5A5" text-anchor="middle">req routing</text>
|
||||
|
||||
<rect x="384" y="0" width="88" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="428" y="11" fill="#FCA5A5" text-anchor="middle">rate-limit</text>
|
||||
|
||||
<rect x="480" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="529" y="11" fill="#FCA5A5" text-anchor="middle">audit log</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ NATS BUS ═══════════════════════════════════════ -->
|
||||
<line x1="450" y1="246" x2="450" y2="272" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="16" y="272" width="868" height="34" rx="6" fill="url(#nats-grad)" opacity="0.92"/>
|
||||
<text x="32" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS BUS</text>
|
||||
<text x="140" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#fee2e2" opacity="0.95">ec2.* ebs.* s3.* vpc.* iam.*</text>
|
||||
<text x="876" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">queue groups · fan-out · request/reply</text>
|
||||
|
||||
<!-- ═══ DAEMON FANOUT CONNECTORS ═══════════════════════ -->
|
||||
<g stroke="#DC2626" stroke-width="0.9" stroke-dasharray="3,3" opacity="0.55" fill="none">
|
||||
<line x1="100" y1="306" x2="100" y2="328"/>
|
||||
<line x1="275" y1="306" x2="275" y2="328"/>
|
||||
<line x1="450" y1="306" x2="450" y2="328"/>
|
||||
<line x1="625" y1="306" x2="625" y2="328"/>
|
||||
<line x1="800" y1="306" x2="800" y2="328"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ SERVICE DAEMONS ROW ═══════════════════════════ -->
|
||||
<!-- EC2 -->
|
||||
<g transform="translate(28, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ec2.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">RunInstances</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">TerminateInstances</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">QMP · cloud-init</text>
|
||||
</g>
|
||||
|
||||
<!-- EBS -->
|
||||
<g transform="translate(204, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ebs.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">CreateVolume</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">AttachVolume</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">snapshots · WAL</text>
|
||||
</g>
|
||||
|
||||
<!-- S3 -->
|
||||
<g transform="translate(380, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">s3.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">PutObject · GetObject</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Multipart · presigned</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">erasure-coded</text>
|
||||
</g>
|
||||
|
||||
<!-- VPC -->
|
||||
<g transform="translate(556, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">vpc.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Subnets · SGs · RTs</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">ALB · NAT · IGW</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">OVN · OVS</text>
|
||||
</g>
|
||||
|
||||
<!-- IAM -->
|
||||
<g transform="translate(732, 328)">
|
||||
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">iam.* daemon</text>
|
||||
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Users · Roles · Keys</text>
|
||||
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Policy evaluation</text>
|
||||
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">audit trail</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ DAEMON → BACKEND CONNECTORS ═══════════════════ -->
|
||||
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
|
||||
<line x1="100" y1="410" x2="100" y2="440"/>
|
||||
<line x1="276" y1="410" x2="276" y2="440"/>
|
||||
<line x1="452" y1="410" x2="452" y2="440"/>
|
||||
<line x1="628" y1="410" x2="628" y2="440"/>
|
||||
<line x1="804" y1="410" x2="804" y2="440"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ BACKEND RESOURCES BAR ═════════════════════════ -->
|
||||
<rect x="16" y="440" width="868" height="26" rx="5" fill="url(#bar-grad)"/>
|
||||
<text x="450" y="458" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">BACKEND RESOURCES · local hardware · no cloud dependency</text>
|
||||
|
||||
<!-- ═══ BACKEND ROW ═══════════════════════════════════ -->
|
||||
<!-- QEMU/KVM -->
|
||||
<g transform="translate(28, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">hypervisor</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">host kernel · virtio</text>
|
||||
</g>
|
||||
|
||||
<!-- Viperblock -->
|
||||
<g transform="translate(204, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Viperblock</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">block storage</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">NVMe WAL · NBD</text>
|
||||
</g>
|
||||
|
||||
<!-- Predastore -->
|
||||
<g transform="translate(380, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Predastore</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">object storage</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">Reed–Solomon</text>
|
||||
</g>
|
||||
|
||||
<!-- OVN/OVS -->
|
||||
<g transform="translate(556, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">OVN / OVS</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SDN fabric</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">overlays · flow rules</text>
|
||||
</g>
|
||||
|
||||
<!-- IAM store -->
|
||||
<g transform="translate(732, 474)">
|
||||
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Identity store</text>
|
||||
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">users · keys · rules</text>
|
||||
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">local · audit-logged</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ FOOTER NOTES ══════════════════════════════════ -->
|
||||
<g transform="translate(16, 558)">
|
||||
<rect width="868" height="48" rx="6" fill="#0a0a0a" stroke="#27272a" stroke-width="0.8"/>
|
||||
<text x="24" y="20" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
|
||||
<tspan fill="#F87171" font-weight="600">Queue groups</tspan> load-balance ops across daemon replicas.
|
||||
<tspan fill="#F87171" font-weight="600"> Fan-out</tspan> (no queue group) dispatches describe calls to every node.
|
||||
</text>
|
||||
<text x="24" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
|
||||
Daemons are <tspan fill="#F87171" font-weight="600">stateless</tspan> — scale horizontally by starting more.
|
||||
No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,154 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="0 0 900 300">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#0d1117;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0a0a0a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="0%" style="stop-color:#7F1D1D;stop-opacity:0.08"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="mulga-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.95" />
|
||||
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.95" />
|
||||
</linearGradient>
|
||||
<linearGradient id="rule" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0" />
|
||||
<stop offset="50%" style="stop-color:#F87171;stop-opacity:0.45" />
|
||||
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0" />
|
||||
</linearGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="900" height="300" fill="url(#bg)" rx="12"/>
|
||||
<rect width="900" height="300" fill="url(#vignette)" rx="12"/>
|
||||
|
||||
<!-- Mesh network background (mulgadc style) -->
|
||||
<g opacity="0.35">
|
||||
<!-- Connections (drawn first, under nodes) -->
|
||||
<g stroke="#7F1D1D" stroke-width="0.6" fill="none">
|
||||
<line x1="80" y1="60" x2="200" y2="40"/>
|
||||
<line x1="80" y1="60" x2="150" y2="140"/>
|
||||
<line x1="200" y1="40" x2="330" y2="90"/>
|
||||
<line x1="150" y1="140" x2="260" y2="180"/>
|
||||
<line x1="150" y1="140" x2="60" y2="220"/>
|
||||
<line x1="330" y1="90" x2="260" y2="180"/>
|
||||
<line x1="260" y1="180" x2="120" y2="260"/>
|
||||
<line x1="60" y1="220" x2="120" y2="260"/>
|
||||
|
||||
<line x1="620" y1="50" x2="760" y2="90"/>
|
||||
<line x1="620" y1="50" x2="700" y2="160"/>
|
||||
<line x1="760" y1="90" x2="850" y2="160"/>
|
||||
<line x1="700" y1="160" x2="820" y2="220"/>
|
||||
<line x1="850" y1="160" x2="820" y2="220"/>
|
||||
<line x1="700" y1="160" x2="600" y2="240"/>
|
||||
<line x1="600" y1="240" x2="820" y2="220"/>
|
||||
<line x1="760" y1="90" x2="700" y2="160"/>
|
||||
</g>
|
||||
<!-- Nodes -->
|
||||
<g fill="#DC2626">
|
||||
<circle cx="80" cy="60" r="2.5"/>
|
||||
<circle cx="200" cy="40" r="2"/>
|
||||
<circle cx="330" cy="90" r="2.5"/>
|
||||
<circle cx="150" cy="140" r="3"/>
|
||||
<circle cx="260" cy="180" r="2"/>
|
||||
<circle cx="60" cy="220" r="2"/>
|
||||
<circle cx="120" cy="260" r="2.5"/>
|
||||
|
||||
<circle cx="620" cy="50" r="2"/>
|
||||
<circle cx="760" cy="90" r="3"/>
|
||||
<circle cx="700" cy="160" r="2.5"/>
|
||||
<circle cx="850" cy="160" r="2"/>
|
||||
<circle cx="820" cy="220" r="2.5"/>
|
||||
<circle cx="600" cy="240" r="2"/>
|
||||
</g>
|
||||
<!-- Pulse rings (static "about-to-pulse" look) -->
|
||||
<g fill="none" stroke="#F87171" stroke-width="0.8" opacity="0.5">
|
||||
<circle cx="150" cy="140" r="6"/>
|
||||
<circle cx="760" cy="90" r="6"/>
|
||||
<circle cx="700" cy="160" r="8"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Main emblem: cluster-node stack with NATS bus -->
|
||||
<g transform="translate(200, 150)">
|
||||
<!-- Outer glow ring -->
|
||||
<circle cx="0" cy="0" r="64" fill="none" stroke="url(#mulga-grad)" stroke-width="1.5" opacity="0.35" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Top node -->
|
||||
<rect x="-24" y="-52" width="48" height="22" rx="3" fill="none" stroke="#DC2626" stroke-width="1.8"/>
|
||||
<circle cx="-16" cy="-41" r="1.6" fill="#F87171"/>
|
||||
<circle cx="-8" cy="-41" r="1.6" fill="#F87171"/>
|
||||
<line x1="0" y1="-41" x2="16" y2="-41" stroke="#F87171" stroke-width="0.9" opacity="0.6"/>
|
||||
|
||||
<!-- Middle node (active) -->
|
||||
<rect x="-30" y="-14" width="60" height="28" rx="3" fill="url(#mulga-grad)" opacity="0.22" stroke="#DC2626" stroke-width="2"/>
|
||||
<circle cx="-20" cy="0" r="2" fill="#F87171"/>
|
||||
<circle cx="-10" cy="0" r="2" fill="#F87171"/>
|
||||
<line x1="-2" y1="0" x2="22" y2="0" stroke="#F87171" stroke-width="1.2" opacity="0.8"/>
|
||||
|
||||
<!-- Bottom node -->
|
||||
<rect x="-24" y="30" width="48" height="22" rx="3" fill="none" stroke="#7F1D1D" stroke-width="1.8"/>
|
||||
<circle cx="-16" cy="41" r="1.6" fill="#DC2626"/>
|
||||
<circle cx="-8" cy="41" r="1.6" fill="#DC2626"/>
|
||||
<line x1="0" y1="41" x2="16" y2="41" stroke="#DC2626" stroke-width="0.9" opacity="0.6"/>
|
||||
|
||||
<!-- Radiating mesh ports -->
|
||||
<g stroke-width="0.9" opacity="0.55">
|
||||
<line x1="-30" y1="0" x2="-58" y2="-26" stroke="#DC2626"/>
|
||||
<line x1="-30" y1="0" x2="-64" y2="0" stroke="#DC2626"/>
|
||||
<line x1="-30" y1="0" x2="-58" y2="26" stroke="#DC2626"/>
|
||||
<line x1="30" y1="0" x2="58" y2="-26" stroke="#7F1D1D"/>
|
||||
<line x1="30" y1="0" x2="64" y2="0" stroke="#7F1D1D"/>
|
||||
<line x1="30" y1="0" x2="58" y2="26" stroke="#7F1D1D"/>
|
||||
</g>
|
||||
<g fill="#F87171">
|
||||
<circle cx="-58" cy="-26" r="2.5"/>
|
||||
<circle cx="-64" cy="0" r="2.5"/>
|
||||
<circle cx="-58" cy="26" r="2.5"/>
|
||||
</g>
|
||||
<g fill="#DC2626">
|
||||
<circle cx="58" cy="-26" r="2.5"/>
|
||||
<circle cx="64" cy="0" r="2.5"/>
|
||||
<circle cx="58" cy="26" r="2.5"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="340" y="120" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="56" font-weight="700" fill="#f5f5f5" letter-spacing="-1.5">Spinifex</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="340" y="155" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">Open-source AWS-compatible platform</text>
|
||||
<text x="340" y="180" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">for secure edge deployments</text>
|
||||
|
||||
<!-- Rule -->
|
||||
<rect x="340" y="200" width="440" height="1" fill="url(#rule)" rx="1"/>
|
||||
|
||||
<!-- Service pills -->
|
||||
<g font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" font-weight="500">
|
||||
<rect x="340" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
|
||||
<text x="365" y="231" fill="#F87171" text-anchor="middle">EC2</text>
|
||||
|
||||
<rect x="398" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
|
||||
<text x="423" y="231" fill="#F87171" text-anchor="middle">EBS</text>
|
||||
|
||||
<rect x="456" y="215" width="45" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
|
||||
<text x="478" y="231" fill="#F87171" text-anchor="middle">S3</text>
|
||||
|
||||
<rect x="509" y="215" width="55" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
|
||||
<text x="536" y="231" fill="#F87171" text-anchor="middle">VPC</text>
|
||||
|
||||
<rect x="572" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
|
||||
<text x="597" y="231" fill="#F87171" text-anchor="middle">IAM</text>
|
||||
|
||||
<rect x="630" y="215" width="78" height="24" rx="12" fill="#18181b" stroke="#52525b" stroke-width="0.5" stroke-opacity="0.8"/>
|
||||
<text x="669" y="231" fill="#a1a1aa" text-anchor="middle">Air-Gapped</text>
|
||||
</g>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="340" y="265" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" fill="#71717a" letter-spacing="1.8">YOUR INFRASTRUCTURE · YOUR HARDWARE · YOUR CLOUD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,175 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="720" viewBox="0 0 900 720">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#161b22"/>
|
||||
<stop offset="45%" style="stop-color:#1f2028"/>
|
||||
<stop offset="100%" style="stop-color:#161b22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC262633"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D14"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#F87171"/>
|
||||
<stop offset="50%" style="stop-color:#DC2626"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="daemon-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC262622"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="720" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">HIGH-LEVEL ARCHITECTURE</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">SDK → Gateway → NATS → Daemons → VMs</text>
|
||||
|
||||
<!-- ═══ AWS SDK / CLI ══════════════════════════════════ -->
|
||||
<rect x="16" y="50" width="868" height="76" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
|
||||
<text x="32" y="72" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="1">AWS SDK / CLI</text>
|
||||
<text x="32" y="90" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Users interact with Spinifex using standard AWS SDKs and CLIs against a custom endpoint URL</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
|
||||
<rect x="32" y="98" width="480" height="20" rx="4" fill="#0a0a0a" stroke="#3fb950" stroke-width="0.6" stroke-opacity="0.4"/>
|
||||
<text x="42" y="112" fill="#7ee787">$ aws ec2 run-instances --endpoint-url https://localhost:9999 ...</text>
|
||||
</g>
|
||||
|
||||
<!-- Connector: HTTPS + SigV4 -->
|
||||
<line x1="450" y1="126" x2="450" y2="154" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="380" y="128" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
|
||||
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">HTTPS · SigV4 Auth</text>
|
||||
|
||||
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
|
||||
<rect x="16" y="154" width="868" height="104" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
|
||||
<text x="32" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY · port 9999</text>
|
||||
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/services/awsgw/awsgw.go</text>
|
||||
|
||||
<!-- Two-column feature bullets -->
|
||||
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
|
||||
<text x="40" y="200">● TLS termination</text>
|
||||
<text x="40" y="220">● EC2 Query Protocol parsing</text>
|
||||
<text x="40" y="240">● XML response generation</text>
|
||||
|
||||
<text x="460" y="200">● AWS SigV4 authentication</text>
|
||||
<text x="460" y="220">● Routes to service handlers</text>
|
||||
<text x="460" y="240">● NATS client connection</text>
|
||||
</g>
|
||||
|
||||
<!-- Connector: JSON over NATS -->
|
||||
<line x1="450" y1="258" x2="450" y2="286" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
|
||||
<rect x="380" y="260" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
|
||||
<text x="450" y="274" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">JSON over NATS</text>
|
||||
|
||||
<!-- ═══ NATS BROKER ═══════════════════════════════════ -->
|
||||
<rect x="16" y="286" width="868" height="124" rx="8" fill="#1a0a0a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
|
||||
<rect x="16" y="286" width="868" height="28" rx="8" fill="url(#nats-grad)" opacity="0.9"/>
|
||||
<text x="32" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS MESSAGE BROKER</text>
|
||||
<text x="876" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">request/reply · queue groups · fan-out</text>
|
||||
|
||||
<!-- Two-column topics/queue groups -->
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<text x="40" y="332" fill="#F87171" font-weight="600">Topics</text>
|
||||
<text x="40" y="350" fill="#f5f5f5">ec2.RunInstances</text>
|
||||
<text x="40" y="368" fill="#f5f5f5">ec2.DescribeInstances</text>
|
||||
<text x="40" y="386" fill="#f5f5f5">ec2.cmd.{instanceID}</text>
|
||||
<text x="40" y="404" fill="#f5f5f5">ebs.mount · ebs.unmount</text>
|
||||
|
||||
<text x="460" y="332" fill="#F87171" font-weight="600">Delivery mode</text>
|
||||
<text x="460" y="350" fill="#a1a1aa">spinifex-workers <tspan fill="#71717a">(load-balanced)</tspan></text>
|
||||
<text x="460" y="368" fill="#a1a1aa">no queue group <tspan fill="#71717a">(fan-out to all nodes)</tspan></text>
|
||||
<text x="460" y="386" fill="#a1a1aa">per-instance <tspan fill="#71717a">(start / stop / terminate)</tspan></text>
|
||||
<text x="460" y="404" fill="#a1a1aa">Viperblock request/reply</text>
|
||||
</g>
|
||||
|
||||
<!-- Fan-out connectors to 3 daemons -->
|
||||
<g stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6" fill="none">
|
||||
<line x1="450" y1="410" x2="450" y2="430"/>
|
||||
<line x1="450" y1="430" x2="155" y2="452"/>
|
||||
<line x1="450" y1="430" x2="450" y2="452"/>
|
||||
<line x1="450" y1="430" x2="745" y2="452"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ DAEMON ROW ════════════════════════════════════ -->
|
||||
<!-- Daemon 1 -->
|
||||
<g transform="translate(32, 452)">
|
||||
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
|
||||
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 1</text>
|
||||
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
|
||||
<text x="20" y="52">● NATS subscriber</text>
|
||||
<text x="20" y="70">● Resource manager</text>
|
||||
<text x="20" y="88">● QEMU/KVM launcher</text>
|
||||
<text x="20" y="106">● QMP monitoring</text>
|
||||
</g>
|
||||
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
|
||||
</g>
|
||||
|
||||
<!-- Daemon 2 -->
|
||||
<g transform="translate(327, 452)">
|
||||
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
|
||||
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 2</text>
|
||||
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
|
||||
<text x="20" y="52">● NATS subscriber</text>
|
||||
<text x="20" y="70">● Resource manager</text>
|
||||
<text x="20" y="88">● QEMU/KVM launcher</text>
|
||||
<text x="20" y="106">● QMP monitoring</text>
|
||||
</g>
|
||||
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
|
||||
</g>
|
||||
|
||||
<!-- Daemon N -->
|
||||
<g transform="translate(622, 452)">
|
||||
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
|
||||
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node N</text>
|
||||
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
|
||||
<text x="20" y="52">● NATS subscriber</text>
|
||||
<text x="20" y="70">● Resource manager</text>
|
||||
<text x="20" y="88">● QEMU/KVM launcher</text>
|
||||
<text x="20" y="106">● QMP monitoring</text>
|
||||
</g>
|
||||
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
|
||||
</g>
|
||||
|
||||
<!-- Daemon → VM connectors -->
|
||||
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
|
||||
<line x1="155" y1="576" x2="155" y2="610"/>
|
||||
<line x1="450" y1="576" x2="450" y2="610"/>
|
||||
<line x1="745" y1="576" x2="745" y2="610"/>
|
||||
</g>
|
||||
|
||||
<!-- ═══ VM BAR + ROW ═══════════════════════════════════ -->
|
||||
<rect x="16" y="610" width="868" height="22" rx="5" fill="url(#bar-grad)"/>
|
||||
<text x="450" y="625" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">WORKLOADS · QEMU/KVM virtual machines per node</text>
|
||||
|
||||
<!-- VM boxes -->
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<rect x="32" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="155" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
|
||||
<text x="155" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
|
||||
<text x="155" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 1 workloads</text>
|
||||
|
||||
<rect x="327" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="450" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
|
||||
<text x="450" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
|
||||
<text x="450" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 2 workloads</text>
|
||||
|
||||
<rect x="622" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
|
||||
<text x="745" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
|
||||
<text x="745" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
|
||||
<text x="745" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node N workloads</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">BASTION · only entry point into a private subnet</text>
|
||||
|
||||
<!-- WAN -->
|
||||
<g transform="translate(40, 56)">
|
||||
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
|
||||
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">operator / laptop</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow: SSH -->
|
||||
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
|
||||
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
|
||||
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
|
||||
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">SSH</text>
|
||||
|
||||
<!-- Bastion -->
|
||||
<g transform="translate(338, 56)">
|
||||
<rect width="220" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.3" stroke-opacity="0.6"/>
|
||||
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Bastion</text>
|
||||
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">public subnet</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow: SSH (private IP) -->
|
||||
<line x1="558" y1="86" x2="638" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
|
||||
<polygon points="634,82 646,86 634,90" fill="#a855f7" opacity="0.8"/>
|
||||
<rect x="552" y="62" width="140" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
|
||||
<text x="622" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">SSH (private IP)</text>
|
||||
|
||||
<!-- App server -->
|
||||
<g transform="translate(656, 56)">
|
||||
<rect width="220" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
|
||||
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">App Server</text>
|
||||
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">private subnet · no internet</text>
|
||||
</g>
|
||||
|
||||
<text x="450" y="156" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">App server has no route to the internet — all access hops through the bastion.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,98 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="480" viewBox="0 0 900 480">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="inet-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3fb9501f"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f71f"/>
|
||||
<stop offset="100%" style="stop-color:#a855f708"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="480" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NGINX + ALB · VPC WITH PUBLIC AND PRIVATE SUBNETS</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">10.20.0.0/16 · multi-AZ</text>
|
||||
|
||||
<!-- Internet -->
|
||||
<rect x="350" y="54" width="200" height="40" rx="8" fill="url(#inet-grad)" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
|
||||
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Internet</text>
|
||||
|
||||
<!-- connector to IGW -->
|
||||
<line x1="450" y1="94" x2="450" y2="116" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<polygon points="446,112 454,112 450,120" fill="#71717a" opacity="0.7"/>
|
||||
|
||||
<!-- IGW -->
|
||||
<rect x="380" y="122" width="140" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
|
||||
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">IGW</text>
|
||||
<text x="450" y="158" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">nginx-alb-igw</text>
|
||||
|
||||
<!-- Fan-out to two public subnets -->
|
||||
<line x1="450" y1="166" x2="450" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="230" y1="184" x2="670" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="230" y1="184" x2="230" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="670" y1="184" x2="670" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
|
||||
<!-- public_a -->
|
||||
<g transform="translate(108, 202)">
|
||||
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_a</text>
|
||||
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-a</text>
|
||||
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<rect x="20" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="120" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
|
||||
|
||||
<rect x="20" y="70" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="120" y="85" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">NAT GW</text>
|
||||
</g>
|
||||
|
||||
<!-- public_b -->
|
||||
<g transform="translate(548, 202)">
|
||||
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_b</text>
|
||||
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-b</text>
|
||||
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<rect x="20" y="56" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="120" y="71" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
|
||||
</g>
|
||||
|
||||
<!-- SNAT arrow from public_a NAT → private row -->
|
||||
<line x1="230" y1="306" x2="230" y2="340" stroke="#F87171" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.65"/>
|
||||
<polygon points="226,336 234,336 230,344" fill="#F87171" opacity="0.8"/>
|
||||
<rect x="256" y="314" width="240" height="20" rx="10" fill="#0a0a0a" stroke="#F87171" stroke-width="0.6" stroke-opacity="0.55"/>
|
||||
<text x="376" y="328" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">SNAT for private subnets (egress)</text>
|
||||
|
||||
<!-- private_a -->
|
||||
<g transform="translate(108, 350)">
|
||||
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_a</text>
|
||||
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-a</text>
|
||||
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_1</text>
|
||||
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
|
||||
</g>
|
||||
|
||||
<!-- private_b -->
|
||||
<g transform="translate(548, 350)">
|
||||
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_b</text>
|
||||
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-b</text>
|
||||
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_2</text>
|
||||
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">S3 WEBAPP · Flask on EC2 with Predastore backend</text>
|
||||
|
||||
<!-- Browser -->
|
||||
<g transform="translate(40, 56)">
|
||||
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Browser</text>
|
||||
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">end user</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow: HTTP -->
|
||||
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
|
||||
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
|
||||
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
|
||||
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">HTTP</text>
|
||||
|
||||
<!-- EC2 Instance -->
|
||||
<g transform="translate(338, 56)">
|
||||
<rect width="240" height="60" rx="8" fill="#DC26261a" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.65"/>
|
||||
<text x="120" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">EC2 Instance</text>
|
||||
<text x="120" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">Flask webapp · port 80</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow: S3 API (boto3) -->
|
||||
<line x1="578" y1="86" x2="658" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
|
||||
<polygon points="654,82 666,86 654,90" fill="#a855f7" opacity="0.8"/>
|
||||
<rect x="570" y="62" width="144" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
|
||||
<text x="642" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">S3 API (boto3)</text>
|
||||
|
||||
<!-- Predastore -->
|
||||
<g transform="translate(676, 56)">
|
||||
<rect width="200" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
|
||||
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">Predastore</text>
|
||||
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">S3 store · port 8443</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,78 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="330" viewBox="0 0 900 330">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cluster-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pool-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#DC262622"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D08"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="node-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#58a6ff22"/>
|
||||
<stop offset="100%" style="stop-color:#58a6ff08"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="330" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">CONFIGURATION LEVELS · spinifex.toml</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">three scopes: cluster · pool · node</text>
|
||||
|
||||
<!-- File badge -->
|
||||
<rect x="24" y="50" width="140" height="24" rx="6" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
|
||||
<text x="94" y="67" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#e4e4e7" text-anchor="middle">spinifex.toml</text>
|
||||
|
||||
<!-- spine -->
|
||||
<line x1="52" y1="74" x2="52" y2="298" stroke="#52525b" stroke-width="1" opacity="0.6"/>
|
||||
|
||||
<!-- ═══ Level 1: [network] cluster-wide ═══════════════════ -->
|
||||
<line x1="52" y1="108" x2="92" y2="108" stroke="#52525b" stroke-width="1" opacity="0.6"/>
|
||||
<rect x="92" y="86" width="764" height="56" rx="8" fill="url(#cluster-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="108" y="108" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">[network]</text>
|
||||
<text x="220" y="108" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Cluster-wide · which mode?</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<text x="108" y="128" fill="#a1a1aa">└─</text>
|
||||
<text x="128" y="128" fill="#7ee787">external_mode</text>
|
||||
<text x="224" y="128" fill="#a1a1aa">=</text>
|
||||
<text x="238" y="128" fill="#f5f5f5">"pool"</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ Level 2: [[network.external_pools]] ═══════════════ -->
|
||||
<line x1="52" y1="176" x2="92" y2="176" stroke="#52525b" stroke-width="1" opacity="0.6"/>
|
||||
<rect x="92" y="154" width="764" height="78" rx="8" fill="url(#pool-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
|
||||
<text x="108" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">[[network.external_pools]]</text>
|
||||
<text x="328" y="176" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">IP ranges · what IPs to use?</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<text x="108" y="196" fill="#a1a1aa">├─</text>
|
||||
<text x="128" y="196" fill="#FCA5A5">name</text>
|
||||
<text x="158" y="196" fill="#71717a">·</text>
|
||||
<text x="166" y="196" fill="#FCA5A5">range_start</text>
|
||||
<text x="246" y="196" fill="#71717a">·</text>
|
||||
<text x="254" y="196" fill="#FCA5A5">range_end</text>
|
||||
|
||||
<text x="108" y="212" fill="#a1a1aa">├─</text>
|
||||
<text x="128" y="212" fill="#FCA5A5">gateway</text>
|
||||
<text x="182" y="212" fill="#71717a">·</text>
|
||||
<text x="190" y="212" fill="#FCA5A5">prefix_len</text>
|
||||
|
||||
<text x="108" y="228" fill="#a1a1aa">└─</text>
|
||||
<text x="128" y="228" fill="#FCA5A5">region</text>
|
||||
<text x="174" y="228" fill="#71717a">·</text>
|
||||
<text x="182" y="228" fill="#FCA5A5">az</text>
|
||||
<text x="202" y="228" fill="#71717a">(optional)</text>
|
||||
</g>
|
||||
|
||||
<!-- ═══ Level 3: [nodes.NAME.vpcd] ════════════════════════ -->
|
||||
<line x1="52" y1="268" x2="92" y2="268" stroke="#52525b" stroke-width="1" opacity="0.6"/>
|
||||
<rect x="92" y="246" width="764" height="56" rx="8" fill="url(#node-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="108" y="268" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700">[nodes.NAME.vpcd]</text>
|
||||
<text x="266" y="268" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Per-node · which NIC?</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
|
||||
<text x="108" y="288" fill="#a1a1aa">└─</text>
|
||||
<text x="128" y="288" fill="#8ab4f8">external_interface</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,63 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="22" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DATA PATH · VM to WAN via OVN localnet</text>
|
||||
|
||||
<!-- VM TAP -->
|
||||
<g transform="translate(24, 40)">
|
||||
<rect width="118" height="48" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="59" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">VM TAP</text>
|
||||
<text x="59" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">guest interface</text>
|
||||
</g>
|
||||
<polygon points="148,60 160,64 148,68" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="142" y1="64" x2="148" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- br-int -->
|
||||
<g transform="translate(166, 40)">
|
||||
<rect width="102" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="51" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-int</text>
|
||||
<text x="51" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
|
||||
</g>
|
||||
<polygon points="274,60 286,64 274,68" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="268" y1="64" x2="274" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- OVN logical pipeline -->
|
||||
<g transform="translate(292, 40)">
|
||||
<rect width="192" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
|
||||
<text x="96" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN logical pipeline</text>
|
||||
<text x="96" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">switch → router → SNAT/DNAT</text>
|
||||
</g>
|
||||
<polygon points="490,60 502,64 490,68" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="484" y1="64" x2="490" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- localnet -->
|
||||
<g transform="translate(508, 40)">
|
||||
<rect width="120" height="48" rx="7" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<text x="60" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">localnet</text>
|
||||
<text x="60" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">OVN port type</text>
|
||||
</g>
|
||||
<polygon points="634,60 646,64 634,68" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="628" y1="64" x2="634" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- br-wan -->
|
||||
<g transform="translate(652, 40)">
|
||||
<rect width="110" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="55" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
|
||||
<text x="55" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
|
||||
</g>
|
||||
<polygon points="768,60 780,64 768,68" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="762" y1="64" x2="768" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- WAN -->
|
||||
<g transform="translate(786, 40)">
|
||||
<rect width="90" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="45" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
|
||||
<text x="45" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">uplink</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="360" viewBox="0 0 900 360">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="host-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#58a6ff22"/>
|
||||
<stop offset="100%" style="stop-color:#58a6ff08"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="vm-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="360" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">TWO DHCP CONVERSATIONS · completely independent</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">pool mode · static or dhcp source</text>
|
||||
|
||||
<!-- ═══ HOST ↔ ROUTER DHCP ══════════════════════════════ -->
|
||||
<rect x="24" y="54" width="852" height="138" rx="10" fill="url(#host-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="44" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700" letter-spacing="0.8">HOST ↔ ROUTER DHCP</text>
|
||||
<text x="876" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="end">on br-ext · veth pair → br-wan</text>
|
||||
<line x1="24" y1="88" x2="876" y2="88" stroke="#58a6ff" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<!-- step 1 -->
|
||||
<circle cx="54" cy="108" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
|
||||
<text x="54" y="112" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">1</text>
|
||||
<text x="74" y="112" fill="#f5f5f5">Spinifex asks router:</text>
|
||||
<text x="228" y="112" fill="#a1a1aa">"give me a public IP for this VM"</text>
|
||||
|
||||
<!-- step 2 -->
|
||||
<circle cx="54" cy="132" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
|
||||
<text x="54" y="136" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">2</text>
|
||||
<text x="74" y="136" fill="#f5f5f5">Router responds:</text>
|
||||
<text x="196" y="136" fill="#8ab4f8" font-weight="600">192.168.1.75</text>
|
||||
|
||||
<!-- step 3 -->
|
||||
<circle cx="54" cy="156" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
|
||||
<text x="54" y="160" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">3</text>
|
||||
<text x="74" y="160" fill="#f5f5f5">Spinifex stores IP, tells OVN:</text>
|
||||
<text x="274" y="160" fill="#F87171" font-weight="600">dnat_and_snat(192.168.1.75 ↔ 172.31.0.4)</text>
|
||||
</g>
|
||||
|
||||
<text x="44" y="181" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">Only used in DHCP source mode. Static mode picks from range.</text>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="24" y1="206" x2="876" y2="206" stroke="#27272a" stroke-width="0.8"/>
|
||||
<text x="450" y="220" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#52525b" text-anchor="middle" letter-spacing="2">INDEPENDENT · no packets cross between</text>
|
||||
|
||||
<!-- ═══ VM ↔ OVN DHCP ═══════════════════════════════════ -->
|
||||
<rect x="24" y="234" width="852" height="110" rx="10" fill="url(#vm-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="44" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="0.8">VM ↔ OVN DHCP</text>
|
||||
<text x="876" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end">on tap → br-int → OVN logical switch</text>
|
||||
<line x1="24" y1="268" x2="876" y2="268" stroke="#3fb950" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<circle cx="54" cy="288" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
|
||||
<text x="54" y="292" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">1</text>
|
||||
<text x="74" y="292" fill="#f5f5f5">cloud-init asks:</text>
|
||||
<text x="190" y="292" fill="#a1a1aa">"what's my IP?"</text>
|
||||
|
||||
<circle cx="54" cy="312" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
|
||||
<text x="54" y="316" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">2</text>
|
||||
<text x="74" y="316" fill="#f5f5f5">OVN DHCP responds:</text>
|
||||
<text x="226" y="316" fill="#a1a1aa">"you are</text>
|
||||
<text x="284" y="316" fill="#7ee787" font-weight="600">172.31.0.4</text>
|
||||
<text x="356" y="316" fill="#a1a1aa">"</text>
|
||||
</g>
|
||||
|
||||
<text x="44" y="336" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">VM never sees 192.168.1.x or the router.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DIRECT BRIDGE MODE · dedicated WAN NIC</text>
|
||||
|
||||
<!-- Row 1: Management NIC (independent, no WAN bridge path) -->
|
||||
<g transform="translate(24, 42)">
|
||||
<rect width="852" height="44" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
|
||||
<text x="20" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">Management NIC</text>
|
||||
<text x="20" y="36" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">host IP · SSH · spinifex services · overlay (Geneve)</text>
|
||||
<text x="840" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end" font-weight="600">independent path</text>
|
||||
</g>
|
||||
|
||||
<!-- Row 2: WAN NIC → br-wan → localnet -->
|
||||
<g transform="translate(24, 100)">
|
||||
<rect width="160" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="80" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
|
||||
<text x="80" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">dedicated physical</text>
|
||||
</g>
|
||||
<polygon points="198,118 210,122 198,126" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="186" y1="122" x2="198" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<g transform="translate(214, 100)">
|
||||
<rect width="220" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="110" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
|
||||
<text x="110" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS port (NIC added directly)</text>
|
||||
</g>
|
||||
<polygon points="448,118 460,122 448,126" fill="#71717a" opacity="0.7"/>
|
||||
<line x1="434" y1="122" x2="448" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<g transform="translate(464, 100)">
|
||||
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
|
||||
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">OVN localnet</text>
|
||||
<text x="90" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">external switch port</text>
|
||||
</g>
|
||||
|
||||
<text x="670" y="130" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" font-weight="600">distributed NAT · any MAC · all frames</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,91 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="440" viewBox="0 0 900 440">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="host-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#27272a88"/>
|
||||
<stop offset="100%" style="stop-color:#27272a22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="br-int-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f71f"/>
|
||||
<stop offset="100%" style="stop-color:#a855f708"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="br-wan-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#58a6ff1f"/>
|
||||
<stop offset="100%" style="stop-color:#58a6ff08"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="440" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK FLOW · bare-metal host</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">br-int overlay · br-wan WAN uplink</text>
|
||||
|
||||
<!-- ═══ Bare-Metal Host container ═══════════════════════ -->
|
||||
<rect x="40" y="60" width="820" height="284" rx="12" fill="url(#host-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.7"/>
|
||||
<text x="58" y="84" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" font-weight="700" letter-spacing="1">BARE-METAL HOST</text>
|
||||
<line x1="40" y1="96" x2="860" y2="96" stroke="#52525b" stroke-opacity="0.5" stroke-width="0.5"/>
|
||||
|
||||
<!-- br-int bridge (left) -->
|
||||
<g transform="translate(80, 122)">
|
||||
<rect width="260" height="176" rx="10" fill="url(#br-int-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" font-weight="700">br-int</text>
|
||||
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="end">(overlay)</text>
|
||||
<line x1="20" y1="34" x2="240" y2="34" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<rect x="30" y="52" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<text x="130" y="70" fill="#d8b4fe" text-anchor="middle">tap-eni1</text>
|
||||
|
||||
<rect x="30" y="88" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<text x="130" y="106" fill="#d8b4fe" text-anchor="middle">tap-eni2</text>
|
||||
|
||||
<rect x="30" y="124" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
|
||||
<text x="130" y="142" fill="#d8b4fe" text-anchor="middle">tap-eni3</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- OVN NAT pipeline arrow in middle -->
|
||||
<g transform="translate(352, 200)">
|
||||
<line x1="0" y1="10" x2="148" y2="10" stroke="#DC2626" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
|
||||
<polygon points="148,5 160,10 148,15" fill="#DC2626" opacity="0.8"/>
|
||||
<text x="78" y="0" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN</text>
|
||||
<text x="78" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">NAT pipeline</text>
|
||||
</g>
|
||||
|
||||
<!-- br-wan bridge (right) -->
|
||||
<g transform="translate(520, 122)">
|
||||
<rect width="260" height="176" rx="10" fill="url(#br-wan-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" font-weight="700">br-wan</text>
|
||||
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#8ab4f8" text-anchor="end">(WAN)</text>
|
||||
<line x1="20" y1="34" x2="240" y2="34" stroke="#58a6ff" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<rect x="30" y="52" width="200" height="60" rx="5" fill="#58a6ff1a" stroke="#58a6ff" stroke-opacity="0.45" stroke-width="0.8"/>
|
||||
<text x="130" y="74" fill="#8ab4f8" text-anchor="middle" font-weight="600">spx-ext-{nic}</text>
|
||||
<text x="130" y="92" fill="#a1a1aa" text-anchor="middle" font-size="10">(macvlan)</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- macvlan bridge mode label -->
|
||||
<text x="650" y="314" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">macvlan bridge mode</text>
|
||||
|
||||
<!-- WAN NIC inside host, bottom -->
|
||||
<line x1="650" y1="298" x2="650" y2="320" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<g transform="translate(500, 320)">
|
||||
<rect width="300" height="20" rx="5" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
|
||||
<text x="150" y="14" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">WAN NIC · host IP unchanged</text>
|
||||
</g>
|
||||
|
||||
<!-- connector out -->
|
||||
<line x1="650" y1="344" x2="650" y2="380" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<polygon points="646,376 654,376 650,384" fill="#71717a" opacity="0.7"/>
|
||||
|
||||
<!-- Physical WAN -->
|
||||
<g transform="translate(500, 388)">
|
||||
<rect width="300" height="40" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
|
||||
<text x="150" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">Physical WAN</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="320" viewBox="0 0 900 320">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="320" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">MACVLAN FALLBACK · single-NIC host</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">WAN NIC == SSH NIC · host IP preserved</text>
|
||||
|
||||
<!-- WAN NIC (parent) -->
|
||||
<g transform="translate(280, 58)">
|
||||
<rect width="340" height="54" rx="8" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.3" stroke-opacity="0.65"/>
|
||||
<text x="170" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
|
||||
<text x="170" y="42" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">host IP unchanged · parent interface</text>
|
||||
</g>
|
||||
|
||||
<!-- Split point -->
|
||||
<line x1="450" y1="112" x2="450" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="260" y1="132" x2="640" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="260" y1="132" x2="260" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<line x1="640" y1="132" x2="640" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- Child 1: host traffic -->
|
||||
<g transform="translate(120, 148)">
|
||||
<rect width="280" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">host traffic</text>
|
||||
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SSH · management · overlay</text>
|
||||
</g>
|
||||
|
||||
<!-- Child 2: macvlan -->
|
||||
<g transform="translate(500, 148)">
|
||||
<rect width="280" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
|
||||
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">spx-ext-{nic}</text>
|
||||
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">macvlan · bridge mode · no IP</text>
|
||||
</g>
|
||||
|
||||
<!-- br-wan -->
|
||||
<line x1="640" y1="196" x2="640" y2="216" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<polygon points="636,212 644,212 640,220" fill="#71717a" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(500, 220)">
|
||||
<rect width="280" height="40" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="140" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
|
||||
</g>
|
||||
|
||||
<!-- localnet -->
|
||||
<line x1="640" y1="260" x2="640" y2="276" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<polygon points="636,272 644,272 640,280" fill="#71717a" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(500, 280)">
|
||||
<rect width="280" height="30" rx="6" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
|
||||
<text x="140" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="600">localnet → OVN ext switch</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,131 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wan-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ovs-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#58a6ff22"/>
|
||||
<stop offset="100%" style="stop-color:#58a6ff0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="router-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#DC262622"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3fb95022"/>
|
||||
<stop offset="100%" style="stop-color:#3fb95008"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f722"/>
|
||||
<stop offset="100%" style="stop-color:#a855f708"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="560" fill="url(#bg)" rx="12"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK PATH</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">OVN logical topology · bridge mapping · NAT</text>
|
||||
|
||||
<!-- WAN -->
|
||||
<rect x="300" y="54" width="300" height="40" rx="8" fill="url(#wan-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.55"/>
|
||||
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Physical Network (WAN)</text>
|
||||
|
||||
<!-- connector -->
|
||||
<line x1="450" y1="94" x2="450" y2="112" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
|
||||
<!-- br-wan bridge -->
|
||||
<rect x="340" y="112" width="220" height="52" rx="8" fill="url(#ovs-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.65"/>
|
||||
<text x="450" y="134" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
|
||||
<text x="450" y="152" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">OVS bridge · physical uplink</text>
|
||||
|
||||
<!-- connector with bridge-mappings label -->
|
||||
<line x1="450" y1="164" x2="450" y2="186" stroke="#71717a" stroke-width="1" opacity="0.5"/>
|
||||
<rect x="316" y="170" width="268" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.5"/>
|
||||
<text x="450" y="183" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">ovn-bridge-mappings: external:br-wan</text>
|
||||
|
||||
<!-- VPC Logical Router (the big box) -->
|
||||
<rect x="60" y="208" width="780" height="196" rx="10" fill="url(#router-grad)" stroke="#DC2626" stroke-width="1.4" stroke-opacity="0.75"/>
|
||||
<text x="78" y="232" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" font-weight="700" letter-spacing="1">VPC LOGICAL ROUTER</text>
|
||||
<line x1="60" y1="242" x2="840" y2="242" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
|
||||
|
||||
<!-- Router ports row -->
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
|
||||
<rect x="82" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
|
||||
<text x="142" y="270" fill="#F87171" text-anchor="middle" font-weight="600">Gateway port</text>
|
||||
<text x="142" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">public IP</text>
|
||||
|
||||
<text x="215" y="282" fill="#71717a">─────</text>
|
||||
|
||||
<rect x="260" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
|
||||
<text x="320" y="270" fill="#F87171" text-anchor="middle" font-weight="600">ext switch</text>
|
||||
<text x="320" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">OVN logical</text>
|
||||
|
||||
<text x="393" y="282" fill="#71717a">─────</text>
|
||||
|
||||
<rect x="438" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
|
||||
<text x="498" y="270" fill="#F87171" text-anchor="middle" font-weight="600">localnet port</text>
|
||||
<text x="498" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">→ br-wan</text>
|
||||
</g>
|
||||
|
||||
<!-- NAT rules + Routes, two columns on right -->
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
|
||||
<text x="598" y="260" fill="#F87171" font-weight="600">NAT rules</text>
|
||||
<text x="598" y="276" fill="#f5f5f5">SNAT:</text>
|
||||
<text x="644" y="276" fill="#a1a1aa">VPC CIDR → gw IP</text>
|
||||
<text x="598" y="292" fill="#f5f5f5">dnat_and_snat:</text>
|
||||
<text x="704" y="292" fill="#a1a1aa">pub ↔ priv</text>
|
||||
</g>
|
||||
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
|
||||
<text x="82" y="330" fill="#F87171" font-weight="600">Routes</text>
|
||||
<text x="82" y="346" fill="#f5f5f5">0.0.0.0/0</text>
|
||||
<text x="154" y="346" fill="#a1a1aa">→ WAN gateway</text>
|
||||
</g>
|
||||
|
||||
<!-- Subnet branches inside router -->
|
||||
<line x1="150" y1="362" x2="150" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="150" y1="386" x2="305" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="305" y1="386" x2="305" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
|
||||
<line x1="150" y1="386" x2="515" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
<line x1="515" y1="386" x2="515" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
|
||||
|
||||
<!-- Subnet A (public) -->
|
||||
<g transform="translate(215, 404)">
|
||||
<rect width="180" height="124" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">subnet-a</text>
|
||||
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">(public)</text>
|
||||
<line x1="16" y1="46" x2="164" y2="46" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.1.0/24</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
|
||||
<circle cx="40" cy="88" r="2.5" fill="#3fb950"/>
|
||||
<text x="52" y="92">eni-1</text>
|
||||
<circle cx="40" cy="108" r="2.5" fill="#3fb950"/>
|
||||
<text x="52" y="112">eni-2</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Subnet B (private) -->
|
||||
<g transform="translate(425, 404)">
|
||||
<rect width="180" height="124" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">subnet-b</text>
|
||||
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">(private)</text>
|
||||
<line x1="16" y1="46" x2="164" y2="46" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.2.0/24</text>
|
||||
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
|
||||
<circle cx="40" cy="88" r="2.5" fill="#a855f7"/>
|
||||
<text x="52" y="92">eni-3</text>
|
||||
<circle cx="40" cy="108" r="2.5" fill="#a855f7"/>
|
||||
<text x="52" y="112">eni-4</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Footnote -->
|
||||
<text x="24" y="548" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a">Cross-host traffic uses Geneve tunnels (UDP 6081) over the overlay NIC · ovn-controller programs OpenFlow rules on br-int</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PRIVATE SUBNET · no default route</text>
|
||||
|
||||
<!-- Instance -->
|
||||
<g transform="translate(40, 42)">
|
||||
<rect width="180" height="44" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">Instance</text>
|
||||
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#d8b4fe" text-anchor="middle">10.0.2.5</text>
|
||||
</g>
|
||||
|
||||
<!-- arrow -->
|
||||
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
|
||||
<line x1="220" y1="64" x2="260" y2="64" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="260,60 268,64 260,68" fill="#DC2626" opacity="0.6"/>
|
||||
|
||||
<!-- Router -->
|
||||
<g transform="translate(275, 42)">
|
||||
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
|
||||
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">VPC Router</text>
|
||||
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">lookup 0.0.0.0/0</text>
|
||||
</g>
|
||||
|
||||
<!-- arrow -->
|
||||
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
|
||||
<line x1="455" y1="64" x2="495" y2="64" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="495,60 503,64 495,68" fill="#DC2626" opacity="0.6"/>
|
||||
|
||||
<!-- [no default route] -->
|
||||
<g transform="translate(510, 42)">
|
||||
<rect width="196" height="44" rx="7" fill="#0a0a0a" stroke="#71717a" stroke-width="1" stroke-opacity="0.7" stroke-dasharray="4,3"/>
|
||||
<text x="98" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">[no default route]</text>
|
||||
<text x="98" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle">no IGW attached</text>
|
||||
</g>
|
||||
|
||||
<!-- arrow to dropped -->
|
||||
<g stroke="#71717a" stroke-width="1.2" opacity="0.6" fill="none">
|
||||
<line x1="706" y1="64" x2="746" y2="64" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="746,60 754,64 746,68" fill="#71717a" opacity="0.6"/>
|
||||
|
||||
<!-- dropped -->
|
||||
<g transform="translate(760, 42)">
|
||||
<rect width="116" height="44" rx="7" fill="#27272a" stroke="#52525b" stroke-width="1" stroke-opacity="0.7"/>
|
||||
<text x="58" y="27" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">dropped</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
|
||||
|
||||
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PUBLIC SUBNET · 1:1 NAT managed by OVN</text>
|
||||
|
||||
<!-- OUTBOUND row -->
|
||||
<text x="24" y="52" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700">Outbound</text>
|
||||
|
||||
<g transform="translate(112, 38)">
|
||||
<rect width="180" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">Instance</text>
|
||||
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">10.0.1.5</text>
|
||||
</g>
|
||||
|
||||
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
|
||||
<line x1="292" y1="58" x2="352" y2="58" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="352,54 360,58 352,62" fill="#3fb950" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(368, 38)">
|
||||
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
|
||||
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">SNAT</text>
|
||||
</g>
|
||||
|
||||
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
|
||||
<line x1="498" y1="58" x2="558" y2="58" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="558,54 566,58 558,62" fill="#3fb950" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(574, 38)">
|
||||
<rect width="272" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">exits as 203.0.113.10</text>
|
||||
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">source IP rewritten</text>
|
||||
</g>
|
||||
|
||||
<!-- INBOUND row -->
|
||||
<text x="24" y="110" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" font-weight="700">Inbound</text>
|
||||
|
||||
<g transform="translate(112, 96)">
|
||||
<rect width="180" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">WAN client</text>
|
||||
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">→ 203.0.113.10</text>
|
||||
</g>
|
||||
|
||||
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
|
||||
<line x1="292" y1="116" x2="352" y2="116" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="352,112 360,116 352,120" fill="#58a6ff" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(368, 96)">
|
||||
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
|
||||
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">DNAT</text>
|
||||
</g>
|
||||
|
||||
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
|
||||
<line x1="498" y1="116" x2="558" y2="116" stroke-dasharray="3,3"/>
|
||||
</g>
|
||||
<polygon points="558,112 566,116 558,120" fill="#58a6ff" opacity="0.7"/>
|
||||
|
||||
<g transform="translate(574, 96)">
|
||||
<rect width="272" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">arrives at 10.0.1.5</text>
|
||||
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">dest IP rewritten</text>
|
||||
</g>
|
||||
|
||||
<!-- Footnote -->
|
||||
<text x="24" y="158" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a">The instance OS only sees its private IP (10.0.1.5) — the public address is managed entirely by OVN's dnat_and_snat rule.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 544 KiB |
@@ -0,0 +1,144 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
|
||||
<defs>
|
||||
<linearGradient id="pf-bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a"/>
|
||||
<stop offset="100%" style="stop-color:#0d1117"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hw-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#52525b;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#52525b;stop-opacity:0.06"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="os-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="spx-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.28"/>
|
||||
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="app-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3fb950;stop-opacity:0.18"/>
|
||||
<stop offset="100%" style="stop-color:#3fb950;stop-opacity:0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="560" fill="url(#pf-bg)" rx="12"/>
|
||||
|
||||
<!-- Subtle mesh dots background -->
|
||||
<g opacity="0.12" fill="#7F1D1D">
|
||||
<circle cx="40" cy="40" r="1"/><circle cx="120" cy="30" r="1"/><circle cx="220" cy="50" r="1"/>
|
||||
<circle cx="320" cy="25" r="1"/><circle cx="440" cy="45" r="1"/><circle cx="560" cy="30" r="1"/>
|
||||
<circle cx="680" cy="50" r="1"/><circle cx="780" cy="30" r="1"/><circle cx="860" cy="45" r="1"/>
|
||||
<circle cx="60" cy="530" r="1"/><circle cx="160" cy="520" r="1"/><circle cx="260" cy="540" r="1"/>
|
||||
<circle cx="380" cy="525" r="1"/><circle cx="500" cy="540" r="1"/><circle cx="620" cy="520" r="1"/>
|
||||
<circle cx="740" cy="540" r="1"/><circle cx="840" cy="525" r="1"/>
|
||||
</g>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="450" y="32" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" text-anchor="middle" letter-spacing="2.8">THE MULGA PLATFORM</text>
|
||||
<text x="450" y="58" font-family="'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="600" fill="#f5f5f5" text-anchor="middle">AWS-style workflows, on your own stack</text>
|
||||
|
||||
<!-- Layer 4: Applications (top) -->
|
||||
<g transform="translate(60, 95)">
|
||||
<rect width="780" height="88" rx="10" fill="url(#app-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" letter-spacing="1.5" font-weight="600">LAYER 4 · APPLICATIONS & TOOLING</text>
|
||||
<text x="20" y="48" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">Your workloads — unmodified AWS tooling</text>
|
||||
|
||||
<g transform="translate(20, 60)" font-family="'Segoe UI', sans-serif" font-size="10.5">
|
||||
<rect x="0" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="35" y="14" fill="#7ee787" text-anchor="middle">aws-cli</text>
|
||||
|
||||
<rect x="78" y="0" width="78" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="117" y="14" fill="#7ee787" text-anchor="middle">Terraform</text>
|
||||
|
||||
<rect x="164" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="208" y="14" fill="#7ee787" text-anchor="middle">AWS SDKs</text>
|
||||
|
||||
<rect x="260" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="304" y="14" fill="#7ee787" text-anchor="middle">Kubernetes</text>
|
||||
|
||||
<rect x="356" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="391" y="14" fill="#7ee787" text-anchor="middle">Packer</text>
|
||||
|
||||
<rect x="434" y="0" width="98" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
|
||||
<text x="483" y="14" fill="#7ee787" text-anchor="middle">Your Services</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 188)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 3: Spinifex (highlighted) -->
|
||||
<g transform="translate(60, 215)">
|
||||
<rect width="780" height="140" rx="10" fill="url(#spx-grad)" stroke="#DC2626" stroke-width="1.5" stroke-opacity="0.75"/>
|
||||
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" letter-spacing="1.5" font-weight="600">LAYER 3 · SPINIFEX · AWS ORCHESTRATION</text>
|
||||
<text x="20" y="50" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">AWS Gateway → NATS → Service Daemons · SigV4, IAM, VPC-aware</text>
|
||||
|
||||
<g transform="translate(20, 66)" font-family="'Segoe UI', sans-serif">
|
||||
<!-- EC2 -->
|
||||
<rect x="0" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="59" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EC2</text>
|
||||
<text x="59" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">QEMU/KVM VMs</text>
|
||||
<text x="59" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">cloud-init · AMIs</text>
|
||||
|
||||
<!-- EBS -->
|
||||
<rect x="126" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="185" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EBS</text>
|
||||
<text x="185" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Viperblock</text>
|
||||
<text x="185" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">WAL · NBD · snapshots</text>
|
||||
|
||||
<!-- S3 -->
|
||||
<rect x="252" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="311" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">S3</text>
|
||||
<text x="311" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Predastore</text>
|
||||
<text x="311" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Reed–Solomon EC</text>
|
||||
|
||||
<!-- VPC -->
|
||||
<rect x="378" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
|
||||
<text x="437" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">VPC</text>
|
||||
<text x="437" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">OVN / OVS</text>
|
||||
<text x="437" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">subnets · SG · ALB</text>
|
||||
|
||||
<!-- IAM -->
|
||||
<rect x="504" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
|
||||
<text x="563" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">IAM</text>
|
||||
<text x="563" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">SigV4 · policies</text>
|
||||
<text x="563" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">users · roles · keys</text>
|
||||
|
||||
<!-- Control -->
|
||||
<rect x="630" y="0" width="110" height="58" rx="6" fill="#27272a" fill-opacity="0.8" stroke="#71717a" stroke-opacity="0.6" stroke-width="1"/>
|
||||
<text x="685" y="18" font-size="13" fill="#e4e4e7" text-anchor="middle" font-weight="600">Control</text>
|
||||
<text x="685" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">NATS bus</text>
|
||||
<text x="685" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">spx · daemons</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 360)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 2: OS -->
|
||||
<g transform="translate(60, 387)">
|
||||
<rect width="780" height="62" rx="10" fill="url(#os-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.5"/>
|
||||
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" letter-spacing="1.5" font-weight="600">LAYER 2 · OPERATING SYSTEM</text>
|
||||
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · headless autoinstall</text>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow -->
|
||||
<g transform="translate(450, 454)" opacity="0.45">
|
||||
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
|
||||
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer 1: Hardware -->
|
||||
<g transform="translate(60, 481)">
|
||||
<rect width="780" height="62" rx="10" fill="url(#hw-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" letter-spacing="1.5" font-weight="600">LAYER 1 · HARDWARE</text>
|
||||
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Single node · multi-node cluster · edge appliance · air-gapped rack · commodity x86 / ARM</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,52 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
target-branch: dev
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
groups:
|
||||
go-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: dev
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-patch"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: dev
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
groups:
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,125 @@
|
||||
name: E2E DDIL
|
||||
|
||||
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
|
||||
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
|
||||
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quarantined:
|
||||
description: "Comma-separated scenario letters to quarantine (A..F)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
ddil:
|
||||
name: DDIL Real Multi-Node
|
||||
runs-on: [ self-hosted, ci-multi ]
|
||||
# Budget: ~45m go test + tofu provision/bootstrap/teardown on ci-multi.
|
||||
# In Phase 1 the scenarios all t.Skip, so the real-world runtime is
|
||||
# dominated by provisioning; the 60m ceiling leaves headroom for
|
||||
# Phase 2/3 when assertions come online.
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
# Full spinifex checkout: the Go harness must run on the runner
|
||||
# (outside the cluster) so its SSH path survives scenario partitions
|
||||
# that drop peer-to-peer cluster traffic. Running inside the cluster
|
||||
# would self-sever when Scenario C isolates a node.
|
||||
- uses: actions/checkout@v6
|
||||
name: Checkout Spinifex
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
name: Checkout Mulga (tofu-cluster)
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run DDIL Scenarios
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -o pipefail
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
|
||||
REGION=$(echo "$INVENTORY" | jq -r '.region')
|
||||
# DDIL_NODES must be the cluster-traffic IPs: PartitionNode
|
||||
# installs iptables DROP rules for each peer, so the runner's
|
||||
# SSH source IP must differ from these. On single-NIC
|
||||
# tofu-cluster, wan_ip == cluster IP and the runner's outbound
|
||||
# IP is distinct — see e2e-ddil-test-harness.md §3 Scenario C.
|
||||
NODE_IPS=$(echo "$INVENTORY" | jq -r '[.nodes[].wan_ip] | join(",")')
|
||||
echo "DDIL_NODES=${NODE_IPS}"
|
||||
|
||||
mkdir -p "${RUNNER_TEMP}/ddil-results"
|
||||
cd "${GITHUB_WORKSPACE}/spinifex"
|
||||
DDIL_NODES="${NODE_IPS}" \
|
||||
DDIL_SSH_USER="${SSH_USER}" \
|
||||
DDIL_SSH_KEY="${SSH_KEY}" \
|
||||
DDIL_QUARANTINED="${{ inputs.quarantined }}" \
|
||||
AWS_REGION="${REGION}" \
|
||||
go test -tags=e2e -timeout 45m -json \
|
||||
./tests/e2e/ddil/scenarios/... \
|
||||
| tee "${RUNNER_TEMP}/ddil-results/results.json"
|
||||
|
||||
- name: Upload DDIL Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ddil-results-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/ddil-results
|
||||
retention-days: 30
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
@@ -0,0 +1,216 @@
|
||||
name: E2E Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run (blank = all)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-24.04.qcow2
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
|
||||
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
|
||||
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
|
||||
|
||||
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
|
||||
|
||||
steps:
|
||||
# blank or scheduled run = full matrix.
|
||||
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
|
||||
- name: Gate on Dispatch Input
|
||||
id: gate
|
||||
env:
|
||||
CELL: ${{ matrix.cell }}
|
||||
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
|
||||
run: |
|
||||
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
|
||||
echo "selected=true" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} selected"
|
||||
else
|
||||
echo "selected=false" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
|
||||
fi
|
||||
|
||||
- name: Clean Workspace
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Build Install Artifacts
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_NET: ${{ matrix.net }}
|
||||
run: |
|
||||
if [ "$MATRIX_NET" = "dhcp" ]; then
|
||||
unset SPX_EXTERNAL_POOL
|
||||
export SPX_EXTERNAL_SOURCE=dhcp
|
||||
else
|
||||
export SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}"
|
||||
fi
|
||||
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run Single Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
|
||||
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 -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
|
||||
|
||||
- name: Run Multi Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
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}"
|
||||
|
||||
NODE_IPS=""
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
|
||||
done
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
|
||||
|
||||
- name: Run Tofu Workbooks
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
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 -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
|
||||
|
||||
- name: Run Reboot Resilience E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
# Driver runs from the runner (NOT via ssh-and-run) so it survives the
|
||||
# systemctl-reboot-induced ssh disconnect mid-run.
|
||||
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}"
|
||||
|
||||
REBOOT_DIR="${RUNNER_TEMP}/reboot-e2e"
|
||||
mkdir -p "$REBOOT_DIR"
|
||||
tar xf "${RUNNER_TEMP}/spinifex-tests.tar" -C "$REBOOT_DIR"
|
||||
chmod +x "$REBOOT_DIR/run-reboot-e2e.sh"
|
||||
|
||||
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
|
||||
|
||||
- name: Dump Cluster Journals on Failure
|
||||
if: failure() && steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
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")
|
||||
echo "===== node$((i+1)) ($IP) ====="
|
||||
for svc in spinifex-nats spinifex-predastore spinifex-viperblock spinifex-daemon spinifex-awsgw spinifex-vpcd dhcpcd; do
|
||||
echo "--- $svc ---"
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
|
||||
"sudo journalctl -u $svc --no-pager -n 200 2>/dev/null || echo '(not found)'" 2>&1 || echo "(node unreachable)"
|
||||
done
|
||||
echo "--- DHCP client state ---"
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
|
||||
"systemctl is-enabled dhcpcd.service 2>/dev/null; systemctl is-active dhcpcd.service 2>/dev/null; sudo ss -lupn 'sport = 68' 2>/dev/null; pgrep -laf dhcpcd 2>/dev/null" 2>&1 || echo "(node unreachable)"
|
||||
done
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
run: |
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Spinifex E2E Tests
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,6 +13,13 @@ on:
|
||||
- tests/e2e/**
|
||||
- .github/workflows/e2e.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sha:
|
||||
description: "PR head SHA to report status on"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -20,18 +27,18 @@ env:
|
||||
jobs:
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 25
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
@@ -42,26 +49,32 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Clone and build
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap Cluster
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_PREBUILT=true \
|
||||
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
|
||||
./bootstrap.sh env3 >> "$TOFU_LOG" 2>&1
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run E2E Tests
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -71,30 +84,30 @@ jobs:
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh'
|
||||
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
e2e_pseudo_multi:
|
||||
name: Pseudo Multi-Node E2E
|
||||
runs-on: [self-hosted, ci-pseudo]
|
||||
timeout-minutes: 25
|
||||
runs-on: [ self-hosted, ci-pseudo ]
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
@@ -105,26 +118,27 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Clone and build
|
||||
- name: Clone and Build
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}"
|
||||
"${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap Cluster
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_PREBUILT=true \
|
||||
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
|
||||
./bootstrap.sh env4 >> "$TOFU_LOG" 2>&1
|
||||
./bootstrap.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Run E2E Tests
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -134,30 +148,30 @@ jobs:
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-pseudo-multinode-e2e.sh'
|
||||
"cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL=\"${SPINIFEX_CI_POOL}\" ./tests/e2e/run-pseudo-multinode-e2e.sh"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
e2e_real_multi:
|
||||
name: Real Multi-Node E2E
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 25
|
||||
runs-on: [ self-hosted, ci-multi ]
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
@@ -168,26 +182,32 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Clone and build
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap Cluster
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_PREBUILT=true \
|
||||
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
|
||||
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run E2E Tests
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -204,13 +224,41 @@ jobs:
|
||||
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
|
||||
done
|
||||
|
||||
NODE2_IP=$(echo "$INVENTORY" | jq -r '.nodes[1].wan_ip')
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS}"
|
||||
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always() && inputs.sha
|
||||
needs: [ e2e_single, e2e_pseudo_multi, e2e_real_multi ]
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Report Status to PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [[ "${{ needs.e2e_single.result }}" == "success" && \
|
||||
"${{ needs.e2e_pseudo_multi.result }}" == "success" && \
|
||||
"${{ needs.e2e_real_multi.result }}" == "success" ]]; then
|
||||
STATE="success"
|
||||
DESC="All E2E suites passed"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="E2E failed: single=${{ needs.e2e_single.result }} pseudo=${{ needs.e2e_pseudo_multi.result }} multi=${{ needs.e2e_real_multi.result }}"
|
||||
fi
|
||||
gh api "repos/${{ github.repository }}/statuses/${{ inputs.sha }}" \
|
||||
-f state="$STATE" \
|
||||
-f context=e2e \
|
||||
-f description="$DESC" \
|
||||
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
tags: [ "v*" ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
|
||||
|
||||
- name: Upload amd64 Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: |
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
|
||||
|
||||
- name: Upload arm64 Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: |
|
||||
@@ -82,23 +82,23 @@ jobs:
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
|
||||
|
||||
release:
|
||||
needs: [distro-amd64, distro-arm64]
|
||||
needs: [ distro-amd64, distro-arm64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download amd64 Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: distro-amd64
|
||||
path: artifacts
|
||||
|
||||
- name: Download arm64 Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: distro-arm64
|
||||
path: artifacts
|
||||
|
||||
- name: Create Draft GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
@@ -108,14 +108,14 @@ jobs:
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Upload Tarball for Verify Job
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/
|
||||
|
||||
verify:
|
||||
needs: release
|
||||
runs-on: [self-hosted, ci-single]
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
path: mulga
|
||||
|
||||
- name: Download Release Assets
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-assets
|
||||
path: release-assets
|
||||
@@ -149,40 +149,30 @@ jobs:
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
|
||||
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
|
||||
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
|
||||
|
||||
- name: Run Smoke Test
|
||||
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}"
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
||||
-i "$SSH_KEY" \
|
||||
smoke-test-install.sh \
|
||||
"tf-user@${WAN_IP}:/tmp/smoke-test.sh"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
|
||||
-i "$SSH_KEY" "tf-user@${WAN_IP}" \
|
||||
'bash /tmp/smoke-test.sh'
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
|
||||
|
||||
- name: Promote Release
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false
|
||||
echo "Release ${TAG} promoted to latest"
|
||||
if [[ "$TAG" =~ -(dev|rc|alpha|beta) ]]; then
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease
|
||||
echo "Release ${TAG} promoted as prerelease"
|
||||
else
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false
|
||||
echo "Release ${TAG} promoted to latest"
|
||||
fi
|
||||
|
||||
- name: Mark Release Failed
|
||||
if: failure()
|
||||
@@ -199,6 +189,6 @@ jobs:
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Spinifex Build and Test
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,11 +11,15 @@ on:
|
||||
- go.sum
|
||||
- Makefile
|
||||
- .github/workflows/spinifex.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Spinifex Build and Test
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -56,7 +60,7 @@ jobs:
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
race_detection:
|
||||
name: Spinifex Race Detection
|
||||
name: Race Detection
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -90,7 +94,7 @@ jobs:
|
||||
run: make -C spinifex test-race
|
||||
|
||||
lint_and_security:
|
||||
name: Spinifex Lint and Security
|
||||
name: Lint and Security
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -121,7 +125,7 @@ jobs:
|
||||
run: go work init ./spinifex ./viperblock ./predastore
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.11.3
|
||||
working-directory: spinifex
|
||||
|
||||
@@ -8,6 +8,13 @@ on:
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -25,7 +32,7 @@ jobs:
|
||||
sparse-checkout: spinifex/services/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: spinifex/services/spinifexui/frontend/package.json
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
!example.tfvars
|
||||
scripts/iac/proxmox/clusters/
|
||||
|
||||
# Generated SSH private keys from terraform examples (e.g. local_file.nginx_pem)
|
||||
*.pem
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
go.work.sum
|
||||
@@ -14,6 +17,7 @@ go.work.sum
|
||||
# Build outputs
|
||||
bin/
|
||||
dist/
|
||||
!spinifex/services/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
*.test
|
||||
*.out
|
||||
@@ -27,6 +31,7 @@ Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.venv/
|
||||
|
||||
# Test reports
|
||||
*-report.txt
|
||||
@@ -39,3 +44,4 @@ docs/PLAN.md
|
||||
docs/TODO.md
|
||||
docs/development/
|
||||
docs/internal/
|
||||
scripts/iac/aws/spinifex-test.pem
|
||||
|
||||
@@ -22,6 +22,7 @@ linters:
|
||||
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
|
||||
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- forcetypeassert # flags unchecked type assertions that can panic
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- goprintffuncname # checks that printf-like functions are named with f at the end
|
||||
- gosec # inspects source code for security problems
|
||||
@@ -29,11 +30,14 @@ linters:
|
||||
- ineffassign # detects assignments to existing variables that are not used
|
||||
- makezero # finds slice declarations with non-zero initial length followed by append
|
||||
- mirror # catches wrong mirror patterns in bytes/strings usage
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
|
||||
- nakedret # finds naked returns in functions
|
||||
- nilerr # catches code that returns nil even after checking err != nil
|
||||
- nilnesserr # catches returning a different nil error variable after err != nil check
|
||||
- nolintlint # reports ill-formed or insufficient nolint directives
|
||||
- nosprintfhostport # flags fmt.Sprintf host:port — use net.JoinHostPort for IPv6 safety
|
||||
- perfsprint # detects fmt.Sprintf calls replaceable with faster alternatives
|
||||
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- recvcheck # checks for receiver type consistency (pointer vs value)
|
||||
@@ -91,6 +95,12 @@ linters:
|
||||
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
|
||||
- stringsbuilder # don't replace small string concatenations with strings.Builder
|
||||
|
||||
perfsprint:
|
||||
concat-loop: false
|
||||
err-error: false # don't flag err.Error()
|
||||
errorf: false # don't convert fmt.Errorf to errors.New
|
||||
strconcat: false
|
||||
|
||||
nakedret:
|
||||
max-func-lines: 0
|
||||
|
||||
@@ -124,5 +134,3 @@ linters:
|
||||
linters: [gosec] # subprocess execution — legitimate in VM orchestration
|
||||
- text: "G304"
|
||||
linters: [gosec] # file path from variable — necessary for config loading
|
||||
- text: "G402"
|
||||
linters: [gosec] # TLS config — we handle TLS ourselves
|
||||
|
||||
@@ -34,6 +34,9 @@ else
|
||||
endif
|
||||
|
||||
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
|
||||
# Note: grep pipelines use PIPESTATUS[0] so the exit status of `go test`
|
||||
# propagates through the filter — otherwise a test failure is swallowed by
|
||||
# grep's own (success) exit code and preflight prints "passed" on red.
|
||||
ifdef QUIET
|
||||
_Q = @
|
||||
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
|
||||
@@ -46,8 +49,7 @@ else
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
|
||||
build:
|
||||
$(MAKE) go_build
|
||||
build: go_build build-installer build-lb-agent
|
||||
|
||||
# Build spinifex-ui frontend (requires pnpm)
|
||||
build-ui:
|
||||
@@ -63,6 +65,23 @@ go_build:
|
||||
@echo -e "\n....Building $(GO_PROJECT_NAME)"
|
||||
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
|
||||
|
||||
build-installer:
|
||||
@echo -e "\n....Building spinifex-installer"
|
||||
go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
|
||||
|
||||
build-lb-agent:
|
||||
@echo -e "\n....Building lb-agent (static)"
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/lb-agent cmd/lb-agent/main.go
|
||||
|
||||
build-system-image: ## Build a system image from manifest (use IMAGE=lb)
|
||||
ifndef IMAGE
|
||||
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
|
||||
endif
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
|
||||
|
||||
build-lb-image: ## Build LB Alpine image
|
||||
$(MAKE) build-system-image IMAGE=lb
|
||||
|
||||
go_run:
|
||||
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
|
||||
$(GOPATH)/bin/$(GO_PROJECT_NAME)
|
||||
@@ -103,6 +122,17 @@ run:
|
||||
$(MAKE) go_build
|
||||
$(MAKE) go_run
|
||||
|
||||
# Fast iteration: build + install binary + restart all services
|
||||
deploy: build
|
||||
sudo install -m 755 bin/spx /usr/local/bin/spx
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart spinifex.target
|
||||
|
||||
# Re-run setup.sh after changing systemd units, helper scripts, or logrotate config.
|
||||
# Not needed for code-only changes — use deploy for those.
|
||||
reinstall:
|
||||
scripts/dev-install.sh
|
||||
|
||||
clean:
|
||||
rm -f ./bin/$(GO_PROJECT_NAME)
|
||||
rm -rf spinifex/services/spinifexui/frontend/dist
|
||||
@@ -116,12 +146,12 @@ install-system:
|
||||
nbdkit nbdkit-plugin-dev pkg-config $(QEMU_PACKAGES) qemu-utils qemu-kvm \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
|
||||
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
|
||||
ovn-central ovn-host openvswitch-switch
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.26.2 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.26.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
@@ -194,7 +224,23 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
|
||||
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
|
||||
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
|
||||
ansible-dev-preflight:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
|
||||
|
||||
ansible-dev-teardown:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
|
||||
|
||||
ansible-dev-install:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
|
||||
|
||||
ansible-dev-reset:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
|
||||
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-lb-image go_build go_run preflight test test-cover test-race diff-coverage bench run \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
distro distro-amd64 distro-arm64 distro-clean \
|
||||
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
<p align="center">
|
||||
<img src=".github/assets/banner.svg" alt="Spinifex — AWS-Compatible Infrastructure Stack" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></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://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-docs.mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-is-spinifex">What is Spinifex?</a> ·
|
||||
<a href="#the-platform">Platform</a> ·
|
||||
<a href="#core-components">Components</a> ·
|
||||
<a href="#architecture-at-a-glance">Architecture</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
<a href="https://docs.mulgadc.com">Docs</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
|
||||
|
||||
(Developer Preview) - Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com/) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
|
||||
Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
|
||||
|
||||
## What is Spinifex?
|
||||
|
||||
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
|
||||
|
||||
It’s designed for developers and operators who need:
|
||||
It's designed for developers and operators who need:
|
||||
|
||||
- Cloud-like infrastructure without the cloud
|
||||
- Minimal dependencies, full control
|
||||
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
|
||||
- A secure cloud environment you control and own the entire hardware, network and software stack
|
||||
|
||||
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren’t feasible.
|
||||
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren't feasible.
|
||||
|
||||
## The Platform
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/platform.svg" alt="The Mulga Platform — Hardware to Applications" width="900">
|
||||
</p>
|
||||
|
||||
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
|
||||
|
||||
## Core Components
|
||||
|
||||
### Spinifex (Compute Service – EC2 Alternative)
|
||||
|
||||
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU’s QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
|
||||
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
|
||||
|
||||
- EC2-like VM management on bare metal
|
||||
- Launches with cloud-init metadata support
|
||||
@@ -27,7 +56,7 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
|
||||
|
||||
### Viperblock (Block Storage – EBS Alternative)
|
||||
|
||||
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It’s built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
|
||||
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It's built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
|
||||
|
||||
- Fast, durable virtual disks
|
||||
- Replication for resilience
|
||||
@@ -43,39 +72,48 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
|
||||
- Multipart uploads, streaming reads/writes
|
||||
- Data redundancy with Reed-Solomon encoding
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture — AWS Gateway, NATS bus, service daemons" width="900">
|
||||
</p>
|
||||
|
||||
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless — scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
|
||||
|
||||
## Key Features
|
||||
|
||||
AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, or SDKs you already use.
|
||||
|
||||
- Designed for Control – Run on your own terms, whether that’s in a datacenter, remote site, or sensitive environment.
|
||||
- Designed for Control – Run on your own terms, whether that's in a datacenter, remote site, or sensitive environment.
|
||||
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
|
||||
- Works Offline – No reliance on centralized cloud services or external networks.
|
||||
- Open Source – Licensed under Apache 2.0. Fork it, extend it, or deploy it as-is.
|
||||
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
|
||||
|
||||
## Installation
|
||||
|
||||
Installation requires an Ubuntu / Debian system. See the detailed documentation at [https://docs.mulgadc.com](https://docs.mulgadc.com/) for maintaining and installing Spinifex.
|
||||
Installation requires an Ubuntu / Debian system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
|
||||
|
||||
### Single node install
|
||||
### Single Node Install
|
||||
|
||||
The installation is straightforward to set up and running on a single node for testing purposes.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
|
||||
sudo systemctl start spinifex.target
|
||||
|
||||
export AWS_PROFILE=spinifex
|
||||
export AWS_REGION=ap-southeast-2-sc
|
||||
```
|
||||
|
||||
```bash
|
||||
curl https://install.mulgadc.com/ | bash
|
||||
|
||||
spx admin init --region $AWS_REGION
|
||||
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
|
||||
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/source-install/) documentation
|
||||
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation
|
||||
|
||||
### Component Repositories
|
||||
|
||||
@@ -86,6 +124,26 @@ Spinifex coordinates these independent components:
|
||||
|
||||
Each component can be developed independently. See component-specific documentation for focused development guides.
|
||||
|
||||
## Spinifex UI
|
||||
|
||||
Spinifex ships with a built-in web console — an optional alternative to the AWS CLI, SDKs, and Terraform. If you're familiar with the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
|
||||
</p>
|
||||
|
||||
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
|
||||
|
||||
```bash
|
||||
open https://YOUR_NODE_IP:3000
|
||||
```
|
||||
|
||||
- **Same API, different surface.** Every action in the UI is the same AWS SigV4 call the CLI makes — so RBAC, audit trails, and IAM policies apply uniformly.
|
||||
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed — no separate user database.
|
||||
- **Self-hosted, works offline.** The UI is embedded in the Spinifex binary and served from the node itself. No external CDN, no analytics calls, no cloud dependency.
|
||||
|
||||
For the full walkthrough — first-time TLS certificate trust, login, and feature tour — see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
### Built by Engineers, For Engineers
|
||||
@@ -121,4 +179,4 @@ This hybrid approach ensures Spinifex benefits from both proven engineering expe
|
||||
|
||||
## License
|
||||
|
||||
Spinifex is open source under the Apache 2.0 License. You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
|
||||
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
|
||||
|
||||
@@ -42,6 +42,7 @@ RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-s
|
||||
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
|
||||
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
|
||||
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
|
||||
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
|
||||
|
||||
FROM scratch AS export
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Helper script for spinifex-predastore.service
|
||||
# Detects host, port, backend, and node-id from spinifex.toml
|
||||
# then execs spx. Falls back to single-node defaults if no config found.
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
BIND="0.0.0.0:8443"
|
||||
NODE_ID="0"
|
||||
|
||||
if [ -f "$CONF" ]; then
|
||||
H=$(awk -F'"' '/\[nodes\..*\.predastore\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$H" ]; then
|
||||
BIND="$H"
|
||||
BACKEND="distributed"
|
||||
fi
|
||||
|
||||
N=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2);print $2;exit}' "$CONF")
|
||||
if [ -n "$N" ] && [ "$N" != "0" ]; then
|
||||
NODE_ID="$N"
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
|
||||
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
|
||||
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
|
||||
|
||||
exec /usr/local/bin/spx service predastore start
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Wait for NATS to be reachable before starting a dependent service.
|
||||
# Reads the NATS address from spinifex.toml [nodes.*.nats] section,
|
||||
# falls back to nats.conf, then to 127.0.0.1:4222.
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
NATS_CONF=/etc/spinifex/nats/nats.conf
|
||||
NATS_HOST=""
|
||||
NATS_PORT="4222"
|
||||
TIMEOUT=30
|
||||
|
||||
# Try spinifex.toml first
|
||||
if [ -f "$CONF" ]; then
|
||||
NATS_BIND=$(awk -F'"' '/\[nodes\..*\.nats\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$NATS_BIND" ]; then
|
||||
NATS_HOST="${NATS_BIND%%:*}"
|
||||
NATS_PORT="${NATS_BIND##*:}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to nats.conf
|
||||
if [ -z "$NATS_HOST" ] && [ -f "$NATS_CONF" ]; then
|
||||
NATS_HOST=$(grep "^listen:" "$NATS_CONF" | sed 's/listen: *//;s/:.*//')
|
||||
fi
|
||||
|
||||
# Final fallback
|
||||
NATS_HOST="${NATS_HOST:-127.0.0.1}"
|
||||
|
||||
for i in $(seq 1 "$TIMEOUT"); do
|
||||
if nc -z "$NATS_HOST" "$NATS_PORT" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Timed out waiting for NATS at $NATS_HOST:$NATS_PORT" >&2
|
||||
exit 1
|
||||
@@ -2,21 +2,53 @@
|
||||
Description=Spinifex AWS Gateway
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-gw
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service awsgw start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
|
||||
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[Unit]
|
||||
Description=Spinifex Daemon (VM Orchestration)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-viperblock.service
|
||||
Requires=spinifex-nats.service
|
||||
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-daemon
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
@@ -17,5 +17,39 @@ Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
|
||||
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl)
|
||||
ProtectSystem=full
|
||||
PrivateDevices=no
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=/dev/kvm rw
|
||||
DeviceAllow=/dev/net/tun rw
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
# DHCP lease acquisition moved to spinifex-vpcd (see dhcp-allocate-error.md);
|
||||
# the daemon no longer needs AF_PACKET or the softened kernel protections.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -5,7 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-nats
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service nats start
|
||||
Restart=on-failure
|
||||
@@ -13,5 +13,36 @@ RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
Description=Spinifex Predastore (S3-compatible Object Storage)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-storage
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStart=/usr/local/bin/spx service predastore start
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/var/lib/spinifex/predastore-start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
@@ -18,5 +18,36 @@ Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -5,12 +5,45 @@ After=spinifex-awsgw.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-ui
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service spinifex-ui start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
Description=Spinifex Viperblock (Block Storage)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-predastore.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-viperblock
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service viperblock start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
@@ -17,5 +17,36 @@ EnvironmentFile=/etc/spinifex/systemd.env
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
[Unit]
|
||||
Description=Spinifex VPC Daemon (OVN Networking)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service
|
||||
Requires=spinifex-nats.service
|
||||
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service ovn-controller.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-vpcd
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service vpcd start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
|
||||
ProtectSystem=full
|
||||
PrivateDevices=no
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=no
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=no
|
||||
# AF_PACKET: required by the in-process DHCP client (github.com/insomniacslk/dhcp
|
||||
# / nclient4) for raw L2 BOOTP on br-wan before an IP is configured on the
|
||||
# interface. See docs/development/bugs/dhcp-allocate-error.md.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET
|
||||
# CAP_NET_RAW: open AF_PACKET sockets.
|
||||
# CAP_NET_BIND_SERVICE: covers unicast RENEW to UDP :68.
|
||||
# CAP_NET_ADMIN: already used for ovs-vsctl / ip link wiring on the bridge.
|
||||
# Note, disabled for now, sudo issue with ovs
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
NoNewPrivileges=no
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=Spinifex Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service openvswitch-switch.service ovn-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package autoinstall reads headless install parameters from environment
|
||||
// variables exported by spinifex-init.sh from the kernel cmdline.
|
||||
// Parameters are set in the GRUB "Headless" menu entry in grub.cfg.
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// Load returns an install.Config built from SPINIFEX_* environment variables,
|
||||
// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode).
|
||||
func Load() (*install.Config, error) {
|
||||
if os.Getenv("SPINIFEX_AUTO") != "1" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slog.Info("autoinstall: headless mode enabled via kernel cmdline")
|
||||
|
||||
cfg, err := buildConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autoinstall config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// EjectAndReboot ejects the boot device (best-effort) then reboots.
|
||||
func EjectAndReboot() {
|
||||
srcDev, _ := os.ReadFile("/run/iso-dev")
|
||||
if dev := strings.TrimSpace(string(srcDev)); dev != "" {
|
||||
slog.Info("autoinstall: ejecting boot device", "device", dev)
|
||||
_ = exec.Command("eject", dev).Run()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Installation complete.")
|
||||
fmt.Println("Remove the USB drive now if it was not ejected automatically.")
|
||||
fmt.Println("Rebooting in 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
// Use the kernel syscall directly — the live environment runs spinifex-init
|
||||
// as PID 1 (not systemd), so exec("reboot") fails trying to reach D-Bus.
|
||||
if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil {
|
||||
slog.Error("autoinstall: reboot syscall failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfig() (*install.Config, error) {
|
||||
password := os.Getenv("SPINIFEX_PASSWORD")
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_PASSWORD is required")
|
||||
}
|
||||
|
||||
hostname := os.Getenv("SPINIFEX_HOSTNAME")
|
||||
if hostname == "" {
|
||||
hostname = "spinifex-node"
|
||||
}
|
||||
|
||||
disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: %w", err)
|
||||
}
|
||||
|
||||
wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WAN NIC: %w", err)
|
||||
}
|
||||
|
||||
cfg := &install.Config{
|
||||
Disk: disk,
|
||||
Hostname: hostname,
|
||||
RootPassword: password,
|
||||
// Optional on the headless path — empty means no --email passed to
|
||||
// spx admin init, which omits the operator identity from telemetry.
|
||||
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
|
||||
WANInterface: wanIface,
|
||||
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
|
||||
}
|
||||
|
||||
if !cfg.WANDHCPMode {
|
||||
ip := os.Getenv("SPINIFEX_WAN_IP")
|
||||
mask := os.Getenv("SPINIFEX_WAN_MASK")
|
||||
gw := os.Getenv("SPINIFEX_WAN_GW")
|
||||
if ip == "" || mask == "" || gw == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
|
||||
}
|
||||
cfg.WANAddress = ip
|
||||
cfg.WANMask = mask
|
||||
cfg.WANGateway = gw
|
||||
if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" {
|
||||
cfg.WANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
}
|
||||
|
||||
if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" {
|
||||
lan, err := resolveNIC(lanIface, wanIface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LAN NIC: %w", err)
|
||||
}
|
||||
cfg.LANInterface = lan
|
||||
cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static"
|
||||
if !cfg.LANDHCPMode {
|
||||
cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP")
|
||||
cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK")
|
||||
if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" {
|
||||
cfg.LANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
|
||||
if role == "" {
|
||||
role = "init"
|
||||
}
|
||||
cfg.ClusterRole = role
|
||||
if role == "join" {
|
||||
joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR")
|
||||
if joinAddr == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join")
|
||||
}
|
||||
cfg.JoinAddr = joinAddr
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveDisk maps the spx_disk value from grub.cfg to a block device path.
|
||||
//
|
||||
// Supported values:
|
||||
//
|
||||
// auto — use the only non-removable disk; fail if multiple found
|
||||
// largest — largest non-removable disk (explicit opt-in)
|
||||
// smallest — smallest non-removable disk (typical OS-on-SSD pattern)
|
||||
// nvme — the only NVMe disk; fail if multiple found
|
||||
// /dev/sda (etc.) — exact device path; fail if not present
|
||||
func resolveDisk(target string) (string, error) {
|
||||
switch strings.ToLower(target) {
|
||||
case "", "auto":
|
||||
return singleDisk()
|
||||
case "largest":
|
||||
return diskBySize(true)
|
||||
case "smallest":
|
||||
return diskBySize(false)
|
||||
case "nvme":
|
||||
return nvmeDisk()
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return "", fmt.Errorf("%q not found", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
|
||||
type diskCandidate struct {
|
||||
dev string
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// nonRemovableDisks returns all non-removable, non-virtual block devices.
|
||||
func nonRemovableDisks() ([]diskCandidate, error) {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []diskCandidate
|
||||
for _, e := range entries {
|
||||
dev := e.Name()
|
||||
switch {
|
||||
case strings.HasPrefix(dev, "loop"),
|
||||
strings.HasPrefix(dev, "ram"),
|
||||
strings.HasPrefix(dev, "dm-"),
|
||||
strings.HasPrefix(dev, "sr"):
|
||||
continue
|
||||
}
|
||||
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
|
||||
if strings.TrimSpace(string(removable)) == "1" {
|
||||
continue
|
||||
}
|
||||
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
|
||||
sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
|
||||
if parseErr != nil || sectors == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, diskCandidate{dev: dev, bytes: sectors * 512})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// diskList returns a human-readable list of candidates for error messages.
|
||||
func diskList(disks []diskCandidate) string {
|
||||
lines := make([]string, len(disks))
|
||||
for i, d := range disks {
|
||||
lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// singleDisk selects the only non-removable disk, or fails listing all found.
|
||||
func singleDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(disks) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+
|
||||
" largest (largest disk)\n"+
|
||||
" smallest (smallest disk)\n"+
|
||||
" nvme (NVMe only)\n"+
|
||||
" /dev/sdX (exact path)",
|
||||
diskList(disks),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// diskBySize selects the largest (largest=true) or smallest non-removable disk.
|
||||
func diskBySize(largest bool) (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(disks) == 0 {
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
}
|
||||
sort.Slice(disks, func(i, j int) bool {
|
||||
if largest {
|
||||
return disks[i].bytes > disks[j].bytes
|
||||
}
|
||||
return disks[i].bytes < disks[j].bytes
|
||||
})
|
||||
label := "smallest"
|
||||
if largest {
|
||||
label = "largest"
|
||||
}
|
||||
slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
}
|
||||
|
||||
// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found.
|
||||
func nvmeDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var nvmes []diskCandidate
|
||||
for _, d := range disks {
|
||||
if strings.HasPrefix(d.dev, "nvme") {
|
||||
nvmes = append(nvmes, d)
|
||||
}
|
||||
}
|
||||
switch len(nvmes) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no NVMe disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30)
|
||||
return "/dev/" + nvmes[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s",
|
||||
diskList(nvmes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// virtualNICPrefixes lists interface name prefixes that identify non-physical
|
||||
// interfaces (bridges, tunnels, container/OVS veth pairs, etc.). These are
|
||||
// skipped when auto-selecting a NIC so we don't configure docker0 or
|
||||
// ovs-system as the management interface on a machine that previously ran
|
||||
// Docker or OVN.
|
||||
var virtualNICPrefixes = []string{
|
||||
"docker", "veth", "virbr", "br-", "ovs-", "vxlan", "genev", "tun", "tap",
|
||||
}
|
||||
|
||||
// resolveNIC returns the interface name to use. "auto" or empty picks the
|
||||
// first physical (non-loopback, non-virtual) interface that is not exclude.
|
||||
func resolveNIC(name, exclude string) (string, error) {
|
||||
if name != "" && name != "auto" {
|
||||
return name, nil
|
||||
}
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
// Require broadcast capability — filters out point-to-point tunnels.
|
||||
if iface.Flags&net.FlagBroadcast == 0 {
|
||||
continue
|
||||
}
|
||||
// Require a MAC address — virtual/tunnel interfaces have none.
|
||||
if len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Name == exclude {
|
||||
continue
|
||||
}
|
||||
// Skip known virtual interface prefixes.
|
||||
virtual := false
|
||||
for _, pfx := range virtualNICPrefixes {
|
||||
if strings.HasPrefix(iface.Name, pfx) {
|
||||
virtual = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if virtual {
|
||||
continue
|
||||
}
|
||||
slog.Info("autoinstall: NIC resolved", "mode", "auto", "selected", iface.Name)
|
||||
return iface.Name, nil
|
||||
}
|
||||
return "", fmt.Errorf("no suitable physical NIC found (non-loopback, broadcast-capable, with MAC)")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package branding centralises Mulga/Spinifex visual identity constants used
|
||||
// by the installer TUI.
|
||||
//
|
||||
// TODO: Replace placeholder values with official Mulga/Spinifex assets once
|
||||
// the brand guide and logo files are located.
|
||||
package branding
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Logo is the ASCII art rendered on the installer welcome screen.
|
||||
// TODO: Replace with official Spinifex/Mulga ASCII logo.
|
||||
const Logo = `
|
||||
███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝
|
||||
███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝
|
||||
╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗
|
||||
███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗
|
||||
╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝`
|
||||
|
||||
// Subtitle is displayed beneath the logo on the welcome screen.
|
||||
const Subtitle = "Bare-Metal Node Provisioner"
|
||||
|
||||
// Publisher is shown in the welcome screen footer.
|
||||
const Publisher = "Mulga Defense Corporation"
|
||||
|
||||
// Mulga color palette.
|
||||
// TODO: Replace with official hex values from the Mulga brand guide.
|
||||
const (
|
||||
ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder)
|
||||
ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder)
|
||||
ColorBackground = lipgloss.Color("#0D1117") // Dark background
|
||||
ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface
|
||||
ColorText = lipgloss.Color("#E6EDF3") // Light text
|
||||
ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text
|
||||
ColorSuccess = lipgloss.Color("#3FB950") // Green
|
||||
ColorWarning = lipgloss.Color("#D29922") // Amber
|
||||
ColorError = lipgloss.Color("#F85149") // Red
|
||||
ColorBorder = lipgloss.Color("#30363D") // Border
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package firstboot writes the oneshot systemd service and configuration that
|
||||
// completes Spinifex provisioning on the first real boot after installation.
|
||||
package firstboot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/systemd"
|
||||
)
|
||||
|
||||
// Config holds the values the firstboot service needs to configure the node.
|
||||
type Config struct {
|
||||
Hostname string
|
||||
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
|
||||
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
|
||||
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
|
||||
EncapIP string
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
|
||||
// the headless path. Passed to `spx admin init --email=<value>` when set;
|
||||
// omitted entirely when empty.
|
||||
Email string
|
||||
}
|
||||
|
||||
// Write drops the firstboot script and systemd unit into root, which should be
|
||||
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
|
||||
func Write(root string, cfg Config) error {
|
||||
if err := writeScript(root, cfg); err != nil {
|
||||
return fmt.Errorf("firstboot script: %w", err)
|
||||
}
|
||||
if err := systemd.WriteFirstbootUnit(root); err != nil {
|
||||
return fmt.Errorf("firstboot unit: %w", err)
|
||||
}
|
||||
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Banner unit is written but NOT enabled here. firstboot enables it
|
||||
// (along with spinifex.target) at the end of its run, so on the first
|
||||
// boot the only spinifex unit running is firstboot itself — no implicit
|
||||
// pull of spinifex.target via the banner's Wants= relationship, which
|
||||
// was the root cause of the boot-time race against firstboot.
|
||||
if err := systemd.WriteBannerUnit(root); err != nil {
|
||||
return fmt.Errorf("banner unit: %w", err)
|
||||
}
|
||||
return systemd.WriteGettyDropIn(root)
|
||||
}
|
||||
|
||||
func writeScript(root string, cfg Config) error {
|
||||
clusterCmd := buildClusterCmd(cfg)
|
||||
|
||||
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
|
||||
// and setup-ovn.sh auto-detects it from the default route at boot.
|
||||
setupOVN := "/usr/local/bin/setup-ovn.sh --management"
|
||||
if cfg.EncapIP != "" {
|
||||
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
# Spinifex firstboot — runs once after ISO installation. On success, writes
|
||||
# /var/lib/spinifex/.firstboot-done and the systemd unit's
|
||||
# ConditionPathExists=! prevents re-execution on subsequent boots. A partial
|
||||
# run leaves no marker, so the next reboot retries from the top — safe
|
||||
# because every step below is idempotent (hostnamectl, setup-ovn.sh, spx
|
||||
# admin init are all "set if not set"; systemctl enable on an already-enabled
|
||||
# unit is a no-op).
|
||||
set -euo pipefail
|
||||
|
||||
DONE_MARKER=/var/lib/spinifex/.firstboot-done
|
||||
|
||||
# Idempotency: bail early if a previous run completed successfully. The unit
|
||||
# also has ConditionPathExists=!$DONE_MARKER so we shouldn't get here on
|
||||
# subsequent boots, but defend in depth in case the unit is re-triggered
|
||||
# manually before the operator notices the marker exists.
|
||||
if [ -f "$DONE_MARKER" ]; then
|
||||
echo "[firstboot] already complete — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Set hostname
|
||||
hostnamectl set-hostname %s
|
||||
|
||||
# Pre-start OVS and OVN central so their databases are initialised before
|
||||
# setup-ovn.sh runs. On physical hardware, first-boot DB initialisation takes
|
||||
# longer than setup-ovn.sh's internal 15-second timeout allows. Starting them
|
||||
# here and waiting until the NB DB is ready means setup-ovn.sh sees a live DB
|
||||
# the moment it starts — no races, no timeout failures.
|
||||
systemctl start openvswitch-switch
|
||||
systemctl start ovn-central
|
||||
echo "Waiting for OVN NB DB to initialise..."
|
||||
for _i in $(seq 1 120); do
|
||||
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
|
||||
echo "OVN NB DB ready (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Create default nameservers if DHCP server returns blank
|
||||
printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolvconf/resolv.conf.d/base
|
||||
resolvconf -u
|
||||
|
||||
# Configure OVN networking.
|
||||
# br-wan (and br-lan if present) are Linux bridges managed by systemd-networkd
|
||||
# (declared in /etc/systemd/network/ by the installer). setup-ovn.sh
|
||||
# auto-detects br-wan as the default route device and wires it to OVS via a
|
||||
# veth pair — non-destructive, SSH-safe.
|
||||
%s
|
||||
|
||||
# Write the banner (first time, spinifex-banner service will do this on reboot)
|
||||
/usr/local/bin/spx admin banner --boot-check
|
||||
|
||||
# Cluster formation — capture credentials to file for display on console.
|
||||
%s 2>&1
|
||||
|
||||
# Fix ownership of files spx admin init wrote. spx runs as root under
|
||||
# systemd, so /etc/spinifex/{spinifex.toml,master.key,ca.key,*.pem} and any
|
||||
# per-service files written under /var/lib/spinifex/* land as root:root.
|
||||
# Delegate to setup.sh's fix_file_ownership (single source of truth) — it
|
||||
# knows the per-file modes (ca.key 0600, *.pem 0644, toml/key 0640) that a
|
||||
# blunt recursive chmod would clobber.
|
||||
SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
|
||||
# The /var/lib/spinifex/awsgw/config symlink was created at build time by
|
||||
# setup.sh's create_directories — it resolves to /etc/spinifex, so
|
||||
# {BaseDir}/config/master.key automatically points at /etc/spinifex/master.key.
|
||||
|
||||
# Copy AWS credentials to the spinifex user's home directory.
|
||||
# spx admin init runs with HOME=/root (set by the systemd unit), so credentials
|
||||
# land in /root/.aws/. Copy them to the spinifex user's home so the operator
|
||||
# can use the AWS CLI without sudo.
|
||||
if [ -f /root/.aws/credentials ]; then
|
||||
mkdir -p /home/spinifex/.aws
|
||||
cp /root/.aws/credentials /home/spinifex/.aws/credentials
|
||||
cp /root/.aws/config /home/spinifex/.aws/config 2>/dev/null || true
|
||||
chown -R spinifex:spinifex /home/spinifex/.aws
|
||||
chmod 700 /home/spinifex/.aws
|
||||
chmod 600 /home/spinifex/.aws/credentials
|
||||
[ -f /home/spinifex/.aws/config ] && chmod 600 /home/spinifex/.aws/config
|
||||
fi
|
||||
|
||||
# Enable + activate spinifex.target and the banner now that all configs are
|
||||
# in place. "enable --now" creates the multi-user.target.wants/ symlinks (so
|
||||
# they start directly on every subsequent boot — no longer dependent on
|
||||
# firstboot running each time, since firstboot is condition-skipped after
|
||||
# this run via the marker) and activates them in this boot.
|
||||
# NOTE: Moved to firstinstall to enable
|
||||
#systemctl enable --now spinifex.target spinifex-banner.service
|
||||
|
||||
# Enable services to start, on reboot
|
||||
systemctl enable spinifex.target spinifex-banner.service
|
||||
|
||||
# Mark complete only after every step above has succeeded. Until this point,
|
||||
# any failure (set -e) leaves the marker absent and the next reboot retries
|
||||
# firstboot from the top.
|
||||
mkdir -p "$(dirname "$DONE_MARKER")"
|
||||
touch "$DONE_MARKER"
|
||||
|
||||
systemctl start spinifex.target
|
||||
|
||||
# Wait for the daemon to bring up external networking. When external_mode=pool
|
||||
# is configured the daemon creates br-ext (the OVN external bridge) during
|
||||
# startup; launching instances before it is ready results in no public IP being
|
||||
# assigned. ip link requires no root and avoids parsing daemon logs.
|
||||
if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
|
||||
echo "[firstboot] waiting for external networking (br-ext)..."
|
||||
for _i in $(seq 1 30); do
|
||||
if ip link show br-ext >/dev/null 2>&1; then
|
||||
echo "[firstboot] br-ext ready (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! ip link show br-ext >/dev/null 2>&1; then
|
||||
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
|
||||
fi
|
||||
fi
|
||||
`, cfg.Hostname, setupOVN, clusterCmd)
|
||||
|
||||
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
||||
return os.WriteFile(path, []byte(script), 0o755)
|
||||
}
|
||||
|
||||
func buildClusterCmd(cfg Config) string {
|
||||
emailFlag := ""
|
||||
if cfg.Email != "" {
|
||||
// shellEscapeSingle keeps the email safe if it ever contains a
|
||||
// character shell treats specially — belt-and-braces; the regex
|
||||
// validator already rejects whitespace and @-chains.
|
||||
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
|
||||
}
|
||||
switch cfg.ClusterRole {
|
||||
case "join":
|
||||
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
|
||||
default:
|
||||
return fmt.Sprintf("spx admin init --node %s --nodes 1%s", cfg.Hostname, emailFlag)
|
||||
}
|
||||
}
|
||||
|
||||
// shellEscapeSingle wraps s in single quotes with any embedded single
|
||||
// quotes escaped. Minimal — we only need this because the email value is
|
||||
// interpolated into a shell script written by Write().
|
||||
func shellEscapeSingle(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package install performs the disk installation steps after the UI has
|
||||
// collected configuration.
|
||||
package install
|
||||
|
||||
// Config holds all values collected by the installer UI.
|
||||
type Config struct {
|
||||
// Disk is the block device path to install onto (e.g. /dev/sda).
|
||||
Disk string
|
||||
|
||||
// WAN interface — always present. The management IP lives on br-wan (a Linux
|
||||
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
|
||||
// to safely attach to the bridge without disrupting host connectivity.
|
||||
WANInterface string
|
||||
WANDHCPMode bool
|
||||
WANAddress string // empty if WANDHCPMode
|
||||
WANMask string // empty if WANDHCPMode
|
||||
WANGateway string // empty if WANDHCPMode
|
||||
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
|
||||
WANWiFiSSID string // non-empty if a WiFi NIC was selected
|
||||
WANWiFiPass string // non-empty if a WiFi NIC was selected
|
||||
|
||||
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
|
||||
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
|
||||
LANInterface string
|
||||
LANDHCPMode bool
|
||||
LANAddress string
|
||||
LANMask string
|
||||
LANDNS []string
|
||||
LANWiFiSSID string
|
||||
LANWiFiPass string
|
||||
|
||||
// Node identity
|
||||
Hostname string
|
||||
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
|
||||
// JoinAddr is the primary node address (host:port) when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
|
||||
// CA certificate (PEM), optional.
|
||||
HasCACert bool
|
||||
CACert string
|
||||
|
||||
// RootPassword is the password to set for the root account on the installed system.
|
||||
RootPassword string
|
||||
|
||||
// Email is the operator's email address, used by the call-home telemetry
|
||||
// endpoint to notify of important system updates and security advisories.
|
||||
// Required on interactive installs; may be empty on headless/CI installs
|
||||
// when SPINIFEX_EMAIL is not supplied on the kernel cmdline.
|
||||
Email string
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package install
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/firstboot"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/systemd"
|
||||
)
|
||||
|
||||
const (
|
||||
mountRoot = "/mnt/spinifex-install"
|
||||
efiPart = mountRoot + "/boot/efi"
|
||||
)
|
||||
|
||||
// Run executes all installation steps in order. It is intentionally sequential
|
||||
// and explicit — each step is visible in logs so failures are easy to diagnose.
|
||||
func Run(cfg *Config) error {
|
||||
// The live environment may not have /sbin or /usr/sbin in PATH. Set it
|
||||
// explicitly so exec.Command's LookPath finds system binaries like grub-install.
|
||||
_ = os.Setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
|
||||
// Unmount unconditionally on exit so a failed step never leaves partitions
|
||||
// mounted in the live environment, which would cause a retry to double-mount.
|
||||
defer func() {
|
||||
_ = run("umount", efiPart)
|
||||
_ = run("umount", mountRoot)
|
||||
}()
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"partition disk", func() error { return partitionDisk(cfg.Disk) }},
|
||||
{"format partitions", func() error { return formatPartitions(cfg.Disk) }},
|
||||
{"mount partitions", func() error { return mountPartitions(cfg.Disk) }},
|
||||
{"copy rootfs", copyRootfs},
|
||||
{"write fstab", func() error { return writeFstab(cfg.Disk) }},
|
||||
{"install spinifex", func() error { return installSpinifex(cfg) }},
|
||||
{"write network config", func() error { return writeNetworkConfig(cfg) }},
|
||||
{"write firstboot service", func() error { return firstboot.Write(mountRoot, cfg.toFirstbootConfig()) }},
|
||||
{"install bootloader", func() error { return installBootloader(cfg.Disk) }},
|
||||
{"install CA cert", func() error { return installCACert(cfg) }},
|
||||
}
|
||||
|
||||
for _, s := range steps {
|
||||
slog.Info("installer", "step", s.name)
|
||||
if err := s.fn(); err != nil {
|
||||
return fmt.Errorf("step %q: %w", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("installation complete")
|
||||
promptRemoveUSB()
|
||||
return reboot()
|
||||
}
|
||||
|
||||
func partitionDisk(disk string) error {
|
||||
// GPT table with three partitions:
|
||||
// p1: 1MiB BIOS Boot Partition — required for grub-install i386-pc on GPT
|
||||
// p2: 512MiB EFI System Partition
|
||||
// p3: remainder as root (ext4)
|
||||
if err := run("parted", "--script", disk,
|
||||
"mklabel", "gpt",
|
||||
"mkpart", "bios_boot", "1MiB", "2MiB",
|
||||
"set", "1", "bios_grub", "on",
|
||||
"mkpart", "ESP", "fat32", "2MiB", "514MiB",
|
||||
"set", "2", "esp", "on",
|
||||
"mkpart", "root", "ext4", "514MiB", "100%",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// Force the kernel to re-read the partition table and wait for udev to
|
||||
// create the partition device nodes. Without this, mkfs.fat in the next
|
||||
// step may race and fail with "No such file or directory" on /dev/sda2 —
|
||||
// the kernel has accepted the new layout but /dev hasn't been populated
|
||||
// yet. Trixie's udev seems slower at this than Bookworm's was.
|
||||
return waitForPartitions(disk)
|
||||
}
|
||||
|
||||
// waitForPartitions ensures the EFI and root partition device nodes exist
|
||||
// after parted creates them. It runs partprobe (kernel re-read) and
|
||||
// udevadm settle (wait for queued events), then polls /dev for the files.
|
||||
func waitForPartitions(disk string) error {
|
||||
// Best-effort: partprobe failure isn't fatal — udev may still pick up
|
||||
// the change from the BLKRRPART ioctl that parted itself issued.
|
||||
if err := run("partprobe", disk); err != nil {
|
||||
slog.Warn("partprobe failed, continuing", "disk", disk, "err", err)
|
||||
}
|
||||
if err := run("udevadm", "settle", "--timeout=10"); err != nil {
|
||||
slog.Warn("udevadm settle failed, continuing", "err", err)
|
||||
}
|
||||
efi, root := partitionPaths(disk)
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for _, part := range []string{efi, root} {
|
||||
for {
|
||||
if _, err := os.Stat(part); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("partition device %s did not appear within timeout — kernel/udev did not pick up new partition table", part)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatPartitions(disk string) error {
|
||||
efi, root := partitionPaths(disk)
|
||||
if err := run("mkfs.fat", "-F32", efi); err != nil {
|
||||
return err
|
||||
}
|
||||
return run("mkfs.ext4", "-F", root)
|
||||
}
|
||||
|
||||
func mountPartitions(disk string) error {
|
||||
_, root := partitionPaths(disk)
|
||||
if err := os.MkdirAll(mountRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("mount", root, mountRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(efiPart, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
efi, _ := partitionPaths(disk)
|
||||
return run("mount", efi, efiPart)
|
||||
}
|
||||
|
||||
// copyRootfs copies the live squashfs environment onto the target disk using
|
||||
// rsync. This is the air-gapped alternative to debootstrap — all packages are
|
||||
// already embedded in the ISO so no network access is required.
|
||||
func copyRootfs() error {
|
||||
if err := run("rsync", "-aHAX", "--delete", "--info=progress2",
|
||||
"--exclude=/proc",
|
||||
"--exclude=/sys",
|
||||
"--exclude=/dev",
|
||||
"--exclude=/run",
|
||||
"--exclude=/tmp",
|
||||
"--exclude=/cdrom",
|
||||
"--exclude=/mnt",
|
||||
"--exclude=/etc/machine-id",
|
||||
"--exclude=/var/lib/dbus/",
|
||||
"--exclude=/etc/openvswitch/",
|
||||
"--exclude=/var/lib/openvswitch/",
|
||||
"--exclude=/var/lib/dhcpcd/",
|
||||
"--exclude=/lost+found",
|
||||
"--exclude=/boot/efi",
|
||||
"/", mountRoot+"/",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify critical paths exist before proceeding. rsync exits 0 on ENOSPC
|
||||
// for individual file writes on some filesystems, which would produce a
|
||||
// partial rootfs that boots into a panic.
|
||||
critical := []string{
|
||||
filepath.Join(mountRoot, "bin/bash"),
|
||||
filepath.Join(mountRoot, "lib/systemd/systemd"),
|
||||
filepath.Join(mountRoot, "usr/local/bin/spx"),
|
||||
}
|
||||
for _, p := range critical {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("copyRootfs: critical path missing after rsync (%s): %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// rsync skips excluded paths entirely — recreate the virtual filesystem
|
||||
// mount points that systemd expects to exist on the installed system.
|
||||
mountPoints := []struct {
|
||||
path string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"proc", 0o555},
|
||||
{"sys", 0o555},
|
||||
{"dev", 0o755},
|
||||
{"run", 0o755},
|
||||
{"run/lock", 0o1777},
|
||||
{"tmp", 0o1777},
|
||||
{"mnt", 0o755},
|
||||
}
|
||||
for _, mp := range mountPoints {
|
||||
if err := os.MkdirAll(filepath.Join(mountRoot, mp.path), mp.mode); err != nil {
|
||||
return fmt.Errorf("create mountpoint /%s: %w", mp.path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installSpinifex(cfg *Config) error {
|
||||
// The rootfs copy already contains spx and spinifex-installer at
|
||||
// /usr/local/bin/ — no binary copy needed. Regenerate machine-specific
|
||||
// identity files so each installed node is unique.
|
||||
|
||||
// Bind-mount /dev, /proc, /sys into the chroot so PAM (chpasswd) and
|
||||
// other chroot commands work correctly. Trixie's PAM requires /proc and
|
||||
// /dev/urandom for password hashing.
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
|
||||
// Generate a unique machine-id from the kernel CSPRNG. We do not use
|
||||
// systemd-machine-id-setup in the chroot: it reads the SMBIOS UUID via
|
||||
// the bind-mounted /sys, which is identical on cloned VMs or hosts with
|
||||
// absent/zeroed DMI data, and falls back to non-unique sources (MAC,
|
||||
// hostname) when that also fails — producing the same ID on every node.
|
||||
// /proc/sys/kernel/random/uuid is always unique and requires no chroot.
|
||||
machineIDPath := filepath.Join(mountRoot, "etc/machine-id")
|
||||
rawUUID, err := os.ReadFile("/proc/sys/kernel/random/uuid")
|
||||
if err != nil {
|
||||
return fmt.Errorf("installSpinifex: read kernel uuid: %w", err)
|
||||
}
|
||||
machineID := strings.ReplaceAll(strings.TrimSpace(string(rawUUID)), "-", "") + "\n"
|
||||
if err := os.WriteFile(machineIDPath, []byte(machineID), 0o444); err != nil {
|
||||
return fmt.Errorf("installSpinifex: write machine-id: %w", err)
|
||||
}
|
||||
// dbus mirrors /etc/machine-id; remove it so it is re-created from the
|
||||
// new ID on first boot rather than carrying a stale value.
|
||||
_ = os.Remove(filepath.Join(mountRoot, "var/lib/dbus/machine-id"))
|
||||
|
||||
// Hostname.
|
||||
hostnamePath := filepath.Join(mountRoot, "etc/hostname")
|
||||
if err := os.WriteFile(hostnamePath, []byte(cfg.Hostname+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// /etc/hosts entry for the hostname.
|
||||
hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n127.0.1.1\t%s\n", cfg.Hostname)
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/hosts"), []byte(hosts), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set root + spinifex passwords. We invoke chpasswd from the LIVE
|
||||
// installer (not via `chroot`), passing the target root with -R and
|
||||
// forcing -c YESCRYPT. This deliberately bypasses PAM:
|
||||
// * `chroot ... chpasswd` uses /etc/pam.d/chpasswd → common-password →
|
||||
// pam_unix.so with the "obscure" option, which can return
|
||||
// "Authentication token manipulation error" inside a chroot for
|
||||
// reasons that are awkward to diagnose (audit subsystem, locked
|
||||
// shadow entries from useradd, etc.).
|
||||
// * -c YESCRYPT tells chpasswd to hash locally with libcrypt and write
|
||||
// directly to <root>/etc/shadow — no PAM stack involved. YESCRYPT
|
||||
// matches Trixie's ENCRYPT_METHOD so subsequent logins use the same
|
||||
// algorithm.
|
||||
// * -R <root> opens the target's passwd/shadow directly; no bind
|
||||
// mounts of /dev/urandom or /proc are needed for the password step.
|
||||
if cfg.RootPassword != "" {
|
||||
if err := setShadowPassword("root", cfg.RootPassword); err != nil {
|
||||
return fmt.Errorf("set root password: %w", err)
|
||||
}
|
||||
// The spinifex account is the default interactive login on the
|
||||
// node (console + SSH). Root SSH is disabled, so this is the sole
|
||||
// remote entry point. The user itself is created at rootfs build
|
||||
// time (build-rootfs.sh) — here we just set its password.
|
||||
if err := setShadowPassword("spinifex", cfg.RootPassword); err != nil {
|
||||
return fmt.Errorf("set spinifex password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write /etc/spinifex/node.conf — read at runtime by spx admin banner
|
||||
// to look up the current IP dynamically (handles IP changes after install).
|
||||
// MANAGEMENT_IFACE is the bridge (br-wan), not the physical NIC.
|
||||
// MANAGEMENT_IP is empty for DHCP — banner's --boot-check fills it in at boot.
|
||||
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=br-wan\nNODE_HOSTNAME=%s\n",
|
||||
cfg.WANAddress, cfg.Hostname)
|
||||
confDir := filepath.Join(mountRoot, "etc/spinifex")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(confDir, "node.conf"), []byte(nodeConf), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
|
||||
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
|
||||
// and races with systemd-networkd's built-in DHCP client on br-wan.
|
||||
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
|
||||
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeNetworkConfig(cfg *Config) error {
|
||||
// IPs live on Linux bridges (br-wan, br-lan), not on the physical NICs.
|
||||
// systemd-networkd owns the full lifecycle: it creates the bridge NetDevs,
|
||||
// enslaves the physical NICs, and runs the DHCP client. This means the
|
||||
// veth pair that setup-ovn.sh adds later (veth-wan-br) is a port of a
|
||||
// networkd-known bridge, so a networkctl reload or reboot never orphans it.
|
||||
netdDir := filepath.Join(mountRoot, "etc/systemd/network")
|
||||
if err := os.MkdirAll(netdDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeNetworkdBridge(netdDir, cfg.WANInterface, "br-wan", false,
|
||||
cfg.WANDHCPMode, cfg.WANAddress, cfg.WANMask, cfg.WANGateway, cfg.WANDNS,
|
||||
cfg.WANWiFiSSID, cfg.WANWiFiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.LANInterface != "" {
|
||||
if err := writeNetworkdBridge(netdDir, cfg.LANInterface, "br-lan", true,
|
||||
cfg.LANDHCPMode, cfg.LANAddress, cfg.LANMask, "", cfg.LANDNS,
|
||||
cfg.LANWiFiSSID, cfg.LANWiFiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
// br-lan timing is controlled by spinifex-lan-bridge.service, not
|
||||
// networkd auto-activation — ActivationPolicy=manual in the .network
|
||||
// file ensures networkd creates the bridge but does not bring it up.
|
||||
if err := systemd.WriteLANBridgeUnit(mountRoot); err != nil {
|
||||
return fmt.Errorf("lan bridge unit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unmask systemd-networkd-wait-online on the installed system and scope
|
||||
// it to br-wan only. The live ISO masks this service (build-rootfs.sh) to
|
||||
// avoid blocking the installer environment (which has no br-wan). The mask
|
||||
// is copied to the installed system by copyRootfs and must be removed here
|
||||
// so that network-online.target — and therefore spinifex-firstboot.service
|
||||
// (After=network-online.target) — does not fire before br-wan has its
|
||||
// DHCP lease and default route. Without this, setup-ovn.sh finds no default
|
||||
// route and exits early, creating no veth pair.
|
||||
//
|
||||
// The --interface=br-wan scope means br-lan (ActivationPolicy=manual) never
|
||||
// blocks the wait; --timeout=60 caps the delay on a cold switch.
|
||||
waitOnlineMask := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service")
|
||||
_ = os.Remove(waitOnlineMask)
|
||||
waitOnlineDir := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service.d")
|
||||
if err := os.MkdirAll(waitOnlineDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
waitOnlineConf := "[Service]\nExecStart=\nExecStart=/lib/systemd/systemd-networkd-wait-online --interface=br-wan --timeout=60\n"
|
||||
if err := os.WriteFile(filepath.Join(waitOnlineDir, "spinifex-wan-only.conf"), []byte(waitOnlineConf), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable IPv6 via sysctl — belt-and-suspenders alongside IPv6AcceptRA=no
|
||||
// in the networkd .network files.
|
||||
bridges := []string{"br-wan"}
|
||||
if cfg.LANInterface != "" {
|
||||
bridges = append(bridges, "br-lan")
|
||||
}
|
||||
var sysctl strings.Builder
|
||||
sysctl.WriteString("# Generated by Spinifex installer — IPv6 disabled on management bridges\n")
|
||||
for _, br := range bridges {
|
||||
fmt.Fprintf(&sysctl, "net.ipv6.conf.%s.disable_ipv6=1\n", br)
|
||||
}
|
||||
sysctlDir := filepath.Join(mountRoot, "etc/sysctl.d")
|
||||
if err := os.MkdirAll(sysctlDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sysctlDir, "99-spinifex-network.conf"), []byte(sysctl.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pin each NIC name to its MAC address via udev so the installed system
|
||||
// always uses the same interface name regardless of probe order.
|
||||
udevDir := filepath.Join(mountRoot, "etc/udev/rules.d")
|
||||
if err := os.MkdirAll(udevDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var udevRules strings.Builder
|
||||
for _, iface := range []string{cfg.WANInterface, cfg.LANInterface} {
|
||||
if iface == "" {
|
||||
continue
|
||||
}
|
||||
mac, err := os.ReadFile("/sys/class/net/" + iface + "/address")
|
||||
if err != nil {
|
||||
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", iface, "err", err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&udevRules, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
|
||||
strings.TrimSpace(string(mac)), iface)
|
||||
}
|
||||
if udevRules.Len() > 0 {
|
||||
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRules.String()), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeNetworkdBridge writes the three systemd-networkd files that configure a
|
||||
// physical NIC enslaved to a Linux bridge:
|
||||
//
|
||||
// - {prio}-spinifex-{suffix}-nic.network enslaves the NIC to the bridge
|
||||
// - {prio+1}-spinifex-{suffix}.netdev declares the bridge device
|
||||
// - {prio+1}-spinifex-{suffix}.network configures IP on the bridge
|
||||
//
|
||||
// manual=true sets ActivationPolicy=manual so networkd creates the bridge but
|
||||
// does not auto-activate it — used for br-lan, which spinifex-lan-bridge.service
|
||||
// activates after network-online.target.
|
||||
func writeNetworkdBridge(dir, nicIface, bridgeName string, manual, dhcp bool,
|
||||
addr, mask, gw string, dns []string, wifiSSID, wifiPass string) error {
|
||||
suffix := strings.TrimPrefix(bridgeName, "br-")
|
||||
nicPrio, brPrio := 10, 11
|
||||
if manual {
|
||||
nicPrio, brPrio = 12, 13
|
||||
}
|
||||
|
||||
// NIC .network — enslaves the physical NIC to the bridge.
|
||||
nicNet := fmt.Sprintf("[Match]\nName=%s\n\n[Network]\nBridge=%s\n", nicIface, bridgeName)
|
||||
nicFile := fmt.Sprintf("%02d-spinifex-%s-nic.network", nicPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, nicFile), []byte(nicNet), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bridge .netdev — declares the bridge device.
|
||||
brNetdev := fmt.Sprintf("[NetDev]\nName=%s\nKind=bridge\n\n[Bridge]\nSTP=no\n", bridgeName)
|
||||
brNetdevFile := fmt.Sprintf("%02d-spinifex-%s.netdev", brPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, brNetdevFile), []byte(brNetdev), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bridge .network — IP configuration.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Match]\nName=%s\n\n", bridgeName)
|
||||
if manual {
|
||||
b.WriteString("[Link]\nActivationPolicy=manual\nRequiredForOnline=no\n\n")
|
||||
}
|
||||
b.WriteString("[Network]\n")
|
||||
if dhcp {
|
||||
b.WriteString("DHCP=ipv4\n")
|
||||
} else {
|
||||
cidr, err := addrCIDR(addr, mask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bridge %s: %w", bridgeName, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "Address=%s\n", cidr)
|
||||
if gw != "" {
|
||||
fmt.Fprintf(&b, "Gateway=%s\n", gw)
|
||||
}
|
||||
for _, ns := range dns {
|
||||
if ns = strings.TrimSpace(ns); ns != "" {
|
||||
fmt.Fprintf(&b, "DNS=%s\n", ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("IPv6AcceptRA=no\nConfigureWithoutCarrier=yes\n")
|
||||
if manual && dhcp {
|
||||
// br-lan is non-critical; fail fast on DHCP so a missing LAN cable
|
||||
// does not stall spinifex-lan-bridge.service indefinitely.
|
||||
b.WriteString("\n[DHCP]\nTimeout=10\n")
|
||||
}
|
||||
brNetFile := fmt.Sprintf("%02d-spinifex-%s.network", brPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, brNetFile), []byte(b.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wifiSSID != "" {
|
||||
if err := writeWPASupplicant(nicIface, wifiSSID, wifiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrCIDR converts a dotted-decimal address + subnet mask to CIDR notation.
|
||||
func addrCIDR(addr, dotMask string) (string, error) {
|
||||
if net.ParseIP(addr) == nil {
|
||||
return "", fmt.Errorf("invalid IP: %s", addr)
|
||||
}
|
||||
maskIP := net.ParseIP(dotMask)
|
||||
if maskIP == nil {
|
||||
return "", fmt.Errorf("invalid mask: %s", dotMask)
|
||||
}
|
||||
m := net.IPMask(maskIP.To4())
|
||||
ones, bits := m.Size()
|
||||
if bits == 0 {
|
||||
return "", fmt.Errorf("non-contiguous or zero subnet mask: %s", dotMask)
|
||||
}
|
||||
return fmt.Sprintf("%s/%d", addr, ones), nil
|
||||
}
|
||||
|
||||
// writeWPASupplicant writes a wpa_supplicant config for nicIface and enables
|
||||
// the per-interface wpa_supplicant@{iface}.service so authentication completes
|
||||
// before networkd enslaves the NIC to the bridge.
|
||||
func writeWPASupplicant(nicIface, ssid, psk string) error {
|
||||
conf := fmt.Sprintf(
|
||||
"ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev\nupdate_config=1\n\nnetwork={\n\tssid=%q\n\tpsk=%q\n}\n",
|
||||
ssid, psk,
|
||||
)
|
||||
wpaDir := filepath.Join(mountRoot, "etc/wpa_supplicant")
|
||||
if err := os.MkdirAll(wpaDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wpaDir, "wpa_supplicant-"+nicIface+".conf"), []byte(conf), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Enable via symlink into multi-user.target.wants pointing at the
|
||||
// package-provided template unit.
|
||||
wantsDir := filepath.Join(mountRoot, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, "wpa_supplicant@"+nicIface+".service")
|
||||
_ = os.Remove(link)
|
||||
return os.Symlink("/lib/systemd/system/wpa_supplicant@.service", link)
|
||||
}
|
||||
|
||||
func installBootloader(disk string) error {
|
||||
// grub-install runs in the live environment (not chroot) using the
|
||||
// grub-pc-bin and grub-efi-amd64-bin packages already present on the ISO.
|
||||
// --boot-directory points at the installed system's /boot.
|
||||
bootDir := filepath.Join(mountRoot, "boot")
|
||||
efiDir := filepath.Join(mountRoot, "boot", "efi")
|
||||
|
||||
efiErr := run("grub-install",
|
||||
"--target=x86_64-efi",
|
||||
"--efi-directory="+efiDir,
|
||||
"--boot-directory="+bootDir,
|
||||
"--bootloader-id=spinifex",
|
||||
"--removable",
|
||||
"--recheck",
|
||||
)
|
||||
if efiErr != nil {
|
||||
slog.Warn("installBootloader: EFI install failed", "err", efiErr)
|
||||
}
|
||||
if biosErr := run("grub-install",
|
||||
"--target=i386-pc",
|
||||
"--boot-directory="+bootDir,
|
||||
"--recheck",
|
||||
disk,
|
||||
); biosErr != nil {
|
||||
if efiErr != nil {
|
||||
// Both targets failed — the system will not boot.
|
||||
return fmt.Errorf("both bootloader targets failed (EFI: %v; BIOS: %w)", efiErr, biosErr)
|
||||
}
|
||||
return biosErr
|
||||
}
|
||||
copySplashImage(mountRoot)
|
||||
copyGrubFont(mountRoot)
|
||||
|
||||
// Both consoles listed: tty0 stays primary for local install, ttyS0 mirrors
|
||||
// kernel output to serial so headless installs (CI, racks with serial-only
|
||||
// IPMI, remote-dev SSH-to-QEMU) see boot messages. The last `console=` on
|
||||
// the cmdline becomes the system console, so ttyS0 wins on serial-only
|
||||
// hardware while tty0 still receives output for local display.
|
||||
grubDefault := `GRUB_DEFAULT=0
|
||||
GRUB_TIMEOUT=5
|
||||
GRUB_DISTRIBUTOR=Spinifex
|
||||
GRUB_CMDLINE_LINUX_DEFAULT=""
|
||||
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200 systemd.show_status=1"
|
||||
`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
|
||||
return fmt.Errorf("write /etc/default/grub: %w", err)
|
||||
}
|
||||
|
||||
// Mirror the ISO grub.cfg graphical block exactly so the installed GRUB menu
|
||||
// looks identical to the installer menu. gfxterm MUST be activated before
|
||||
// serial is appended — background_image silently does nothing in text mode.
|
||||
// Using exec tail so update-grub includes everything from line 3 as raw GRUB config.
|
||||
grubTheme := `#!/bin/sh
|
||||
exec tail -n +3 $0
|
||||
insmod all_video
|
||||
insmod font
|
||||
if loadfont /boot/grub/fonts/unicode.pf2; then
|
||||
set gfxmode=auto
|
||||
insmod gfxterm
|
||||
terminal_output gfxterm
|
||||
fi
|
||||
insmod serial
|
||||
if serial --unit=0 --speed=115200 --timeout=1; then
|
||||
terminal_input --append serial
|
||||
terminal_output --append serial
|
||||
fi
|
||||
|
||||
# --- Branding ---
|
||||
insmod png
|
||||
set theme=/boot/grub/theme.txt
|
||||
export theme
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
|
||||
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
|
||||
}
|
||||
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
return run("chroot", mountRoot, "update-grub")
|
||||
}
|
||||
|
||||
func installCACert(cfg *Config) error {
|
||||
if !cfg.HasCACert || cfg.CACert == "" {
|
||||
return nil
|
||||
}
|
||||
certPath := filepath.Join(mountRoot, "usr/local/share/ca-certificates/spinifex-ca.crt")
|
||||
if err := os.WriteFile(certPath, []byte(cfg.CACert), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
return run("chroot", mountRoot, "update-ca-certificates")
|
||||
}
|
||||
|
||||
// promptRemoveUSB prints a removal reminder and waits up to 10 seconds for
|
||||
// the user to press Enter before rebooting. Reading from os.Stdin works because
|
||||
// spinifex-init redirects the installer's stdin from $CONSOLE_DEV.
|
||||
func promptRemoveUSB() {
|
||||
fmt.Println("\n\033[1mInstallation complete.\033[0m")
|
||||
fmt.Println("Remove the USB drive, then press Enter to reboot (auto-rebooting in 10 seconds)...")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func reboot() error {
|
||||
// sync filesystems before reboot so nothing is lost.
|
||||
_ = run("sync")
|
||||
// Use the kernel syscall directly — the live environment runs spinifex-init
|
||||
// as PID 1 (not systemd), so the reboot(8) utility fails trying to reach D-Bus.
|
||||
return syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART)
|
||||
}
|
||||
|
||||
// toFirstbootConfig maps installer Config to the firstboot package's Config.
|
||||
func (c *Config) toFirstbootConfig() firstboot.Config {
|
||||
// Geneve encap IP: use LAN bridge IP when a dedicated LAN NIC is present,
|
||||
// otherwise fall back to WAN bridge IP. Empty for DHCP — setup-ovn.sh
|
||||
// auto-detects the IP from the default route at boot in that case.
|
||||
encapIP := c.WANAddress
|
||||
if c.LANInterface != "" && c.LANAddress != "" {
|
||||
encapIP = c.LANAddress
|
||||
}
|
||||
return firstboot.Config{
|
||||
Hostname: c.Hostname,
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Email: c.Email,
|
||||
}
|
||||
}
|
||||
|
||||
// writeFstab writes /etc/fstab on the installed system using partition UUIDs so
|
||||
// the root filesystem is mounted read-write at boot and the EFI partition is
|
||||
// mounted at /boot/efi.
|
||||
func writeFstab(disk string) error {
|
||||
efi, root := partitionPaths(disk)
|
||||
rootUUID, err := partUUID(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get root UUID: %w", err)
|
||||
}
|
||||
efiUUID, err := partUUID(efi)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get EFI UUID: %w", err)
|
||||
}
|
||||
fstab := fmt.Sprintf("# /etc/fstab — generated by Spinifex installer\nUUID=%s / ext4 errors=remount-ro 0 1\nUUID=%s /boot/efi vfat umask=0077 0 1\n",
|
||||
rootUUID, efiUUID)
|
||||
return os.WriteFile(filepath.Join(mountRoot, "etc/fstab"), []byte(fstab), 0o644)
|
||||
}
|
||||
|
||||
func partUUID(dev string) (string, error) {
|
||||
out, err := exec.Command("blkid", "-s", "UUID", "-o", "value", dev).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("blkid %s: %w", dev, err)
|
||||
}
|
||||
uuid := strings.TrimSpace(string(out))
|
||||
if uuid == "" {
|
||||
return "", fmt.Errorf("blkid returned no UUID for %s — partition may not have a filesystem yet", dev)
|
||||
}
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// partitionPaths returns the EFI and root partition device paths for a given
|
||||
// disk. p1 is the BIOS Boot Partition (no filesystem), p2 is EFI, p3 is root.
|
||||
// Handles both /dev/sdX (→ /dev/sdX2, /dev/sdX3) and /dev/nvmeXnY
|
||||
// (→ /dev/nvmeXnYp2, /dev/nvmeXnYp3).
|
||||
func partitionPaths(disk string) (efi, root string) {
|
||||
// NVMe devices use a 'p' separator before the partition number.
|
||||
if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' {
|
||||
return disk + "p2", disk + "p3"
|
||||
}
|
||||
return disk + "2", disk + "3"
|
||||
}
|
||||
|
||||
// copySplashImage copies the GRUB splash (embedded in the squashfs at build time by
|
||||
// inject-bins.sh) into the installed system so the post-install GRUB shows the same
|
||||
// branded background as the installer GRUB. Non-fatal — missing source is logged and skipped.
|
||||
func copySplashImage(root string) {
|
||||
const src = "/usr/share/spinifex/grub-splash.png"
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
slog.Warn("copySplashImage: splash not found, skipping", "path", src)
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
dstDir := filepath.Join(root, "boot/grub")
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
slog.Warn("copySplashImage: cannot create boot/grub dir", "err", err)
|
||||
return
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dstDir, "splash.png"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
slog.Warn("copySplashImage: cannot open destination", "err", err)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
slog.Warn("copySplashImage: copy failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// copyGrubFont copies the unicode font into the installed system's
|
||||
// /boot/grub/fonts/ so the loadfont path in 05_spinifex resolves at boot.
|
||||
// grub-install does not copy fonts; we mirror what build-iso.sh does.
|
||||
func copyGrubFont(root string) {
|
||||
const src = "/usr/share/grub/unicode.pf2"
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
slog.Warn("copyGrubFont: font not found, graphical GRUB may not work", "path", src)
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
dstDir := filepath.Join(root, "boot/grub/fonts")
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
slog.Warn("copyGrubFont: cannot create fonts dir", "err", err)
|
||||
return
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dstDir, "unicode.pf2"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
slog.Warn("copyGrubFont: cannot open destination", "err", err)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
slog.Warn("copyGrubFont: copy failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maskSystemdUnit creates a symlink to /dev/null for the given unit, which is
|
||||
// the standard way to permanently disable a unit so systemd will never start it.
|
||||
func maskSystemdUnit(root, unit string) error {
|
||||
unitPath := filepath.Join(root, "etc/systemd/system", unit)
|
||||
_ = os.Remove(unitPath)
|
||||
return os.Symlink("/dev/null", unitPath)
|
||||
}
|
||||
|
||||
// setShadowPassword sets a Unix password on the installed system without
|
||||
// going through PAM. See the long comment in installSpinifex for the
|
||||
// rationale.
|
||||
func setShadowPassword(user, password string) error {
|
||||
cmd := exec.Command("chpasswd", "-c", "YESCRYPT", "-R", mountRoot)
|
||||
cmd.Stdin = strings.NewReader(user + ":" + password)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// chrootMountPaths lists virtual filesystems to bind-mount into the chroot.
|
||||
// Order matters: unbind in reverse.
|
||||
var chrootMountPaths = []string{"dev", "proc", "sys"}
|
||||
|
||||
// bindChrootMounts bind-mounts /dev, /proc, and /sys into the installed rootfs
|
||||
// so chroot commands (chpasswd, systemd-machine-id-setup, update-grub) can
|
||||
// access hardware, process info, and entropy sources. Idempotent — already-
|
||||
// mounted paths are skipped.
|
||||
func bindChrootMounts() error {
|
||||
for _, m := range chrootMountPaths {
|
||||
dst := filepath.Join(mountRoot, m)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return fmt.Errorf("create chroot mountpoint /%s: %w", m, err)
|
||||
}
|
||||
if err := run("mount", "--bind", "/"+m, dst); err != nil {
|
||||
return fmt.Errorf("bind-mount /%s into chroot: %w", m, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unbindChrootMounts unmounts the virtual filesystems in reverse order.
|
||||
// Errors are logged but not returned — this is best-effort cleanup.
|
||||
func unbindChrootMounts() {
|
||||
for i := len(chrootMountPaths) - 1; i >= 0; i-- {
|
||||
_ = run("umount", filepath.Join(mountRoot, chrootMountPaths[i]))
|
||||
}
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for a headless autoinstall config on the boot media before
|
||||
// launching the interactive TUI. If found and enabled, run without
|
||||
// any user input then eject the USB and reboot.
|
||||
if cfg, err := autoinstall.Load(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoinstall: %v\n", err)
|
||||
os.Exit(1)
|
||||
} else if cfg != nil {
|
||||
if err := install.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
autoinstall.EjectAndReboot()
|
||||
return
|
||||
}
|
||||
|
||||
// Normal interactive path.
|
||||
ttyPath := detectTTY()
|
||||
|
||||
cfg, err := ui.Run(ttyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "installer: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := install.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// detectTTY returns the TTY device path the installer should attach to.
|
||||
// It first checks the SPINIFEX_CONSOLE environment variable (set by
|
||||
// spinifex-init.sh after console fallback logic), then falls back to parsing
|
||||
// /proc/cmdline. spinifex-init.sh may override the GRUB-set value (e.g. when
|
||||
// GRUB says tty1 but no framebuffer exists, it falls back to ttyS0 and
|
||||
// exports the corrected value).
|
||||
func detectTTY() string {
|
||||
// Prefer the env var — spinifex-init.sh sets this after resolving the
|
||||
// actual console (including headless/framebuffer fallback).
|
||||
if val := os.Getenv("SPINIFEX_CONSOLE"); val != "" && val != "auto" {
|
||||
return "/dev/" + val
|
||||
}
|
||||
|
||||
// Fall back to kernel cmdline for direct invocation without spinifex-init.
|
||||
data, err := os.ReadFile("/proc/cmdline")
|
||||
if err == nil {
|
||||
for param := range strings.FieldsSeq(string(data)) {
|
||||
if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok {
|
||||
val := after
|
||||
if val != "" && val != "auto" {
|
||||
return "/dev/" + val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "/dev/tty1"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package systemd writes systemd unit files and drop-ins into an installed
|
||||
// system root during the Spinifex ISO installation process.
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteLANBridgeUnit installs a non-critical oneshot service that activates
|
||||
// br-lan via networkctl *after* network-online.target. br-lan has
|
||||
// ActivationPolicy=manual in its networkd .network file, so networkd creates
|
||||
// the bridge at boot but does not bring it up automatically. This service
|
||||
// triggers the activation once the WAN path is confirmed online, ensuring a
|
||||
// missing LAN cable or DHCP timeout on the secondary bridge never stalls
|
||||
// the management interface or firstboot.
|
||||
func WriteLANBridgeUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex LAN bridge (non-critical)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/networkctl up br-lan
|
||||
RemainAfterExit=yes
|
||||
# Failure is non-critical — cable unplugged or switch not ready at boot.
|
||||
SuccessExitStatus=0 1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, "spinifex-lan-bridge.service")
|
||||
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale symlink %s: %w", link, err)
|
||||
}
|
||||
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
|
||||
}
|
||||
|
||||
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
|
||||
// runs the firstboot provisioning script on the first real boot after
|
||||
// installation. The script writes /var/lib/spinifex/.firstboot-done on
|
||||
// success; ConditionPathExists=! on that marker condition-skips the unit on
|
||||
// every subsequent boot so it never re-runs (replacing the previous
|
||||
// "disable on EXIT" trap which couldn't distinguish success from failure).
|
||||
func WriteFirstbootUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex first-boot provisioning
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
|
||||
ConditionPathExists=!/var/lib/spinifex/.firstboot-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=HOME=/root
|
||||
ExecStart=/usr/local/bin/spinifex-firstboot.sh
|
||||
RemainAfterExit=yes
|
||||
# On non-zero exit, overwrite /etc/issue with a hint so the operator sees
|
||||
# the failure on the console — the banner unit is never enabled in that
|
||||
# case (firstboot didn't reach its enable --now line), so /etc/issue would
|
||||
# otherwise stay at the Debian default with no signal that anything is wrong.
|
||||
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex firstboot FAILED ***\nLogin as spinifex, then run:\n sudo journalctl -u spinifex-firstboot.service\n\n" > /etc/issue; fi'
|
||||
# Cap total firstboot runtime so a hang in setup-ovn.sh / spx admin init /
|
||||
# ovn-central startup cannot wedge multi-user.target and keep getty from
|
||||
# ever reaching the login prompt.
|
||||
TimeoutStartSec=300s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
|
||||
return os.WriteFile(path, []byte(unit), 0o644)
|
||||
}
|
||||
|
||||
// WriteBannerUnit writes the spinifex-banner.service unit that runs
|
||||
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
|
||||
// Running After=spinifex.target ensures the banner reflects a settled system
|
||||
// state and that the IP check/restart happens once services are already running.
|
||||
//
|
||||
// The unit file is written by the installer but NOT enabled at install time.
|
||||
// firstboot enables it (along with spinifex.target) at the end of its first
|
||||
// run via `systemctl enable --now`. On every subsequent boot both units are
|
||||
// driven by the multi-user.target.wants/ symlinks firstboot installed.
|
||||
//
|
||||
// Note: only After=spinifex.target — no Wants=. A Wants= would implicitly
|
||||
// pull spinifex.target up at boot, which is exactly the race this whole
|
||||
// change is fixing. After= alone is a no-op when the target isn't started,
|
||||
// which is correct on first boot before firstboot has provisioned configs.
|
||||
func WriteBannerUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex console banner and boot health check
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/spx admin banner --boot-check
|
||||
RemainAfterExit=yes
|
||||
# Banner is oneshot; cap it so a stuck boot-check (IP detection, try-restart)
|
||||
# cannot block getty via the spinifex-wait.conf drop-in.
|
||||
TimeoutStartSec=30s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
|
||||
return os.WriteFile(path, []byte(unit), 0o644)
|
||||
}
|
||||
|
||||
// WriteGettyDropIn holds the primary consoles (tty1 and ttyS0) until
|
||||
// spinifex-banner.service completes (when banner is enabled), so the MOTD
|
||||
// banner is visible before the login prompt appears. Drop-ins are scoped to
|
||||
// named instances (getty@tty1, serial-getty@ttyS0) so tty2, tty3, etc.
|
||||
// remain available as rescue terminals.
|
||||
//
|
||||
// Only After=, not Wants=. A Wants= would implicitly start the (un-enabled)
|
||||
// banner on the very first boot, partially undoing the "only firstboot is
|
||||
// enabled on first boot" guarantee. On first boot, banner is not enabled,
|
||||
// so After= is a no-op and login appears immediately with the default
|
||||
// /etc/issue. Once firstboot completes and enables the banner, subsequent
|
||||
// boots have banner enabled at multi-user.target and After= correctly
|
||||
// orders getty after the banner has rendered /etc/issue.
|
||||
func WriteGettyDropIn(root string) error {
|
||||
dropIn := `[Unit]
|
||||
After=spinifex-firstboot.service
|
||||
`
|
||||
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
|
||||
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
|
||||
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableUnit creates the multi-user.target.wants symlink for serviceName,
|
||||
// equivalent to `systemctl enable` for a unit that targets multi-user.target.
|
||||
func EnableUnit(root, serviceName string) error {
|
||||
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, serviceName)
|
||||
target := "/etc/systemd/system/" + serviceName
|
||||
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale symlink %s: %w", link, err)
|
||||
}
|
||||
return os.Symlink(target, link)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// lb-agent runs inside load balancer VMs and manages HAProxy configuration.
|
||||
//
|
||||
// It polls the AWS gateway for config updates and reports health via heartbeats.
|
||||
// All communication uses SigV4-signed HTTP requests.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// lb-agent --lb-id=lb-xxxxx --gateway=https://192.168.1.33:9999 --access-key=AKIA... --secret-key=...
|
||||
//
|
||||
// Flags default to environment variables set by cloud-init:
|
||||
// LB_LB_ID, LB_GATEWAY_URL, LB_ACCESS_KEY, LB_SECRET_KEY, LB_REGION.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/lbagent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
lbID string
|
||||
gatewayURL string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
)
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
flag.StringVar(&lbID, "lb-id", envOrDefault("LB_LB_ID", hostname), "Load balancer ID")
|
||||
flag.StringVar(&gatewayURL, "gateway", os.Getenv("LB_GATEWAY_URL"), "Gateway URL (e.g. https://192.168.1.33:9999)")
|
||||
flag.StringVar(&accessKey, "access-key", os.Getenv("LB_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&secretKey, "secret-key", os.Getenv("LB_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(®ion, "region", envOrDefault("LB_REGION", "us-east-1"), "AWS region for SigV4 signing")
|
||||
flag.Parse()
|
||||
|
||||
if lbID == "" {
|
||||
slog.Error("--lb-id is required (or set LB_LB_ID)")
|
||||
os.Exit(1)
|
||||
}
|
||||
if gatewayURL == "" {
|
||||
slog.Error("--gateway is required (or set LB_GATEWAY_URL)")
|
||||
os.Exit(1)
|
||||
}
|
||||
if accessKey == "" || secretKey == "" {
|
||||
slog.Error("--access-key and --secret-key are required (or set LB_ACCESS_KEY / LB_SECRET_KEY)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("Starting LB agent", "lbId", lbID, "gateway", gatewayURL)
|
||||
|
||||
agent, err := lbagent.New(lbID, gatewayURL, accessKey, secretKey, region)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create agent", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start poll loop in a goroutine — it blocks until Stop is called.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := agent.Start(); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal or fatal error.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
slog.Info("Received signal, shutting down", "signal", sig)
|
||||
case err := <-errCh:
|
||||
slog.Error("Agent error", "err", err)
|
||||
}
|
||||
|
||||
agent.Stop()
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Regression guard: source URL must print for every error kind, not just
|
||||
// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator
|
||||
// which URL to investigate.
|
||||
func TestPrintChecksumError(t *testing.T) {
|
||||
image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
|
||||
const imageFile = "/var/lib/img.tar.xz"
|
||||
const imageName = "debian-12-x86_64"
|
||||
|
||||
errs := []error{
|
||||
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
|
||||
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
|
||||
errors.New("open /x: no such file"),
|
||||
}
|
||||
for _, e := range errs {
|
||||
var buf bytes.Buffer
|
||||
printChecksumError(&buf, imageFile, imageName, image, e)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e)
|
||||
assert.Contains(t, out, imageFile)
|
||||
assert.Contains(t, out, "spx admin images import --name "+imageName+" --force")
|
||||
}
|
||||
}
|
||||
|
||||
// buildRemoteNodes must prefer AdvertiseIP (off-host dial target) and fall
|
||||
// back to BindIP when the peer pre-dates siv-8 and didn't send AdvertiseIP.
|
||||
func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
|
||||
nodes := map[string]formation.NodeInfo{
|
||||
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "203.0.113.1"},
|
||||
"node2": {Name: "node2", BindIP: "10.0.0.2"}, // legacy joiner
|
||||
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "203.0.113.3"},
|
||||
}
|
||||
got := buildRemoteNodes(nodes, "node3")
|
||||
if assert.Len(t, got, 2) {
|
||||
assert.Equal(t, "node1", got[0].Name)
|
||||
assert.Equal(t, "203.0.113.1", got[0].Host, "advertise wins when set")
|
||||
assert.Equal(t, "node2", got[1].Name)
|
||||
assert.Equal(t, "10.0.0.2", got[1].Host, "bind fallback when advertise empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdvertiseIP(t *testing.T) {
|
||||
wan := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{IP: "192.168.1.21"}}
|
||||
noWAN := &admin.DetectedNetwork{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bindIP string
|
||||
advertise string
|
||||
detected *admin.DetectedNetwork
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"single-node default, WAN detected", "0.0.0.0", "", wan, "192.168.1.21", false},
|
||||
{"single-node default, no WAN", "0.0.0.0", "", noWAN, "127.0.0.1", false},
|
||||
{"single-node explicit advertise", "0.0.0.0", "203.0.113.5", wan, "203.0.113.5", false},
|
||||
{"multi-node init, specific bind", "10.11.12.1", "", nil, "10.11.12.1", false},
|
||||
{"multi-node dual-homed override", "10.11.12.1", "203.0.113.5", nil, "203.0.113.5", false},
|
||||
{"loopback-only test", "127.0.0.1", "", noWAN, "127.0.0.1", false},
|
||||
{"invalid advertise flag", "0.0.0.0", "not-an-ip", wan, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := resolveAdvertiseIP(tt.bindIP, tt.advertise, tt.detected)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generated spinifex.toml must include both host (listen, BindIP) and
|
||||
// advertise (off-host dial target, AdvertiseIP) for the local node.
|
||||
func TestSpinifexTomlTemplate_AdvertiseField(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spinifex.toml")
|
||||
settings := admin.ConfigSettings{
|
||||
Node: "node1",
|
||||
Az: "ap-southeast-2a",
|
||||
Port: "4432",
|
||||
Region: "ap-southeast-2",
|
||||
BindIP: "0.0.0.0",
|
||||
AdvertiseIP: "192.168.1.21",
|
||||
AccessKey: "AKIATEST",
|
||||
SecretKey: "SECRET",
|
||||
AccountID: "123456789012",
|
||||
NatsToken: "token",
|
||||
ConfigDir: dir,
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
content := string(data)
|
||||
assert.Contains(t, content, `host = "0.0.0.0"`, "listen address")
|
||||
assert.Contains(t, content, `advertise = "192.168.1.21"`, "off-host dial target")
|
||||
}
|
||||
|
||||
// Empty AdvertiseIP (e.g. loading an existing cluster pre-siv-8) must NOT
|
||||
// render an empty advertise = "" line — downstream fallback to Host kicks in.
|
||||
func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spinifex.toml")
|
||||
settings := admin.ConfigSettings{
|
||||
Node: "node1",
|
||||
Az: "ap-southeast-2a",
|
||||
Port: "4432",
|
||||
Region: "ap-southeast-2",
|
||||
BindIP: "10.11.12.1",
|
||||
AccessKey: "AKIATEST",
|
||||
SecretKey: "SECRET",
|
||||
AccountID: "123456789012",
|
||||
NatsToken: "token",
|
||||
ConfigDir: dir,
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
|
||||
data, _ := os.ReadFile(path)
|
||||
assert.NotContains(t, string(data), "advertise =")
|
||||
}
|
||||
|
||||
// detectedDhcpBindBridge must return the default-route interface name verbatim
|
||||
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
|
||||
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
|
||||
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
|
||||
func TestDetectedDhcpBindBridge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detected *admin.DetectedNetwork
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Linux bridge default route returns bridge name (not 'br-ext')",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
|
||||
want: "br-wan",
|
||||
},
|
||||
{
|
||||
name: "OVS bridge default route returned verbatim",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
|
||||
want: "br-ext",
|
||||
},
|
||||
{
|
||||
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
|
||||
want: "br-wan",
|
||||
},
|
||||
{
|
||||
name: "nil DetectedNetwork",
|
||||
detected: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "DetectedNetwork with nil WAN",
|
||||
detected: &admin.DetectedNetwork{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
|
||||
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
|
||||
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
|
||||
func TestCheckLegacyWanBridgeKey_TOMLRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
node = "node1"
|
||||
[nodes.node1.vpcd]
|
||||
ovn_nb_addr = "tcp:127.0.0.1:6641"
|
||||
wan_bridge = "br-ext"
|
||||
`), 0o644))
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
require.NoError(t, viper.ReadInConfig())
|
||||
|
||||
err := checkLegacyWanBridgeKey("node1", cfgPath)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
|
||||
assert.Contains(t, err.Error(), "wan_bridge")
|
||||
}
|
||||
|
||||
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("SPINIFEX_VPCD_WAN_BRIDGE", "br-ext")
|
||||
|
||||
err := checkLegacyWanBridgeKey("node1", "/tmp/unused.toml")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SPINIFEX_VPCD_WAN_BRIDGE")
|
||||
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
|
||||
}
|
||||
|
||||
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
node = "node1"
|
||||
[nodes.node1.vpcd]
|
||||
ovn_nb_addr = "tcp:127.0.0.1:6641"
|
||||
dhcp_bind_bridge = "br-wan"
|
||||
`), 0o644))
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
require.NoError(t, viper.ReadInConfig())
|
||||
|
||||
assert.NoError(t, checkLegacyWanBridgeKey("node1", cfgPath))
|
||||
}
|
||||
|
||||
// iam.nats_url must not embed a bare 0.0.0.0 — the predastore process dials
|
||||
// its own NATS via loopback when listening on wildcard.
|
||||
func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
|
||||
nodes := []admin.PredastoreNodeConfig{
|
||||
{ID: 1, Host: "10.0.0.1"},
|
||||
{ID: 2, Host: "10.0.0.2"},
|
||||
{ID: 3, Host: "10.0.0.3"},
|
||||
}
|
||||
content, err := admin.GenerateMultiNodePredastoreConfig(
|
||||
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
|
||||
assert.NotContains(t, content, `nats_url = "nats://0.0.0.0:4222"`)
|
||||
|
||||
// Specific bind IP → stays as-is.
|
||||
content2, err := admin.GenerateMultiNodePredastoreConfig(
|
||||
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var upgradeCmd = &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Apply pending config migrations",
|
||||
Long: `Apply pending configuration file migrations for upgrades between Spinifex versions.
|
||||
|
||||
When run interactively (without --yes), shows pending migrations and prompts
|
||||
for confirmation. When called from setup.sh with --yes, applies immediately.
|
||||
|
||||
Operators can skip migrations during install by setting INSTALL_SPINIFEX_SKIP_MIGRATE=1,
|
||||
then run 'spx admin upgrade' manually to review and apply.`,
|
||||
Run: runAdminUpgrade,
|
||||
}
|
||||
|
||||
func runAdminUpgrade(cmd *cobra.Command, _ []string) {
|
||||
configDir, _ := cmd.Root().Flags().GetString("config-dir")
|
||||
dataDir, _ := cmd.Root().Flags().GetString("spinifex-dir")
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
|
||||
// Check that the installation exists.
|
||||
spinifexToml := configDir + "/spinifex.toml"
|
||||
if _, err := os.Stat(spinifexToml); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "No Spinifex installation found at %s\nRun 'spx admin init' first.\n", configDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Show current versions.
|
||||
versions, err := migrate.DefaultRegistry.ConfigVersions(configDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading config versions: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Reading current config versions...")
|
||||
for name, v := range versions {
|
||||
if v == 0 {
|
||||
fmt.Printf(" %-20s %d (no version marker)\n", name+":", v)
|
||||
} else {
|
||||
fmt.Printf(" %-20s %d\n", name+":", v)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Check for pending migrations.
|
||||
pending, err := migrate.DefaultRegistry.PendingConfig(configDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking pending migrations: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
fmt.Println("No pending config migrations.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Pending config migrations:")
|
||||
for _, p := range pending {
|
||||
fmt.Printf(" [config] %s %d → %d: %s\n", p.Target, p.FromVersion, p.ToVersion, p.Description)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Prompt for confirmation unless --yes.
|
||||
if !yes {
|
||||
fmt.Print("Apply? [y/N] ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply migrations.
|
||||
if err := migrate.DefaultRegistry.RunAllConfig(configDir, dataDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\nDone. Restart services to apply: sudo systemctl restart spinifex.target")
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/*
|
||||
Copyright © 2025 Mulga Defense Corporation
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
@@ -21,9 +22,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -75,14 +78,20 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
|
||||
}
|
||||
|
||||
// Backfill BaseDir from config file path when not set in the config.
|
||||
// Config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
|
||||
if nodeConfig, ok := cfg.Nodes[cfg.Node]; ok && nodeConfig.BaseDir == "" {
|
||||
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
|
||||
if isProductionLayout() {
|
||||
// Production: BaseDir is /var/lib/spinifex (data dir).
|
||||
// /var/lib/spinifex/config is a symlink to /etc/spinifex.
|
||||
nodeConfig.BaseDir = DefaultDataDir()
|
||||
} else {
|
||||
// Dev: config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
|
||||
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
|
||||
}
|
||||
cfg.Nodes[cfg.Node] = nodeConfig
|
||||
}
|
||||
|
||||
nodeConfig := cfg.Nodes[cfg.Node]
|
||||
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
|
||||
nc, err := utils.ConnectNATS(admin.DialTarget(nodeConfig.NATS.Host), nodeConfig.NATS.ACL.Token, nodeConfig.NATS.CACert)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
}
|
||||
@@ -138,6 +147,20 @@ func formatUptime(seconds int64) string {
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
|
||||
func formatRoles(resp types.NodeStatusResponse) string {
|
||||
var roles []string
|
||||
if resp.NATSRole != "" {
|
||||
roles = append(roles, "nats:"+resp.NATSRole)
|
||||
}
|
||||
if resp.PredastoreRole != "" {
|
||||
roles = append(roles, "predastore:"+resp.PredastoreRole)
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(roles, ",")
|
||||
}
|
||||
|
||||
func formatMemGB(gb float64) string {
|
||||
if gb >= 1.0 {
|
||||
return fmt.Sprintf("%.1fGi", gb)
|
||||
@@ -172,7 +195,7 @@ func runGetNodes(cmd *cobra.Command, args []string) {
|
||||
|
||||
// Build table: union of config-known nodes + NATS responders
|
||||
tableData := pterm.TableData{
|
||||
{"NAME", "STATUS", "IP", "REGION", "AZ", "UPTIME", "VMs", "SERVICES"},
|
||||
{"NAME", "STATUS", "ROLES", "IP", "REGION", "AZ", "UPTIME", "VMs", "SERVICES"},
|
||||
}
|
||||
|
||||
// Collect all node names: config + responded (union)
|
||||
@@ -195,17 +218,19 @@ func runGetNodes(cmd *cobra.Command, args []string) {
|
||||
tableData = append(tableData, []string{
|
||||
resp.Node,
|
||||
resp.Status,
|
||||
formatRoles(resp),
|
||||
resp.Host,
|
||||
resp.Region,
|
||||
resp.AZ,
|
||||
formatUptime(resp.Uptime),
|
||||
fmt.Sprintf("%d", resp.VMCount),
|
||||
strconv.Itoa(resp.VMCount),
|
||||
strings.Join(resp.Services, ","),
|
||||
})
|
||||
} else {
|
||||
tableData = append(tableData, []string{
|
||||
name,
|
||||
"NotReady",
|
||||
"-",
|
||||
nodeCfg.Host,
|
||||
nodeCfg.Region,
|
||||
nodeCfg.AZ,
|
||||
@@ -278,7 +303,7 @@ func runGetVMs(cmd *cobra.Command, args []string) {
|
||||
v.InstanceID,
|
||||
v.Status,
|
||||
v.InstanceType,
|
||||
fmt.Sprintf("%d", v.VCPU),
|
||||
strconv.Itoa(v.VCPU),
|
||||
formatMemGB(v.MemoryGB),
|
||||
v.Node,
|
||||
v.Host,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatRoles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resp types.NodeStatusResponse
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no roles",
|
||||
resp: types.NodeStatusResponse{},
|
||||
want: "-",
|
||||
},
|
||||
{
|
||||
name: "nats leader only",
|
||||
resp: types.NodeStatusResponse{NATSRole: "leader"},
|
||||
want: "nats:leader",
|
||||
},
|
||||
{
|
||||
name: "predastore follower only",
|
||||
resp: types.NodeStatusResponse{PredastoreRole: "follower"},
|
||||
want: "predastore:follower",
|
||||
},
|
||||
{
|
||||
name: "both roles",
|
||||
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "follower"},
|
||||
want: "nats:leader,predastore:follower",
|
||||
},
|
||||
{
|
||||
name: "both leaders",
|
||||
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "leader"},
|
||||
want: "nats:leader,predastore:leader",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatRoles(tt.resp)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@ package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultConfigDir returns the default configuration directory.
|
||||
// Production: /etc/spinifex (when running as root or /etc/spinifex exists)
|
||||
// Production: /etc/spinifex (when /etc/spinifex exists)
|
||||
// Development: ~/spinifex/config
|
||||
func DefaultConfigDir() string {
|
||||
if isProductionLayout() {
|
||||
return "/etc/spinifex"
|
||||
}
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, "spinifex", "config")
|
||||
return filepath.Join(realUserHomeDir(), "spinifex", "config")
|
||||
}
|
||||
|
||||
// DefaultDataDir returns the default data directory.
|
||||
@@ -23,8 +23,30 @@ func DefaultDataDir() string {
|
||||
if isProductionLayout() {
|
||||
return "/var/lib/spinifex"
|
||||
}
|
||||
return filepath.Join(realUserHomeDir(), "spinifex")
|
||||
}
|
||||
|
||||
// LogDirFor returns the log directory for a given data directory.
|
||||
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
|
||||
// Development: <dataDir>/logs (supports custom per-node data dirs)
|
||||
func LogDirFor(dataDir string) string {
|
||||
if isProductionLayout() {
|
||||
return "/var/log/spinifex"
|
||||
}
|
||||
return filepath.Join(dataDir, "logs")
|
||||
}
|
||||
|
||||
// realUserHomeDir returns the home directory of the real (non-sudo) user.
|
||||
// When running under sudo, SUDO_USER is set to the invoking user — resolve
|
||||
// their home directory so config/data land in the right place.
|
||||
func realUserHomeDir() string {
|
||||
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||
if u, err := user.Lookup(sudoUser); err == nil {
|
||||
return u.HomeDir
|
||||
}
|
||||
}
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, "spinifex")
|
||||
return homeDir
|
||||
}
|
||||
|
||||
// DefaultConfigFile returns the default path to spinifex.toml.
|
||||
@@ -32,13 +54,16 @@ func DefaultConfigFile() string {
|
||||
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
|
||||
}
|
||||
|
||||
// productionMarkerPath identifies a production install by directory presence.
|
||||
// Created by setup.sh during binary install. Overridable in tests.
|
||||
var productionMarkerPath = "/etc/spinifex"
|
||||
|
||||
// isProductionLayout returns true when running in a production install.
|
||||
// Detected by: running as root, or /etc/spinifex directory exists.
|
||||
// No root check — allows non-root users (e.g. tf-user) to run CLI commands
|
||||
// like `spx get nodes` without sudo or --config flags.
|
||||
func isProductionLayout() bool {
|
||||
if os.Getuid() == 0 {
|
||||
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
if info, err := os.Stat(productionMarkerPath); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,57 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDefaultConfigDir_Dev(t *testing.T) {
|
||||
// Non-root user without /etc/spinifex should get dev paths
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex", "config")
|
||||
got := DefaultConfigDir()
|
||||
if got != expected {
|
||||
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
|
||||
}
|
||||
// withProductionMarker temporarily overrides the marker path used to detect a
|
||||
// production install. Tests that pass an empty string force "dev" mode by
|
||||
// pointing at a non-existent path; tests that pass t.TempDir() force
|
||||
// "production" mode by pointing at a real directory.
|
||||
func withProductionMarker(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
orig := productionMarkerPath
|
||||
productionMarkerPath = path
|
||||
t.Cleanup(func() { productionMarkerPath = orig })
|
||||
}
|
||||
|
||||
func TestDefaultDataDir_Dev(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex")
|
||||
got := DefaultDataDir()
|
||||
if got != expected {
|
||||
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
|
||||
}
|
||||
func TestIsProductionLayout(t *testing.T) {
|
||||
t.Run("absent marker returns false", func(t *testing.T) {
|
||||
withProductionMarker(t, filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
assert.False(t, isProductionLayout())
|
||||
})
|
||||
t.Run("present marker returns true", func(t *testing.T) {
|
||||
withProductionMarker(t, t.TempDir())
|
||||
assert.True(t, isProductionLayout())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultConfigFile(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
func TestDefaultPaths(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("SUDO_USER", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
productionMode bool
|
||||
wantConfigDir string
|
||||
wantDataDir string
|
||||
wantConfigFile string
|
||||
wantLogDir string // for LogDirFor("/data/dir")
|
||||
}{
|
||||
{
|
||||
name: "dev layout",
|
||||
productionMode: false,
|
||||
wantConfigDir: filepath.Join(homeDir, "spinifex", "config"),
|
||||
wantDataDir: filepath.Join(homeDir, "spinifex"),
|
||||
wantConfigFile: filepath.Join(homeDir, "spinifex", "config", "spinifex.toml"),
|
||||
wantLogDir: "/data/dir/logs",
|
||||
},
|
||||
{
|
||||
name: "production layout",
|
||||
productionMode: true,
|
||||
wantConfigDir: "/etc/spinifex",
|
||||
wantDataDir: "/var/lib/spinifex",
|
||||
wantConfigFile: "/etc/spinifex/spinifex.toml",
|
||||
wantLogDir: "/var/log/spinifex",
|
||||
},
|
||||
}
|
||||
|
||||
got := DefaultConfigFile()
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
|
||||
if got != expected {
|
||||
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProductionLayout_NonRoot(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("test requires non-root user")
|
||||
}
|
||||
|
||||
if isProductionLayout() {
|
||||
t.Error("isProductionLayout() = true for non-root user, want false")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.productionMode {
|
||||
withProductionMarker(t, t.TempDir())
|
||||
} else {
|
||||
withProductionMarker(t, filepath.Join(t.TempDir(), "absent"))
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantConfigDir, DefaultConfigDir())
|
||||
assert.Equal(t, tt.wantDataDir, DefaultDataDir())
|
||||
assert.Equal(t, tt.wantConfigFile, DefaultConfigFile())
|
||||
assert.Equal(t, tt.wantLogDir, LogDirFor("/data/dir"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/*
|
||||
Copyright © 2025 Mulga Defense Corporation
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/*
|
||||
Copyright © 2025 Mulga Defense Corporation
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
@@ -21,9 +22,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/predastore/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/service"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/nats"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/predastore"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
|
||||
@@ -114,15 +115,6 @@ var predastoreStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
backendType := viper.GetString("backend")
|
||||
var backend s3.BackendType
|
||||
|
||||
if backendType == "distributed" {
|
||||
backend = s3.BackendDistributed
|
||||
} else {
|
||||
backend = s3.BackendFilesystem
|
||||
}
|
||||
|
||||
nodeID := viper.GetInt("node-id")
|
||||
pprofEnabled := viper.GetBool("pprof")
|
||||
pprofOutput := viper.GetString("pprof-output")
|
||||
@@ -136,8 +128,7 @@ var predastoreStartCmd = &cobra.Command{
|
||||
TlsCert: tlsCert,
|
||||
TlsKey: tlsKey,
|
||||
|
||||
Backend: backend,
|
||||
NodeID: nodeID,
|
||||
NodeID: nodeID,
|
||||
|
||||
PprofEnabled: pprofEnabled,
|
||||
PprofOutputPath: pprofOutput,
|
||||
@@ -243,13 +234,13 @@ var viperblockStartCmd = &cobra.Command{
|
||||
|
||||
accessKey := viper.GetString("access-key")
|
||||
if accessKey != "" {
|
||||
fmt.Println("Overwriting access-key:", accessKey)
|
||||
fmt.Println("Overwriting access-key: ****")
|
||||
nodeConfig.Predastore.AccessKey = accessKey
|
||||
}
|
||||
|
||||
secretKey := viper.GetString("secret-key")
|
||||
if secretKey != "" {
|
||||
fmt.Println("Overwriting secret-key:", secretKey)
|
||||
fmt.Println("Overwriting secret-key: ****")
|
||||
nodeConfig.Predastore.SecretKey = secretKey
|
||||
}
|
||||
|
||||
@@ -285,6 +276,8 @@ var viperblockStartCmd = &cobra.Command{
|
||||
|
||||
service, err := service.New("viperblock", &viperblockd.Config{
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
NatsCACert: nodeConfig.NATS.CACert,
|
||||
PluginPath: pluginPath,
|
||||
S3Host: nodeConfig.Predastore.Host,
|
||||
Bucket: nodeConfig.Predastore.Bucket,
|
||||
@@ -308,7 +301,7 @@ var viperblockStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Viperblock service started", service)
|
||||
fmt.Println("Viperblock service started")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -556,6 +549,7 @@ var awsgwStartCmd = &cobra.Command{
|
||||
// Apply changes back to cluster config
|
||||
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
|
||||
|
||||
awsgw.SetBuildInfo(Version, Commit)
|
||||
service, err := service.New("awsgw", clusterConfig)
|
||||
|
||||
if err != nil {
|
||||
@@ -674,6 +668,32 @@ var spinifexUIStatusCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
|
||||
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. The key was
|
||||
// renamed to `dhcp_bind_bridge` (see mulga-998.a) because the old name misled
|
||||
// operators into pointing at the OVN-side bridge ("br-ext"), which never sees
|
||||
// LAN DHCP traffic. Per D3: no silent alias, no auto-rewrite — operator must
|
||||
// rename the key before vpcd will start.
|
||||
func checkLegacyWanBridgeKey(node, cfgFile string) error {
|
||||
legacyInTOML := viper.IsSet("nodes." + node + ".vpcd.wan_bridge")
|
||||
legacyInEnv := os.Getenv("SPINIFEX_VPCD_WAN_BRIDGE") != ""
|
||||
if !legacyInTOML && !legacyInEnv {
|
||||
return nil
|
||||
}
|
||||
source := cfgFile
|
||||
if legacyInEnv {
|
||||
source = "env SPINIFEX_VPCD_WAN_BRIDGE"
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"vpcd: deprecated 'wan_bridge' key found in %s. "+
|
||||
"Rename 'wan_bridge' to 'dhcp_bind_bridge'. The value may also be wrong — verify it is "+
|
||||
"the Linux bridge holding your WAN NIC (veth mode) or the OVS bridge holding your WAN NIC (direct mode); "+
|
||||
"never 'br-ext'. Typical value on a consumer-router LAN: 'br-wan'. "+
|
||||
"Then: sudo systemctl restart spinifex-vpcd",
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
var vpcdStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the vpcd service",
|
||||
@@ -692,15 +712,65 @@ var vpcdStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
if err := checkLegacyWanBridgeKey(clusterConfig.Node, cfgFile); err != nil {
|
||||
slog.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
|
||||
|
||||
// Map cluster-wide external pools to vpcd config
|
||||
var extPools []vpcd.ExternalPoolConfig
|
||||
for _, p := range clusterConfig.Network.ExternalPools {
|
||||
extPools = append(extPools, vpcd.ExternalPoolConfig{
|
||||
Name: p.Name,
|
||||
Source: p.Source,
|
||||
RangeStart: p.RangeStart,
|
||||
RangeEnd: p.RangeEnd,
|
||||
Gateway: p.Gateway,
|
||||
GatewayIP: p.GatewayIP,
|
||||
PrefixLen: p.PrefixLen,
|
||||
DNSServers: p.DNSServers,
|
||||
Region: p.Region,
|
||||
AZ: p.AZ,
|
||||
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
|
||||
GwLrpRangeStart: p.GwLrpRangeStart,
|
||||
GwLrpRangeEnd: p.GwLrpRangeEnd,
|
||||
})
|
||||
}
|
||||
|
||||
var bootstrap *vpcd.BootstrapVPC
|
||||
if clusterConfig.Bootstrap.VpcId != "" {
|
||||
bootstrap = &vpcd.BootstrapVPC{
|
||||
AccountID: clusterConfig.Bootstrap.AccountID,
|
||||
VpcId: clusterConfig.Bootstrap.VpcId,
|
||||
SubnetId: clusterConfig.Bootstrap.SubnetId,
|
||||
IgwId: clusterConfig.Bootstrap.IgwId,
|
||||
Cidr: clusterConfig.Bootstrap.Cidr,
|
||||
SubnetCidr: clusterConfig.Bootstrap.SubnetCidr,
|
||||
}
|
||||
}
|
||||
|
||||
baseDir := viper.GetString("base-dir")
|
||||
if baseDir != "" {
|
||||
fmt.Println("Overwriting vpcd base-dir to:", baseDir)
|
||||
nodeConfig.BaseDir = baseDir
|
||||
}
|
||||
|
||||
svc, err := service.New("vpcd", &vpcd.Config{
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
|
||||
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
|
||||
BaseDir: nodeConfig.BaseDir,
|
||||
Debug: false,
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
NatsCACert: nodeConfig.NATS.CACert,
|
||||
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
|
||||
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
|
||||
BaseDir: nodeConfig.BaseDir,
|
||||
Debug: false,
|
||||
ExternalMode: clusterConfig.Network.ExternalMode,
|
||||
ExternalPools: extPools,
|
||||
Bootstrap: bootstrap,
|
||||
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
|
||||
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
|
||||
BridgeMode: nodeConfig.VPCD.BridgeMode,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("Error starting vpcd service:", err)
|
||||
@@ -814,7 +884,7 @@ func init() {
|
||||
|
||||
serviceCmd.AddCommand(viperblockCmd)
|
||||
|
||||
viperblockCmd.PersistentFlags().String("s3-host", "0.0.0.0:8443", "Predastore (S3) host URI")
|
||||
viperblockCmd.PersistentFlags().String("s3-host", "", "Predastore (S3) host URI")
|
||||
viper.BindEnv("s3-host", "SPINIFEX_VIPERBLOCK_S3_HOST")
|
||||
viper.BindPFlag("s3-host", predastoreCmd.PersistentFlags().Lookup("s3-host"))
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
# AWS Gateway configuration file in TOML format.
|
||||
version = "1.0"
|
||||
version = "2"
|
||||
region = "{{.Region}}"
|
||||
|
||||
# Enable verbose debugging
|
||||
debug = false
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 20 # Default sustained requests per second per account per action
|
||||
burst = 100 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action.RunInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.TerminateInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.StartInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.StopInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# spinifex-config-version: 3
|
||||
# NATS Server Configuration
|
||||
# Generated by Spinifex admin init
|
||||
|
||||
@@ -8,15 +9,28 @@ listen: {{.BindIP}}:4222
|
||||
listen: 0.0.0.0:4222
|
||||
{{- end }}
|
||||
|
||||
tls {
|
||||
cert_file: "{{.ConfigDir}}/server.pem"
|
||||
key_file: "{{.ConfigDir}}/server.key"
|
||||
ca_file: "{{.ConfigDir}}/ca.pem"
|
||||
}
|
||||
|
||||
{{- if and .ClusterBindIP .ClusterRoutes }}
|
||||
|
||||
cluster {
|
||||
name: {{ .ClusterName }}
|
||||
listen: {{.ClusterBindIP}}:4248
|
||||
|
||||
tls {
|
||||
cert_file: "{{.ConfigDir}}/server.pem"
|
||||
key_file: "{{.ConfigDir}}/server.key"
|
||||
ca_file: "{{.ConfigDir}}/ca.pem"
|
||||
verify: true
|
||||
}
|
||||
|
||||
routes = [
|
||||
{{- range .ClusterRoutes }}
|
||||
"nats://{{ . }}",
|
||||
"nats-route://{{$.NatsToken}}@{{ . }}",
|
||||
{{- end }}
|
||||
]
|
||||
}
|
||||
@@ -26,7 +40,7 @@ server_name: spinifex-nats-{{.Node}}
|
||||
|
||||
# Authorization
|
||||
authorization {
|
||||
# token: "{{.NatsToken}}"
|
||||
token: "{{.NatsToken}}"
|
||||
}
|
||||
|
||||
# JetStream for persistent messaging and KV store
|
||||
@@ -40,7 +54,7 @@ jetstream {
|
||||
debug: false
|
||||
trace: false
|
||||
logtime: true
|
||||
log_file: "{{.DataDir}}/logs/nats.log"
|
||||
log_file: "{{.LogDir}}/nats.log"
|
||||
|
||||
# Limits
|
||||
max_connections: 1000
|
||||
@@ -48,9 +62,5 @@ max_payload: 1MB
|
||||
max_pending: 64MB
|
||||
|
||||
# Monitoring
|
||||
{{- if .BindIP }}
|
||||
http: {{.BindIP}}:8222
|
||||
{{- else }}
|
||||
http_port: 8222
|
||||
{{- end }}
|
||||
http: 127.0.0.1:8222
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Predastore S3-compatible storage configuration file in TOML format.
|
||||
# Generated for multi-node cluster
|
||||
version = "1.0"
|
||||
version = "3"
|
||||
region = "{{.Region}}"
|
||||
|
||||
host = "0.0.0.0"
|
||||
@@ -47,9 +47,24 @@ policy = [
|
||||
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
|
||||
]
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 100 # Default sustained requests per second per account per action
|
||||
burst = 500 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action."s3:PutObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
[ratelimit.action."s3:DeleteObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
# IAM authentication via NATS KV (enables multi-account S3 access)
|
||||
[iam]
|
||||
nats_url = "nats://{{.BindIP}}:4222"
|
||||
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
|
||||
nats_token = "{{.NatsToken}}"
|
||||
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
|
||||
master_key_path = "{{.ConfigDir}}/master.key"
|
||||
access_keys_bucket = "spinifex-iam-access-keys"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Predastore S3-compatible storage configuration file in TOML format.
|
||||
# MULTI NODE used for testing
|
||||
version = "1.0"
|
||||
version = "3"
|
||||
region = "{{.Region}}"
|
||||
|
||||
# Optionally configure port and host, or specify via daemon CLI options
|
||||
@@ -81,9 +81,24 @@ policy = [
|
||||
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
|
||||
]
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 100 # Default sustained requests per second per account per action
|
||||
burst = 500 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action."s3:PutObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
[ratelimit.action."s3:DeleteObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
# IAM authentication via NATS KV (enables multi-account S3 access)
|
||||
[iam]
|
||||
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
|
||||
nats_token = "{{.NatsToken}}"
|
||||
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
|
||||
master_key_path = "{{.ConfigDir}}/master.key"
|
||||
access_keys_bucket = "spinifex-iam-access-keys"
|
||||
@@ -1,11 +1,55 @@
|
||||
# Domain configuration file in TOML format.
|
||||
version = "1.0"
|
||||
version = "2"
|
||||
epoch = 1
|
||||
node = "{{.Node}}"
|
||||
{{- if .OperatorEmail}}
|
||||
|
||||
[operator]
|
||||
email = "{{.OperatorEmail}}"
|
||||
{{- end}}
|
||||
{{- if .ExternalMode}}
|
||||
|
||||
[network]
|
||||
external_mode = "{{.ExternalMode}}"
|
||||
{{- if .ExternalDHCP}}
|
||||
external_dhcp = true
|
||||
{{- end}}
|
||||
|
||||
[[network.external_pools]]
|
||||
name = "{{.PoolName}}"
|
||||
{{- if .PoolSource}}
|
||||
source = "{{.PoolSource}}"
|
||||
{{- end}}
|
||||
{{- if .PoolStart}}
|
||||
range_start = "{{.PoolStart}}"
|
||||
range_end = "{{.PoolEnd}}"
|
||||
{{- end}}
|
||||
gateway = "{{.PoolGateway}}"
|
||||
{{- if .PoolGatewayIP}}
|
||||
gateway_ip = "{{.PoolGatewayIP}}"
|
||||
{{- end}}
|
||||
prefix_len = {{if .PoolPrefixLen}}{{.PoolPrefixLen}}{{else}}24{{end}}
|
||||
{{- if .PoolDNSServers}}
|
||||
dns_servers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .BootstrapVpcId}}
|
||||
|
||||
[bootstrap]
|
||||
account_id = "{{.BootstrapAccountId}}"
|
||||
vpc_id = "{{.BootstrapVpcId}}"
|
||||
subnet_id = "{{.BootstrapSubnetId}}"
|
||||
igw_id = "{{.BootstrapIgwId}}"
|
||||
cidr = "{{.BootstrapCidr}}"
|
||||
subnet_cidr = "{{.BootstrapSubnetCidr}}"
|
||||
{{- end}}
|
||||
|
||||
[nodes.{{.Node}}]
|
||||
node = "{{.Node}}"
|
||||
host = "{{.BindIP}}"
|
||||
{{- if .AdvertiseIP}}
|
||||
advertise = "{{.AdvertiseIP}}"
|
||||
{{- end}}
|
||||
region = "{{.Region}}"
|
||||
az = "{{.Az}}"
|
||||
{{- if .Services}}
|
||||
@@ -18,7 +62,11 @@ account_id = "{{.AccountID}}"
|
||||
host = "{{.BindIP}}:{{.Port}}"
|
||||
tlskey = "config/server.key"
|
||||
tlscert = "config/server.pem"
|
||||
{{- if .ExternalMode}}
|
||||
dev_networking = false
|
||||
{{- else}}
|
||||
dev_networking = true
|
||||
{{- end}}
|
||||
|
||||
[nodes.{{.Node}}.awsgw]
|
||||
host = "{{.BindIP}}:9999"
|
||||
@@ -29,6 +77,7 @@ config = "awsgw/awsgw.toml"
|
||||
|
||||
[nodes.{{.Node}}.nats]
|
||||
host = "{{.BindIP}}:4222"
|
||||
cacert = "{{.ConfigDir}}/ca.pem"
|
||||
|
||||
[nodes.{{.Node}}.nats.acl]
|
||||
token = "{{.NatsToken}}"
|
||||
@@ -41,6 +90,18 @@ shardwal = false
|
||||
[nodes.{{.Node}}.vpcd]
|
||||
ovn_nb_addr = "{{.OVNNBAddr}}"
|
||||
ovn_sb_addr = "{{.OVNSBAddr}}"
|
||||
{{- if .ExternalIface}}
|
||||
external_interface = "{{.ExternalIface}}"
|
||||
{{- end}}
|
||||
{{- if .DhcpBindBridge}}
|
||||
# dhcp_bind_bridge: the bridge where vpcd's DHCP client binds its AF_PACKET
|
||||
# socket. On Linux-bridge-WAN hosts (netplan default with the WAN NIC enslaved),
|
||||
# this is the Linux bridge name (e.g. "br-wan"). On direct-OVS hosts, this is
|
||||
# the OVS bridge holding the WAN NIC.
|
||||
# NEVER set this to "br-ext" — that is the OVN-side bridge and never sees LAN
|
||||
# DHCP traffic.
|
||||
dhcp_bind_bridge = "{{.DhcpBindBridge}}"
|
||||
{{- end}}
|
||||
|
||||
[nodes.{{.Node}}.predastore]
|
||||
host = "{{.BindIP}}:8443"
|
||||
@@ -61,4 +122,4 @@ az = "{{.AZ}}"
|
||||
{{- if .Services}}
|
||||
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/*
|
||||
Copyright © 2025 Mulga Defense Corporation
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
@@ -98,7 +100,7 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
resp.Node,
|
||||
fmt.Sprintf("%d/%d", resp.AllocVCPU, resp.TotalVCPU),
|
||||
fmt.Sprintf("%s/%s", formatMemGB(resp.AllocMemGB), formatMemGB(resp.TotalMemGB)),
|
||||
fmt.Sprintf("%d", resp.VMCount),
|
||||
strconv.Itoa(resp.VMCount),
|
||||
})
|
||||
|
||||
for _, cap := range resp.InstanceTypes {
|
||||
@@ -145,8 +147,8 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
agg := capacityMap[name]
|
||||
capTable = append(capTable, []string{
|
||||
name,
|
||||
fmt.Sprintf("%d", agg.Available),
|
||||
fmt.Sprintf("%d", agg.VCPU),
|
||||
strconv.Itoa(agg.Available),
|
||||
strconv.Itoa(agg.VCPU),
|
||||
formatMemGB(agg.MemoryGB),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionVarsExist(t *testing.T) {
|
||||
// Verify the version variables are set (defaults when not injected via ldflags)
|
||||
if Version == "" {
|
||||
t.Error("Version should not be empty")
|
||||
}
|
||||
if Commit == "" {
|
||||
t.Error("Commit should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
// Verify the version command is registered
|
||||
found := false
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if cmd.Use == "version" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("version command not registered on rootCmd")
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/*
|
||||
Copyright © 2025 Mulga Defense Corporation
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Linux
|
||||
#genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
|
||||
|
||||
# MacOS
|
||||
mkisofs -output seed.iso -volid cidata -joliet -rock meta-data user-data
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Example to launch nbdkit
|
||||
|
||||
./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-50c968e8dbcc74bd2-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
qemu-system-aarch64 \
|
||||
-nographic \
|
||||
-M virt,highmem=off \
|
||||
-accel hvf \
|
||||
-cpu host \
|
||||
-smp 4 \
|
||||
-m 3000 \
|
||||
-drive file=/Users/benduncan/qemu/QEMU_EFI.img,if=pflash,format=raw \
|
||||
-device virtio-blk-pci,drive=debian,bootindex=1 \
|
||||
-drive if=none,media=disk,id=debian,format=raw,file=nbd://192.168.64.5:10809/default \
|
||||
-drive format=raw,file=nbd://192.168.64.5:10810/default,if=pflash \
|
||||
-drive file=nbd://192.168.64.5:10811/default,format=raw,if=virtio \
|
||||
-device qemu-xhci \
|
||||
-device usb-kbd \
|
||||
-device usb-tablet \
|
||||
-device intel-hda \
|
||||
-device hda-duplex \
|
||||
-netdev user,id=net0,hostfwd=tcp::2222-:22 -device virtio-net-device,netdev=net0 \
|
||||
-device virtio-rng-pci
|
||||
|
||||
|
||||
# ./server/nbdkit -p 10809 --pidfile /tmp/vb-vol-1.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=4294967296 volume=vol-e660ff0065ad21f74 bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=20
|
||||
|
||||
# ./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-e660ff0065ad21f74-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
|
||||
|
||||
# ./server/nbdkit -p 10811 --pidfile /tmp/vb-vol-1-cloudinit.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=1048576 volume=vol-e660ff0065ad21f74-cloudinit bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
|
||||
@@ -13,7 +13,7 @@ Service lifecycle commands for starting, stopping, and checking status of all Sp
|
||||
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
|
||||
| `spx service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
|
||||
| `spx service predastore status` | — | None | Reports predastore service status | **DONE** |
|
||||
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (default: /opt/spinifex/lib/nbdkit-viperblock-plugin.so) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
|
||||
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
|
||||
| `spx service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
|
||||
| `spx service viperblock status` | — | None | Reports viperblock service status | **DONE** |
|
||||
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
|
||||
@@ -51,14 +51,32 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured | **DONE** |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error) | **DONE** |
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured<br>7. Multi-node init generates join token and writes to `<config-dir>/join-token` | **DONE** |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error)<br>4. Missing token (error)<br>5. Invalid token (401)<br>6. Expired token (401) | **DONE** |
|
||||
|
||||
### Version
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
|
||||
|
||||
### Cluster Operations
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cluster shutdown` | — | Cluster must be running | Gracefully shuts down the entire cluster | 1. Shutdown running cluster<br>2. All nodes stop cleanly | **DONE** |
|
||||
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
|
||||
|
||||
### Certificate Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
|
||||
|
||||
### Upgrade Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Existing installation (`spinifex.toml` must exist in config dir) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. | 1. No installation present (error, suggests `spx admin init`)<br>2. No pending migrations (prints "No pending config migrations")<br>3. Pending migrations with interactive confirmation (y/yes applies, anything else aborts)<br>4. Pending migrations with `--yes` (applies without prompt)<br>5. Migration failure surfaces error and exits non-zero | **DONE** |
|
||||
|
||||
### Account Management
|
||||
|
||||
@@ -71,24 +89,57 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
|
||||
| `spx admin images import` | `--name`, `--file`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, and verify the SHA-256/SHA-512 digest before extraction. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip verification — operator-supplied media is outside Spinifex's trust boundary, and the skip is logged at INFO for audit. `--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. | 1. Import valid catalog image (verifies checksum)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>5. `--skip-verify` bypasses checksum with WARN | **DONE** |
|
||||
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
|
||||
|
||||
#### Image integrity verification (CMMC SI.L1-3.14.2)
|
||||
|
||||
Catalog imports (`spx admin images import --name <name>`) verify the image
|
||||
against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums
|
||||
file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme
|
||||
redirects refused), and verification runs on both fresh downloads and cache
|
||||
hits so a poisoned cache is caught on the next import.
|
||||
|
||||
On mismatch the import exits non-zero, the cached file is left on disk for
|
||||
inspection, and the printed guidance is `spx admin images import --name <name>
|
||||
--force` to re-download.
|
||||
|
||||
`--file` imports skip verification by design: operator-supplied media is
|
||||
outside Spinifex's trust boundary and the operator is responsible for
|
||||
integrity (e.g. `sha256sum` against a trusted upstream digest before import).
|
||||
The skip is recorded as an INFO `slog` event with `reason=local-file-import`
|
||||
so a CMMC assessor can audit the decision from journald.
|
||||
|
||||
`--skip-verify` bypasses the checksum step for catalog imports. The command
|
||||
still downloads via the catalog URL but does not compare the image digest
|
||||
against the sums file. Intended for narrow cases such as debugging upstream
|
||||
mirror issues or running against a transiently-broken `latest/` path; the
|
||||
skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr
|
||||
so operators and assessors see it. Prefer `--file` with an out-of-band
|
||||
verified image over `--skip-verify` whenever possible.
|
||||
|
||||
**Limitation:** verification confirms the image matches the digest the mirror
|
||||
served. A mirror compromise that swaps both image and sums file is not
|
||||
detected; closing that gap requires GPG signature verification of the sums
|
||||
file, deferred to a later phase.
|
||||
|
||||
## AWS Commands
|
||||
|
||||
### EC2 - Instance Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination) | `--security-group-ids`, `--placement`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error | **DONE** |
|
||||
| `describe-instances` | `--instance-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes | **DONE** |
|
||||
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → on termination, removes instance from placement group → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error<br>7. Launch with spread placement group (1 per node)<br>8. Launch with cluster placement group (all on one node)<br>9. Insufficient capacity for placement group (error) | **DONE** |
|
||||
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:\*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list. Filters applied per-node before aggregation (reduces payload). Also applies to stopped/terminated instances via `describeInstancesFromKV()`. | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes<br>6. Filter by tag<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | `run-instances` (instance must exist in stopped state) | Gateway sends NATS `ec2.cmd.{instance-id}` → daemon restarts stopped QEMU process with same config → state transitions stopped→pending→running | 1. Start a stopped instance<br>2. Start already-running instance (error: IncorrectInstanceState)<br>3. Start with invalid instance ID<br>4. Verify volumes re-mount on start | **DONE** |
|
||||
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_powerdown` for graceful shutdown → monitors heartbeat until QEMU exits → state transitions running→stopping→stopped | 1. Graceful stop of running instance<br>2. Force stop (kills QEMU process)<br>3. Stop already-stopped instance (error)<br>4. Verify ~30s heartbeat detection | **DONE** |
|
||||
| `terminate-instances` | `--instance-ids`, `DeleteOnTermination` (per-volume flag, default true) | `--dry-run` | `run-instances` (instance must exist) | Gateway sends NATS to target node → daemon kills QEMU process → cleans up NBD mounts → deletes volumes with `DeleteOnTermination=true` via `volumeService.DeleteVolume()` (S3 cleanup of vol/, vol-efi/, vol-cloudinit/) → internal volumes (EFI, cloud-init) always cleaned up via `ebs.delete` NATS → volumes with `DeleteOnTermination=false` left in available state → state→terminated | 1. Terminate running instance<br>2. Terminate stopped instance<br>3. Terminate with DeleteOnTermination=true deletes volumes<br>4. Terminate with DeleteOnTermination=false preserves volumes<br>5. Terminate already-terminated (idempotent)<br>6. Internal volumes (EFI, cloud-init) always cleaned up<br>7. Invalid instance ID | **DONE** |
|
||||
| `reboot-instances` | — | `--instance-ids`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_reset` → instance reboots without stopping | 1. Reboot running instance<br>2. Reboot stopped instance (error)<br>3. Verify instance stays in running state after reboot | **NOT STARTED** (parser test exists) |
|
||||
| `reboot-instances` | `--instance-ids` | `--dry-run` | `run-instances` (instance must be running) | Gateway validates instance IDs → sends EC2InstanceCommand with `RebootInstance=true` via NATS `ec2.cmd.{instanceId}` → daemon validates instance is in StateRunning (returns IncorrectInstanceState if stopped) → issues QMP `system_reset` → instance reboots without stopping, stays in running state | 1. Reboot running instance<br>2. Reboot multiple instances<br>3. Reboot stopped instance (error: IncorrectInstanceState)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Verify instance stays in running state after reboot | **DONE** |
|
||||
| `describe-instance-types` | `--filters` (capacity filter only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, all other filters | None | Gateway fans out NATS `ec2.DescribeInstanceTypes` to all nodes → each daemon reports supported types (t3.micro/small/medium/large) with vCPU/memory specs → gateway deduplicates and returns | 1. List all instance types<br>2. Filter by specific type<br>3. Filter with `capacity=true` shows available slots<br>4. Verify vCPU/memory specs match hardware | **DONE** |
|
||||
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data` | `--disable-api-termination`, `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must be stopped (in NATS KV) | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `spinifex-workers` queue group → daemon loads stopped instance from JetStream KV → applies attribute change → writes back to KV → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Modify running instance (error: NotFound — running instances not in KV)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Instance not stopped (error: IncorrectInstanceState)<br>6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>7. StateReason cleared on type change (recovery from capacity-unavailable)<br>8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>9. No attribute set (error: InvalidParameterValue)<br>10. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
|
||||
| `get-console-output` | `--instance-id` | `--latest` (always returns latest), `--dry-run` | Instance must be running on a node | Gateway sends NATS `ec2.{instanceId}.GetConsoleOutput` (per-instance topic, routed to owning node) → daemon reads console log file from disk → returns last 64KB base64-encoded with timestamp. Always available regardless of serial console access setting (matches AWS behavior). | 1. Get output from running instance<br>2. Empty log file returns empty output<br>3. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, instanceInitiatedShutdownBehavior, disableApiTermination, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.DescribeInstanceAttribute` with `spinifex-workers` queue group → daemon checks running instances first (`d.Instances.VMS`), then stopped instances in JetStream KV → returns single attribute per call (matches AWS behavior). Stored attributes (`instanceType`, `userData`) return real values; unstored attributes return AWS defaults (`instanceInitiatedShutdownBehavior`=stop, `disableApiTermination`=false, etc.) | 1. Get instanceType from running instance<br>2. Get userData from stopped instance<br>3. Get default disableApiTermination<br>4. Invalid attribute name (error)<br>5. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-credit-specifications` | `--instance-ids` | `--filters`, `--max-results`, `--dry-run` | None | Gateway-only stub — returns `CpuCredits: "standard"` for each requested instance ID. No daemon round-trip. T-series credit mode is not persisted. | 1. Get credit spec for T-series instance<br>2. Multiple instance IDs | **DONE** |
|
||||
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
|
||||
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
|
||||
|
||||
@@ -97,7 +148,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519) | `--key-format` (pem/ppk), `--tag-specifications`, `--dry-run` | None | NATS `ec2.CreateKeyPair` → daemon generates SSH keypair → stores public key in Predastore S3 (`/bucket/ec2/{name}.pub`) → returns private key material (one-time) | 1. Create RSA key pair<br>2. Create ED25519 key pair<br>3. Duplicate key name (error: InvalidKeyPair.Duplicate)<br>4. Verify key material returned only on creation | **DONE** |
|
||||
| `describe-key-pairs` | `--key-names`, `--key-pair-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty | **DONE** |
|
||||
| `describe-key-pairs` | `--key-names`, `--key-pair-ids`, `--filters` (key-pair-id, key-name, fingerprint, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → applies filters → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | Key must exist | NATS `ec2.DeleteKeyPair` → daemon deletes public key from Predastore S3 → returns success | 1. Delete existing key pair<br>2. Delete non-existent key (idempotent, no error)<br>3. Verify key no longer in describe-key-pairs | **DONE** |
|
||||
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | None | NATS `ec2.ImportKeyPair` → daemon stores provided public key in Predastore S3 → returns key name and fingerprint | 1. Import valid SSH public key<br>2. Import invalid key material (error)<br>3. Import duplicate name (error)<br>4. Verify imported key usable with run-instances | **DONE** |
|
||||
|
||||
@@ -105,27 +156,27 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias) | `--executable-users`, `--filters`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds and Owners → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName) | **DONE** |
|
||||
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
|
||||
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
|
||||
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
|
||||
| `copy-image` | — | `--source-image-id`, `--source-region`, `--name`, `--description`, `--encrypted`, `--kms-key-id`, `--client-token`, `--copy-image-tags`, `--tag-specifications` | Source AMI must exist | Copy AMI data between regions/nodes in Predastore → create new AMI metadata with new ami-ID → return new ami-ID | 1. Copy image within same region<br>2. Copy non-existent image (error)<br>3. Verify copied image independent of source | **NOT STARTED** |
|
||||
| `register-image` | `--name`, `--description`, `--architecture` (x86_64/arm64/i386), `--root-device-name`, `--virtualization-type` (hvm only), `--block-device-mappings` (root with `Ebs.SnapshotId`+optional `VolumeSize`), `--tag-specifications` | `--billing-products`, `--uefi-data` | Backing snapshot must exist in Predastore (`{snapshotId}/metadata.json`); caller must own snapshot or it must be system-owned | Gateway validates name length (3–128), `snap-` prefix, architecture/virtualization values → NATS `ec2.RegisterImage` → daemon checks AMI name uniqueness, reads snapshot metadata, verifies snapshot ownership, builds `viperblock.AMIMetadata` (defaults: `Architecture=x86_64`, `Virtualization=hvm`, `PlatformDetails=Linux/UNIX`, `RootDeviceType=ebs`), writes `{amiId}/config.json`. Pointer-only — never touches block data. `BootMode`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport`, `EnaSupport`, `SriovNetSupport`, `ImageLocation`, `paravirtual` virtualization rejected with `InvalidParameterValue`. `VolumeSize` smaller than snapshot rejected. | 1. Register with valid snapshot<br>2. Missing name/snapshot (error)<br>3. Duplicate AMI name (`InvalidAMIName.Duplicate`)<br>4. Snapshot not found (`InvalidSnapshot.NotFound`)<br>5. Cross-account snapshot (`UnauthorizedOperation`)<br>6. Tags from `TagSpecifications` persisted<br>7. Verify registered image in describe-images | **DONE** |
|
||||
| `deregister-image` | `--image-id` | `--dry-run` | AMI must exist; caller must own it (system AMIs immutable via this API) | Gateway validates `ami-` prefix → NATS `ec2.DeregisterImage` → daemon hard-deletes `{amiId}/config.json` from Predastore. Backing snapshot is left intact (matches AWS — operators run `delete-snapshot` separately to reclaim block storage). Cross-account/system-AMI mutations rejected with `UnauthorizedOperation`. Re-deregister returns `InvalidAMIID.NotFound` (no tombstone). | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (`InvalidAMIID.NotFound`)<br>3. Re-deregister already-deleted AMI (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI (`UnauthorizedOperation`)<br>5. System AMI (`UnauthorizedOperation`)<br>6. Verify deregistered AMI not in describe-images<br>7. Backing snapshot untouched | **DONE** |
|
||||
| `copy-image` | `--source-image-id`, `--source-region` (must equal gateway region), `--name`, `--description`, `--client-token` (accepted, not honoured), `--copy-image-tags`, `--tag-specifications` (image only) | `--encrypted`, `--kms-key-id`, `--destination-outpost-arn`, `--dry-run` | Source AMI must exist and be owned by caller OR be a system AMI; backing snapshot must still exist | Gateway validates `ami-` prefix, name length (3–128), same-region — cross-region / `Encrypted` / `KmsKeyId` / `DestinationOutpostArn` rejected with `InvalidParameterValue`. NATS `ec2.CopyImage` → daemon checks name uniqueness, reads source `ami-xxx/config.json`, visibility-filters cross-account sources as `InvalidAMIID.NotFound`, reads source `{snap-xxx}/metadata.json` (missing or empty SnapshotID → `InvalidAMIID.NotFound`), writes new `snap-yyy/metadata.json` that inherits source `VolumeID` (no block copy), writes new `ami-yyy/config.json` owned by caller. `Description` inherits from source when unset. Tag merge: `CopyImageTags=true` seeds tags from source, explicit image-resource `TagSpecifications` override colliding keys and append new ones; non-image tag specs ignored. | 1. Same-region copy produces new `ami-`/`snap-` pair with distinct IDs<br>2. New snap shares source `VolumeID` (metadata-only)<br>3. New AMI owned by caller, source untouched<br>4. System AMI copied into caller's account<br>5. Cross-account source (`InvalidAMIID.NotFound`)<br>6. Missing source AMI (`InvalidAMIID.NotFound`)<br>7. Orphaned source (missing/empty `SnapshotID`) → `InvalidAMIID.NotFound`<br>8. Duplicate name (`InvalidAMIName.Duplicate`)<br>9. Cross-region / `Encrypted` / `KmsKeyId` rejected (`InvalidParameterValue`)<br>10. `CopyImageTags` true/false tag inheritance semantics | **DONE** |
|
||||
| `import-image` | — | `--disk-containers` (Format, Url/S3Bucket+S3Key), `--description`, `--architecture`, `--platform` | S3 bucket with disk image | Download disk image from S3 → convert format (VMDK/VHD/RAW→QCOW2) → create viperblock volume → register as AMI | 1. Import QCOW2 image<br>2. Import RAW image<br>3. Invalid format (error)<br>4. Verify imported image launchable | **NOT STARTED** |
|
||||
| `describe-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Read specific attribute from AMI metadata in S3 → return attribute value | 1. Get description attribute<br>2. Get launch permission<br>3. Invalid attribute name (error) | **NOT STARTED** |
|
||||
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
|
||||
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
|
||||
| `describe-image-attribute` | `--image-id`, `--attribute` (`description`, `blockDeviceMapping` only) | `--dry-run`, `--attribute` for `launchPermission`/`bootMode`/`kernel`/`ramdisk`/`sriovNetSupport`/`productCodes`/`tpmSupport`/`uefiData`/`imdsSupport`/`lastLaunchedTime`/`deregistrationProtection` | AMI must exist and be owned by caller OR be a system AMI | Gateway validates `ami-` prefix + allowlisted attribute → NATS `ec2.DescribeImageAttribute` → daemon reads `ami-xxx/config.json`, hides cross-account reads as `InvalidAMIID.NotFound` (matches `DescribeImages`), returns `description` from stored metadata or synthesises a single root `blockDeviceMapping` from `AMIMetadata` (same shape as `DescribeImages`). Unsupported attributes rejected with `InvalidParameterValue` — notably `launchPermission` is deferred (breaks Terraform's `aws_ami` refresh; multi-account AMI sharing needs a separate design). | 1. Read `description`<br>2. Read synthesised `blockDeviceMapping`<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI hidden (`InvalidAMIID.NotFound`)<br>5. System AMI readable by any caller<br>6. Unsupported attributes (`launchPermission`, `bootMode`, …) rejected with `InvalidParameterValue` | **DONE** |
|
||||
| `modify-image-attribute` | `--image-id`, `--description` (top-level `Value=…` form), `--attribute description --value …` (structured form) | `--launch-permission`, `--imds-support`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--product-codes`, `--dry-run`, `--attribute` for anything other than `description` | AMI must exist and be owned by caller (system AMIs immutable via this API) | Gateway validates `ami-` prefix, rejects `LaunchPermission`/`ImdsSupport`/`ProductCodes`/org+user-id flags with `InvalidParameterValue`, and normalises the overloaded input shape (top-level `Description` → `Attribute=description`+`Value=…`). Both forms set together → `InvalidParameterCombination`; neither → `MissingParameter`. NATS `ec2.ModifyImageAttribute` → daemon loads AMI, runs ownership check (cross-account → `UnauthorizedOperation`, system AMIs also rejected), writes `AMIMetadata.Description` back to S3. Empty `Value` clears the description. | 1. Modify description (top-level form)<br>2. Modify description (structured form)<br>3. Clear description with empty value<br>4. Both top-level + structured set (`InvalidParameterCombination`)<br>5. Cross-account / system AMI (`UnauthorizedOperation`)<br>6. AMI not found (`InvalidAMIID.NotFound`)<br>7. `LaunchPermission` / `ImdsSupport` / unsupported `Attribute` rejected (`InvalidParameterValue`)<br>8. Round-trips via `describe-image-attribute` | **DONE** |
|
||||
| `reset-image-attribute` | `--image-id`, `--attribute description` | `--attribute launchPermission`, `--dry-run` | AMI must exist and be owned by caller | Gateway validates `ami-` prefix; only `description` is accepted (`launchPermission` — AWS's default reset target — is out of scope). NATS `ec2.ResetImageAttribute` → daemon loads AMI, ownership check, clears `AMIMetadata.Description` to empty string, persists. | 1. Reset description to empty<br>2. Cross-account / system AMI (`UnauthorizedOperation`)<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. `launchPermission` / other attributes rejected (`InvalidParameterValue`)<br>5. Round-trips via `describe-image-attribute` | **DONE** |
|
||||
|
||||
### EC2 - Volume (EBS) Management
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata) | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value | **DONE** |
|
||||
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata), `--filters` (volume-id, status, size, volume-type, attachment.instance-id, attachment.status, attachment.device, availability-zone, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → applies filters → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value<br>6. Filter by status, size, volume-type<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `modify-volume` | `--volume-id`, `--size`, `--volume-type`, `--iops` | `--throughput`, `--dry-run`, `--multi-attach-enabled` | Volume must exist | NATS `ec2.ModifyVolume` → daemon sends resize request to viperblock → NBD does not support live resize, instance must be stopped → returns modification state | 1. Increase volume size<br>2. Modify volume type<br>3. Decrease size (error - not supported)<br>4. Modify attached volume (requires stop/start) | **DONE** |
|
||||
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3 only), `--snapshot-id` (creates volume from snapshot) | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput`, `--tag-specifications` | Valid AZ configured via `spinifex init` | Gateway validates input → NATS `ec2.CreateVolume` → daemon generates vol-ID via viperblock → creates volume (empty or from snapshot) of specified size → persists config.json to Predastore S3 → returns vol-ID with state=available | 1. Create 80GB gp3 volume<br>2. Boundary sizes (1 GiB min, 16384 GiB max)<br>3. Invalid AZ (error)<br>4. Verify volume in describe-volumes<br>5. Unsupported volume type (error - only gp3)<br>6. Size out of range (error)<br>7. Create from snapshot | **DONE** |
|
||||
| `delete-volume` | `--volume-id` | `--dry-run` | Volume must exist and be detached (state=available) | Gateway validates vol- prefix → NATS `ec2.DeleteVolume` → daemon confirms state=available and no AttachedInstance → NATS `ebs.delete` to viperblockd (stops nbdkit/WAL) → deletes S3 objects under vol-id/, vol-id-efi/, vol-id-cloudinit/ → returns success | 1. Delete detached volume<br>2. Delete attached volume (error: VolumeInUse)<br>3. Delete non-existent volume (error: InvalidVolume.NotFound)<br>4. Verify volume gone from describe-volumes<br>5. Malformed volume ID (error: InvalidVolumeID.Malformed)<br>6. Double delete (idempotent NotFound) | **DONE** |
|
||||
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (optional, auto-assigns `/dev/sd[f-p]`) | `--dry-run` | Volume must exist (available), instance must exist (running) | Gateway sends to `ec2.cmd.{instanceId}` → daemon validates volume (Predastore) → `ebs.mount` via NATS (viperblock starts NBD server) → QMP `blockdev-add` (nbd-{volId}) → QMP `device_add` (virtio-blk-pci, vdisk-{volId}) → three-phase rollback on failure → update EBSRequests + BlockDeviceMappings → persist to JetStream + Predastore → respond with VolumeAttachment | 1. Attach volume to running instance<br>2. Auto-assign device name<br>3. Attach already-attached volume (VolumeInUse)<br>4. Attach to non-existent instance (InvalidInstanceID.NotFound)<br>5. Attach to stopped instance (IncorrectInstanceState)<br>6. Volume not found (InvalidVolume.NotFound)<br>7. All device slots full (AttachmentLimitExceeded)<br>8. Volume persists across stop/start | **DONE** |
|
||||
| `detach-volume` | `--volume-id`, `--instance-id` (optional, resolved via DescribeVolumes), `--device` (optional cross-check), `--force` | `--dry-run` | Volume must be attached, instance must be running | Gateway resolves InstanceId if omitted (via DescribeVolumes) → sends to `ec2.cmd.{instanceId}` → daemon validates (running, attached, not boot/EFI/CloudInit, device match) → three-phase hot-unplug: QMP `device_del` (force continues on failure) → QMP `blockdev-del` (abort if fails, preserves state to prevent double-attach) → `ebs.unmount` via NATS (best-effort) → remove from EBSRequests + BlockDeviceMappings → update volume metadata to available → persist state → respond with VolumeAttachment (state=detaching) | 1. Detach with explicit InstanceId<br>2. Detach without InstanceId (gateway resolution)<br>3. Detach with correct --device cross-check<br>4. Missing VolumeId (InvalidParameterValue)<br>5. Volume not attached (IncorrectState)<br>6. Nonexistent volume (InvalidVolume.NotFound)<br>7. Nonexistent instance (InvalidInstanceID.NotFound)<br>8. Instance not running (IncorrectInstanceState)<br>9. Device mismatch (InvalidParameterValue)<br>10. Boot volume protection (OperationNotPermitted)<br>11. Force flag (continues past device_del failure)<br>12. Volume reusability (re-attach after detach) | **DONE** |
|
||||
| `describe-volume-status` | `--volume-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes | **DONE** |
|
||||
| `describe-volume-status` | `--volume-ids`, `--filters` (volume-id, volume-status.status, availability-zone) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → applies filters → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | None | Query pending/completed volume modifications → return modification state, progress, original/target size | 1. Check in-progress modification<br>2. Check completed modification<br>3. No modifications returns empty | **NOT STARTED** |
|
||||
|
||||
### EC2 - Snapshot Management
|
||||
@@ -135,7 +186,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `create-snapshot` | `--volume-id`, `--description`, `--tag-specifications` | `--dry-run` | Volume must exist in Predastore | Gateway validates vol- prefix → NATS `ec2.CreateSnapshot` → daemon reads VolumeConfig from Predastore → generates snap-ID via viperblock → stores SnapshotConfig (metadata-only, points to source volume) as completed → returns Snapshot with state=completed, progress=100% | 1. Snapshot from valid volume<br>2. Missing volume ID (InvalidParameterValue)<br>3. Volume not found (InvalidVolume.NotFound)<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Snapshot with description<br>6. Snapshot with tag specifications | **DONE** |
|
||||
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist, instance-volume attachment tracking | Create snapshots of all volumes attached to instance → return list of snapshot IDs. Blocked: requires instance-volume attachment tracking to discover which volumes to snapshot. | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
|
||||
| `delete-snapshot` | `--snapshot-id` | `--dry-run` | Snapshot must exist | Gateway validates snap- prefix → NATS `ec2.DeleteSnapshot` → daemon verifies snapshot exists in Predastore → lists and deletes all objects under snapshot prefix → returns success | 1. Delete existing snapshot<br>2. Delete non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing snapshot ID (InvalidParameterValue)<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
|
||||
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → filters by snapshot ID if specified → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
|
||||
| `describe-snapshots` | `--snapshot-ids`, `--filters` (snapshot-id, status, volume-id, volume-size, owner-id, tag:\*) | `--owner-ids`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → applies filters → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed)<br>5. Filter by status, volume-id, volume-size<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
|
||||
|
||||
### EC2 - Tags
|
||||
@@ -188,15 +239,17 @@ Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGW
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-egress-only-internet-gateway` | `--vpc-id`, `--tag-specifications` | `--client-token`, `--dry-run` | VPC must exist | Gateway validates VpcId → NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates EgressOnlyIGWRecord in `spinifex-eigw` KV bucket with state=attached (always attached at creation, unlike IGW) → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **STARTED** (KV only, no OVN) |
|
||||
| `delete-egress-only-internet-gateway` | `--egress-only-internet-gateway-id` | `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from `spinifex-eigw` KV → returns ReturnCode=true. No detach required (unlike IGW). | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **STARTED** (KV only, no OVN) |
|
||||
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **STARTED** (KV only, no OVN) |
|
||||
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids`, `--filters` (egress-only-internet-gateway-id, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → applies filters → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list<br>4. Unknown filter returns InvalidParameterValue | **STARTED** (KV only, no OVN) |
|
||||
|
||||
### EC2 - Placement Groups
|
||||
|
||||
Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket, keyed by `{accountID}.{groupName}`. All mutations use CAS (compare-and-swap) with 5-retry limit for optimistic concurrency. Supported strategies: **spread** (strict 1 instance per node) and **cluster** (all instances pinned to single node). Partition strategy is rejected. Internal NATS operations (ReserveSpreadNodes, FinalizeSpreadInstances, ReleaseSpreadNodes, RemoveInstanceFromPlacementGroup, ReserveClusterNode, FinalizeClusterInstances) support atomic multi-node coordination for RunInstances and TerminateInstances.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-placement-group` | — | `--group-name`, `--strategy` (cluster/spread/partition), `--partition-count`, `--spread-level`, `--tag-specifications` | None | NATS `ec2.CreatePlacementGroup` → daemon stores placement group in JetStream KV with strategy, state=available → return group metadata | 1. Create with cluster strategy<br>2. Create with spread strategy<br>3. Create with partition strategy + partition count<br>4. Duplicate name (error)<br>5. Invalid strategy (error) | **NOT STARTED** |
|
||||
| `delete-placement-group` | — | `--group-name`, `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists and is empty → delete from KV → return success | 1. Delete empty group<br>2. Delete group with instances (error)<br>3. Delete non-existent group (error) | **NOT STARTED** |
|
||||
| `describe-placement-groups` | — | `--group-names`, `--group-ids`, `--filters` | None | NATS `ec2.DescribePlacementGroups` → daemon lists groups from KV → return list with strategy and state | 1. List all groups<br>2. Filter by name<br>3. Filter by strategy | **NOT STARTED** |
|
||||
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level` (hardcoded to "host"), `--tag-specifications`, `--dry-run` | None | Gateway validates GroupName + Strategy → NATS `ec2.CreatePlacementGroup` with `spinifex-workers` queue group → daemon generates pg-ID (pg-{16hex}) → stores PlacementGroupRecord in `spinifex-placement-groups` KV with state=available, SpreadLevel="host" for spread → rejects partition strategy (InvalidParameterValue) → rejects duplicate names (InvalidPlacementGroup.Duplicate) → returns PlacementGroup | 1. Create with spread strategy<br>2. Create with cluster strategy<br>3. Create with partition strategy (error: InvalidParameterValue)<br>4. Duplicate name (error: InvalidPlacementGroup.Duplicate)<br>5. Missing GroupName (error)<br>6. Missing Strategy (error) | **DONE** |
|
||||
| `delete-placement-group` | `--group-name` | `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists (InvalidPlacementGroup.Unknown) → counts instances in NodeInstances map → rejects if non-empty (InvalidPlacementGroup.InUse) → deletes from KV → returns success | 1. Delete empty group<br>2. Delete group with instances (error: InvalidPlacementGroup.InUse)<br>3. Delete non-existent group (error: InvalidPlacementGroup.Unknown) | **DONE** |
|
||||
| `describe-placement-groups` | `--group-names`, `--group-ids`, `--filters` (strategy, state, spread-level, group-name) | `--dry-run` | None | NATS `ec2.DescribePlacementGroups` → daemon lists all keys from `spinifex-placement-groups` KV → filters by GroupNames, GroupIds, or Filters → returns list with Strategy, State, SpreadLevel, GroupId | 1. List all groups<br>2. Filter by name<br>3. Filter by group ID<br>4. Filter by strategy<br>5. Filter by state<br>6. Empty result when no groups exist | **DONE** |
|
||||
|
||||
### EC2 - Dedicated Hosts
|
||||
|
||||
@@ -234,67 +287,76 @@ Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGW
|
||||
|
||||
### EC2 - Elastic IP
|
||||
|
||||
EIP operations are conditionally available — only registered when external IPAM is configured (pool mode). EIPs are stored in `spinifex-eip-allocations` KV bucket. Association publishes `vpc.add-nat`/`vpc.delete-nat` events to vpcd for OVN NAT configuration.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `allocate-address` | — | `--domain` (vpc), `--tag-specifications`, `--dry-run` | None | NATS `ec2.AllocateAddress` → daemon allocates IP from pool → stores in JetStream KV with eipalloc-ID → return AllocationId, PublicIp, Domain | 1. Allocate EIP<br>2. Verify in describe-addresses<br>3. Allocate without --domain uses default | **NOT STARTED** |
|
||||
| `release-address` | — | `--allocation-id`, `--dry-run` | EIP must exist and be disassociated | NATS `ec2.ReleaseAddress` → daemon verifies not associated → release IP back to pool → delete from KV → return success | 1. Release disassociated EIP<br>2. Release associated EIP (error)<br>3. Release non-existent (error) | **NOT STARTED** |
|
||||
| `associate-address` | — | `--allocation-id`, `--instance-id` or `--network-interface-id`, `--dry-run` | EIP and target must exist | NATS `ec2.AssociateAddress` → daemon links EIP to instance/ENI → configure OVS DNAT/SNAT rules → return eipassoc-ID | 1. Associate with instance<br>2. Re-associate (moves EIP)<br>3. Associate non-existent EIP (error) | **NOT STARTED** |
|
||||
| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing<br>2. Disassociate non-existent (error) | **NOT STARTED** |
|
||||
| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP | **NOT STARTED** |
|
||||
| `allocate-address` | `--public-ipv4-pool` (optional, specific IPAM pool), `--tag-specifications` | `--domain` (hardcoded vpc), `--dry-run` | EIP service must be initialized (external IPAM configured) | Gateway validates input → NATS `ec2.AllocateAddress` with `spinifex-workers` queue group → daemon allocates IP from external IPAM pool (named or best-matching) → creates EIPRecord with state=allocated in `spinifex-eip-allocations` KV → returns AllocationId, PublicIp, Domain=vpc, PublicIpv4Pool | 1. Allocate EIP from default pool<br>2. Allocate from specific pool<br>3. Pool exhausted (InsufficientAddressCapacity)<br>4. With tag specifications<br>5. Verify in describe-addresses | **DONE** |
|
||||
| `release-address` | `--allocation-id` | `--dry-run` | EIP must exist and be disassociated | Gateway validates AllocationId non-empty → NATS `ec2.ReleaseAddress` → daemon retrieves EIP record from KV → checks state != associated (InvalidAddressLocked if still associated) → releases IP back to IPAM pool → deletes KV record → returns success | 1. Release disassociated EIP<br>2. Release associated EIP (error: InvalidAddressLocked)<br>3. Release non-existent (error: InvalidAllocationID.NotFound)<br>4. Missing AllocationId (error: MissingParameter) | **DONE** |
|
||||
| `associate-address` | `--allocation-id`, `--network-interface-id` OR `--instance-id`, `--private-ip-address` (optional override) | `--dry-run`, `--allow-reassociation` | EIP and target ENI/instance must exist | Gateway validates AllocationId + one of NetworkInterfaceId/InstanceId required → NATS `ec2.AssociateAddress` → daemon retrieves EIP → resolves target ENI (by ID or instance lookup) → generates AssociationId → updates record state=associated with ENI/instance/VPC/private IP → publishes `vpc.add-nat` event to vpcd (OVN NAT configuration) → returns AssociationId | 1. Associate with instance<br>2. Associate with specific ENI<br>3. Associate with private IP override<br>4. Missing AllocationId (MissingParameter)<br>5. Missing target (MissingParameter)<br>6. Non-existent EIP (InvalidAllocationID.NotFound)<br>7. Non-existent ENI (InvalidNetworkInterfaceID.NotFound) | **DONE** |
|
||||
| `disassociate-address` | `--association-id` | `--dry-run` | Association must exist | Gateway validates AssociationId non-empty → NATS `ec2.DisassociateAddress` → daemon scans KV to find EIP by AssociationId → publishes `vpc.delete-nat` event to vpcd → clears association fields → reverts state to allocated → updates KV → returns success | 1. Disassociate existing<br>2. Non-existent association (InvalidAssociationID.NotFound)<br>3. Missing AssociationId (MissingParameter) | **DONE** |
|
||||
| `describe-addresses` | `--allocation-ids`, `--public-ips`, `--filters` (allocation-id, public-ip, instance-id, association-id, domain, tag:\*) | `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → applies filters → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP<br>4. Non-existent AllocationId returns error<br>5. Empty result when no EIPs<br>6. Filter by instance-id, domain<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `describe-addresses-attribute` | `--allocation-ids` (optional filter) | `--attribute`, `--dry-run`, `--max-results`, `--next-token` | None | Gateway validates input → NATS `ec2.DescribeAddressesAttribute` with `spinifex-workers` queue group → daemon looks up EIP records by AllocationIds (or scans all account EIPs if none specified) → returns AddressAttribute list with AllocationId and PublicIp per entry. Unlike DescribeAddresses, returns empty list (not error) for non-existent AllocationIds. PtrRecord always nil (reverse-DNS not supported). | 1. List all EIP attributes<br>2. Filter by allocation IDs<br>3. Non-existent AllocationId returns empty (not error)<br>4. Nil input returns error | **DONE** |
|
||||
|
||||
### EC2 - NAT Gateway
|
||||
|
||||
NAT gateways are stored in `spinifex-vpc-nat-gateways` KV bucket. Deleted gateways are moved to `spinifex-vpc-deleted-nat-gateways` with a 1-hour TTL for Terraform polling. SNAT rule coordination uses `vpc.add-nat-gateway` and `vpc.delete-nat-gateway` NATS events (published via route table service on CreateRoute and during DeleteNatGateway respectively).
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-nat-gateway` | — | `--subnet-id`, `--allocation-id`, `--connectivity-type`, `--tag-specifications` | Subnet must exist, EIP must be allocated (for public type) | NATS `ec2.CreateNatGateway` → daemon creates NAT gateway in JetStream KV with nat-ID → configure OVS SNAT rules → state=pending→available → return NatGateway | 1. Create public NAT gateway<br>2. Create private NAT gateway (no allocation-id)<br>3. Invalid subnet (error) | **NOT STARTED** |
|
||||
| `delete-nat-gateway` | — | `--nat-gateway-id`, `--dry-run` | NAT gateway must exist | NATS `ec2.DeleteNatGateway` → daemon marks as deleting → remove OVS SNAT rules → delete from KV → return NatGatewayId | 1. Delete existing NAT gateway<br>2. Delete non-existent (error) | **NOT STARTED** |
|
||||
| `describe-nat-gateways` | — | `--nat-gateway-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNatGateways` → daemon lists NAT gateways from KV → return list with state, subnet, addresses | 1. List all NAT gateways<br>2. Filter by ID<br>3. Filter by VPC ID | **NOT STARTED** |
|
||||
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type` (public only), `--tag-specifications`, `--dry-run` | Subnet must exist, EIP must be allocated and unassociated | Gateway validates SubnetId + AllocationId (required) → NATS `ec2.CreateNatGateway` with `spinifex-workers` queue group → daemon verifies subnet exists in `spinifex-vpc-subnets` KV → verifies EIP exists in `spinifex-vpc-eips` KV and has no AssociationId (Resource.AlreadyAssociated if in use) → generates nat-ID → stores NatGatewayRecord in `spinifex-vpc-nat-gateways` KV with state=available → returns NatGateway with addresses | 1. Create NAT gateway (happy path)<br>2. Subnet not found (InvalidSubnetID.NotFound)<br>3. EIP not found (InvalidAllocationID.NotFound)<br>4. EIP already associated (Resource.AlreadyAssociated)<br>5. Missing SubnetId or AllocationId (MissingParameter) | **DONE** |
|
||||
| `delete-nat-gateway` | `--nat-gateway-id` | `--dry-run` | NAT gateway must exist | Gateway validates NatGatewayId (required) → NATS `ec2.DeleteNatGateway` → daemon retrieves record from KV → scans all route tables in VPC for routes targeting this NAT gateway → publishes `vpc.delete-nat-gateway` event per affected subnet (SNAT rule cleanup) → sets state=deleted → stores in `spinifex-vpc-deleted-nat-gateways` with 1-hour TTL → deletes from active KV bucket | 1. Delete existing NAT gateway<br>2. Delete non-existent (InvalidNatGatewayID.NotFound)<br>3. Deleted gateway visible when queried by ID (state=deleted)<br>4. Deleted gateway excluded from general list queries (TTL auto-expire) | **DONE** |
|
||||
| `describe-nat-gateways` | `--nat-gateway-ids`, `--filters` (vpc-id, state) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeNatGateways` → daemon scans `spinifex-vpc-nat-gateways` KV → applies NatGatewayIds filter and/or vpc-id/state filters → for specific IDs not found in active bucket, checks deleted bucket → returns InvalidNatGatewayID.NotFound if ID never existed → returns list with state, addresses, subnet, VPC | 1. List all NAT gateways<br>2. Filter by specific IDs<br>3. Filter by state<br>4. Filter by vpc-id<br>5. Non-existent ID (InvalidNatGatewayID.NotFound)<br>6. Deleted gateway visible by ID query | **DONE** |
|
||||
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | NAT gateway must exist (private type) | NATS `ec2.AssignPrivateNatGatewayAddress` → daemon assigns private IPs to NAT gateway → return assigned addresses | 1. Assign address<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
| `associate-nat-gateway-address` | — | `--nat-gateway-id`, `--allocation-ids` | NAT gateway and EIPs must exist | NATS `ec2.AssociateNatGatewayAddress` → daemon associates EIPs with NAT gateway → return association info | 1. Associate EIP<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
|
||||
### EC2 - VPC Networking (Core)
|
||||
|
||||
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket).
|
||||
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, routers, and ACLs. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). Security groups stored in `spinifex-vpc-security-groups` KV bucket with optimistic locking on rule updates.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `spinifex-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `spinifex-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Invalid CIDR format (error: InvalidVpcRange)<br>3. CIDR prefix outside /16–/28 (error)<br>4. Verify in describe-vpcs<br>5. Tags applied at creation | **DONE** |
|
||||
| `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `spinifex-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `spinifex-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC<br>2. Delete VPC with subnets (DependencyViolation)<br>3. Delete non-existent VPC (error) | **DONE** |
|
||||
| `describe-vpcs` | `--vpc-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error | **DONE** |
|
||||
| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support<br>2. Enable DNS hostnames<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
| `describe-vpcs` | `--vpc-ids`, `--filters` (vpc-id, state, cidr-block, is-default, owner-id, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → applies filters → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error<br>4. Filter by state, cidr-block, is-default<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `modify-vpc-attribute` | `--vpc-id`, `--enable-dns-hostnames`, `--enable-dns-support`, `--enable-network-address-usage-metrics` | `--dry-run` | VPC must exist | Gateway validates VpcId non-empty → NATS `ec2.ModifyVpcAttribute` → daemon retrieves VPC record from `spinifex-vpc-vpcs` KV → updates boolean flags if provided → updates KV with optimistic locking → returns empty output | 1. Enable DNS hostnames<br>2. Enable DNS support<br>3. Enable network address usage metrics<br>4. Missing VPC ID (MissingParameter)<br>5. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
|
||||
| `describe-vpc-attribute` | `--vpc-id`, `--attribute` (enableDnsHostnames, enableDnsSupport, enableNetworkAddressUsageMetrics) | `--dry-run` | VPC must exist | Gateway validates VpcId + Attribute non-empty → NATS `ec2.DescribeVpcAttribute` → daemon retrieves VPC record → returns single attribute as AttributeBooleanValue (one attribute per call, matches AWS behavior) → validates attribute name | 1. Get enableDnsHostnames<br>2. Get enableDnsSupport<br>3. Get enableNetworkAddressUsageMetrics<br>4. Invalid attribute name (InvalidParameterValue)<br>5. Missing VPC ID (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
|
||||
| `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR<br>2. Overlapping CIDR (error)<br>3. Max CIDR blocks exceeded (error) | **NOT STARTED** |
|
||||
| `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR<br>2. Remove primary CIDR (error) | **NOT STARTED** |
|
||||
| `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `spinifex-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)<br>3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)<br>4. CIDR prefix outside /16–/28 (error)<br>5. Missing VPC or CIDR (error: MissingParameter) | **DONE** |
|
||||
| `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `spinifex-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet<br>2. Delete non-existent subnet (error) | **DONE** |
|
||||
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies vpc-id filter if present → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error | **DONE** |
|
||||
| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP<br>2. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id, subnet-id, availability-zone, cidr-block, state, default-for-az, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies filters → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error<br>5. Filter by availability-zone, cidr-block, state<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `modify-subnet-attribute` | `--subnet-id`, `--map-public-ip-on-launch` | `--assign-ipv6-address-on-creation`, `--dry-run` | Subnet must exist | Gateway validates SubnetId non-empty → NATS `ec2.ModifySubnetAttribute` → daemon retrieves Subnet record from `spinifex-vpc-subnets` KV → updates MapPublicIpOnLaunch flag if provided → updates KV with optimistic locking → returns empty output | 1. Enable auto-assign public IP<br>2. Disable auto-assign public IP<br>3. Missing SubnetId (MissingParameter)<br>4. Non-existent subnet (InvalidSubnetID.NotFound) | **DONE** |
|
||||
| `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR<br>2. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR<br>2. Invalid association (error) | **NOT STARTED** |
|
||||
| `create-security-group` | — | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | VPC must exist | Create security group metadata → create default OVS flow rules (deny all inbound, allow all outbound) → assign sg-ID | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error)<br>3. Verify default rules | **NOT STARTED** |
|
||||
| `delete-security-group` | — | `--group-id` | SG must not be in use by instances | Verify no instances reference SG → remove OVS flow rules → delete metadata | 1. Delete unused SG<br>2. Delete SG in use (error) | **NOT STARTED** |
|
||||
| `describe-security-groups` | — | `--group-ids`, `--group-names`, `--filters`, `--max-results` | None | Query NATS KV for SG metadata → return SG list with rules | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name | **NOT STARTED** |
|
||||
| `authorize-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr`, `--source-group` | SG must exist | Add inbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Duplicate rule (idempotent) | **NOT STARTED** |
|
||||
| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR | **NOT STARTED** |
|
||||
| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)<br>3. Verify default egress rule (allow all)<br>4. Verify no default ingress rules<br>5. Missing GroupName (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound)<br>7. With tag specifications | **DONE** |
|
||||
| `delete-security-group` | `--group-id` | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.DeleteSecurityGroup` → daemon retrieves SG from KV → deletes from `spinifex-vpc-security-groups` KV → publishes `vpc.delete-sg` event to vpcd → returns success | 1. Delete existing SG<br>2. Delete non-existent SG (error: InvalidGroup.NotFound)<br>3. Missing GroupId (MissingParameter) | **DONE** |
|
||||
| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name, group-id, description, ip-permission.cidr, tag:\*) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → applies filters (AND logic across filter names, OR across values) → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name<br>4. Filter by GroupId<br>5. Non-existent GroupId (InvalidGroup.NotFound)<br>6. Empty result when no SGs<br>7. Filter by description, ip-permission.cidr<br>8. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `authorize-security-group-ingress` | `--group-id`, `--ip-permissions` (IpProtocol, FromPort, ToPort, IpRanges/CidrIp, UserIdGroupPairs/GroupId) | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.AuthorizeSecurityGroupIngress` → daemon retrieves SG → converts IpPermissions to SGRule objects → appends to IngressRules → updates KV with optimistic locking (revision check) → publishes `vpc.update-sg` event to vpcd → returns true | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Allow from source security group<br>4. Missing GroupId (MissingParameter)<br>5. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `authorize-security-group-egress` | `--group-id`, `--ip-permissions` | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.AuthorizeSecurityGroupEgress` → daemon retrieves SG → appends rules to EgressRules → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR<br>3. Missing GroupId (MissingParameter)<br>4. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `revoke-security-group-ingress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupIngress` → daemon retrieves SG → removes matching rules from IngressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `revoke-security-group-egress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupEgress` → daemon retrieves SG → removes matching rules from EgressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `spinifex-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW<br>2. Verify in describe-internet-gateways<br>3. Tags applied at creation | **DONE** |
|
||||
| `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (ResourceAlreadyAssociated)<br>3. Non-existent IGW (error)<br>4. Missing params (MissingParameter) | **DONE** |
|
||||
| `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW<br>2. Detach from wrong VPC (GatewayNotAttached)<br>3. Detach unattached IGW (error) | **DONE** |
|
||||
| `delete-internet-gateway` | `--internet-gateway-id` | `--dry-run` | IGW must be detached | NATS `ec2.DeleteInternetGateway` → daemon validates IGW exists and VpcId is empty (DependencyViolation if attached) → deletes from `spinifex-igw` KV | 1. Delete detached IGW<br>2. Delete attached IGW (DependencyViolation)<br>3. Delete non-existent (error) | **DONE** |
|
||||
| `describe-internet-gateways` | `--internet-gateway-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info | **DONE** |
|
||||
| `describe-internet-gateways` | `--internet-gateway-ids`, `--filters` (internet-gateway-id, attachment.vpc-id, attachment.state, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → applies filters → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info<br>4. Filter by attachment.vpc-id, attachment.state<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
|
||||
### EC2 - Route Tables
|
||||
|
||||
Route tables are stored in `spinifex-vpc-route-tables` JetStream KV bucket, keyed by `{accountID}.{routeTableId}`. Each route table has a Routes array (with local route pre-populated) and an Associations array. All operations validate VPC consistency. When a route targeting a NAT gateway is created, `vpc.add-nat-gateway` events are published per associated subnet for vpcd SNAT rule creation.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-route-table` | — | `--vpc-id`, `--tag-specifications` | VPC must exist | NATS `ec2.CreateRouteTable` → daemon creates route table in KV with rtb-ID → add default local route for VPC CIDR → return route table | 1. Create route table<br>2. Verify default local route<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
| `delete-route-table` | — | `--route-table-id`, `--dry-run` | Route table must exist and not be main route table | NATS `ec2.DeleteRouteTable` → daemon verifies not main table → delete from KV → return success | 1. Delete non-main route table<br>2. Delete main route table (error)<br>3. Delete non-existent (error) | **NOT STARTED** |
|
||||
| `describe-route-tables` | — | `--route-table-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeRouteTables` → daemon lists route tables from KV → return tables with routes | 1. List all route tables<br>2. Filter by VPC | **NOT STARTED** |
|
||||
| `create-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` or `--egress-only-internet-gateway-id` | Route table and target must exist | NATS `ec2.CreateRoute` → daemon adds route entry → configure OVS flow for destination CIDR → update metadata | 1. Add internet route via IGW<br>2. Add route to NAT gateway<br>3. Add route to EIGW<br>4. Conflicting route (error) | **NOT STARTED** |
|
||||
| `delete-route` | — | `--route-table-id`, `--destination-cidr-block` | Route must exist | NATS `ec2.DeleteRoute` → daemon removes route entry → remove OVS flow → update metadata | 1. Delete existing route<br>2. Delete non-existent route (error)<br>3. Delete local route (error) | **NOT STARTED** |
|
||||
| `replace-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` | Route must exist | NATS `ec2.ReplaceRoute` → daemon replaces route target → update OVS flow → update metadata | 1. Replace IGW route with NAT<br>2. Replace non-existent route (error) | **NOT STARTED** |
|
||||
| `associate-route-table` | — | `--route-table-id`, `--subnet-id` or `--gateway-id` | Route table and target must exist | NATS `ec2.AssociateRouteTable` → daemon links route table to subnet/gateway → return rtbassoc-ID | 1. Associate with subnet<br>2. Missing route table ID (error) | **NOT STARTED** |
|
||||
| `disassociate-route-table` | — | `--association-id` | Association must exist, not main table association | NATS `ec2.DisassociateRouteTable` → daemon unlinks route table → return success | 1. Disassociate existing<br>2. Disassociate main table (error) | **NOT STARTED** |
|
||||
| `create-route-table` | `--vpc-id` | `--tag-specifications`, `--dry-run` | VPC must exist | Gateway validates VpcId (required) → NATS `ec2.CreateRouteTable` with `spinifex-workers` queue group → daemon verifies VPC exists in `spinifex-vpc-vpcs` KV → retrieves VPC CIDR → generates rtb-ID → creates RouteTableRecord with IsMain=false, local route pre-populated (destination=VPC CIDR, gateway="local"), empty associations → stores in `spinifex-vpc-route-tables` KV → returns RouteTable | 1. Create route table with local route (happy path)<br>2. VPC not found (InvalidVpcID.NotFound)<br>3. Missing VpcId (MissingParameter) | **DONE** |
|
||||
| `delete-route-table` | `--route-table-id` | `--dry-run` | Route table must exist, not main, no subnet associations | Gateway validates RouteTableId (required) → NATS `ec2.DeleteRouteTable` → daemon retrieves record → rejects if IsMain=true (DependencyViolation) → rejects if has non-main subnet associations (DependencyViolation) → deletes from KV → returns success | 1. Delete custom route table<br>2. Delete main route table (DependencyViolation)<br>3. Delete table with subnet associations (DependencyViolation)<br>4. Delete non-existent (InvalidRouteTableID.NotFound) | **DONE** |
|
||||
| `describe-route-tables` | `--route-table-ids`, `--filters` (vpc-id, route-table-id, association.main, association.route-table-association-id, association.subnet-id, route.destination-cidr-block, route.gateway-id) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeRouteTables` → daemon scans all keys in `spinifex-vpc-route-tables` KV → filters by RouteTableIds if specified → applies all provided filters (AND logic) → returns error for non-existent requested IDs → converts records to EC2 RouteTable objects | 1. List all route tables<br>2. Filter by vpc-id<br>3. Filter by association.main (true/false)<br>4. Non-existent ID (InvalidRouteTableID.NotFound)<br>5. Empty result when no route tables | **DONE** |
|
||||
| `create-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id`, `--nat-gateway-id` | `--egress-only-internet-gateway-id`, `--vpc-peering-connection-id`, `--dry-run` | Route table and target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.CreateRoute` → daemon retrieves route table → checks for duplicate destination (RouteAlreadyExists) → validates exactly one of GatewayId/NatGatewayId provided → if GatewayId: verifies IGW exists in `spinifex-igw` KV and is attached to same VPC → if NatGatewayId: verifies NAT GW exists in `spinifex-vpc-nat-gateways` KV and is in same VPC → for NAT GW routes, publishes `vpc.add-nat-gateway` event per associated subnet → appends RouteRecord (state=active, origin=CreateRoute) → updates KV → returns Return=true | 1. Add IGW route (happy path)<br>2. Duplicate destination (RouteAlreadyExists)<br>3. IGW not found (InvalidInternetGatewayID.NotFound)<br>4. NAT GW not found (InvalidNatGatewayID.NotFound)<br>5. Gateway not in same VPC (InvalidParameterValue)<br>6. Missing RouteTableId or DestinationCidrBlock (MissingParameter) | **DONE** |
|
||||
| `delete-route` | `--route-table-id`, `--destination-cidr-block` | `--dry-run` | Route must exist | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.DeleteRoute` → daemon retrieves route table → finds matching route → rejects if gateway is "local" (InvalidParameterValue — local routes cannot be deleted) → removes route from Routes array → updates KV → returns success | 1. Delete IGW route (happy path, local route remains)<br>2. Delete local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `replace-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` | `--nat-gateway-id`, `--dry-run` | Route must exist, new target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock + GatewayId (required) → NATS `ec2.ReplaceRoute` → daemon retrieves route table → finds matching route → rejects if local route → verifies IGW exists and is attached to same VPC → updates route GatewayId, clears NatGatewayId, sets state=active → updates KV → returns success | 1. Replace route target (IGW swap)<br>2. Replace local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. IGW not found (InvalidInternetGatewayID.NotFound) | **DONE** |
|
||||
| `associate-route-table` | `--route-table-id`, `--subnet-id` | `--gateway-id`, `--dry-run` | Route table and subnet must exist in same VPC | Gateway validates RouteTableId + SubnetId (required) → NATS `ec2.AssociateRouteTable` → daemon retrieves route table → verifies subnet exists and is in same VPC → scans ALL route tables in VPC to check subnet isn't already explicitly associated (Resource.AlreadyAssociated) → generates rtbassoc-ID → appends AssociationRecord (Main=false) → updates KV → returns AssociationId with state=associated | 1. Associate subnet (happy path)<br>2. Duplicate subnet association (Resource.AlreadyAssociated)<br>3. Subnet not found (InvalidSubnetID.NotFound)<br>4. Subnet in different VPC (InvalidParameterValue)<br>5. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `disassociate-route-table` | `--association-id` | `--dry-run` | Association must exist, not main table association | Gateway validates AssociationId (required) → NATS `ec2.DisassociateRouteTable` → daemon scans ALL route tables in account to find matching AssociationId → rejects if Main=true (InvalidParameterValue — main associations are immutable) → removes association from Associations array → updates KV → returns success | 1. Disassociate subnet (happy path)<br>2. Disassociate main table (InvalidParameterValue)<br>3. Association not found (InvalidAssociationID.NotFound) | **DONE** |
|
||||
| `replace-route-table-association` | `--association-id`, `--route-table-id` | `--dry-run` | Association and new route table must exist | Gateway validates AssociationId + RouteTableId (required) → NATS `ec2.ReplaceRouteTableAssociation` → daemon verifies new route table exists → scans all route tables to find matching AssociationId → if Main=true: atomically swaps IsMain flag between old and new table → two-phase commit: removes association from old table, creates new AssociationId on new table → compensating write on failure (logs CRITICAL if compensation fails) → returns new AssociationId with state=associated | 1. Move subnet between tables (happy path)<br>2. Replace main table association (swaps IsMain flags)<br>3. Association not found (InvalidAssociationID.NotFound)<br>4. Route table not found (InvalidRouteTableID.NotFound) | **DONE** |
|
||||
|
||||
### EC2 - VPC Peering
|
||||
|
||||
@@ -374,7 +436,7 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
|
||||
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `spinifex-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
|
||||
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
|
||||
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
|
||||
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
|
||||
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | ENI must exist; at least one attribute required | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon validates ENI exists → updates description and/or security groups in `spinifex-vpc-enis` KV (optimistic locking) → return success | 1. Modify description<br>2. Update security groups<br>3. No attributes (InvalidParameterValue)<br>4. Non-existent ENI (error) | **DONE** |
|
||||
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
|
||||
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
|
||||
|
||||
@@ -407,19 +469,6 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
|
||||
| `list-snapshot-blocks` | — | `--snapshot-id`, `--max-results`, `--next-token` | Snapshot must exist | List all non-empty blocks in snapshot with tokens and sizes | 1. List blocks of populated snapshot<br>2. List blocks of empty snapshot | **NOT STARTED** |
|
||||
| `list-changed-blocks` | — | `--second-snapshot-id`, `--first-snapshot-id`, `--max-results` | Snapshots must exist | Compare two snapshots → return list of differing block indexes | 1. Compare parent and child snapshots<br>2. Compare unrelated snapshots | **NOT STARTED** |
|
||||
|
||||
### S3 (via Predastore)
|
||||
|
||||
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Spinifex format for AWS gateway integration.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `s3 mb` (CreateBucket) | — | `s3://bucket-name` | None | NATS `s3.createbucket` → Predastore creates bucket → return success | 1. Create valid bucket<br>2. Duplicate bucket name (error)<br>3. Invalid bucket name (error) | **NOT STARTED** (Predastore handles directly) |
|
||||
| `s3 rb` (DeleteBucket) | — | `s3://bucket-name`, `--force` | Bucket must exist | NATS `s3.deletebucket` → verify bucket empty (or force) → Predastore deletes bucket | 1. Delete empty bucket<br>2. Delete non-empty bucket (error)<br>3. Force delete non-empty bucket | **NOT STARTED** |
|
||||
| `s3 ls` (ListBuckets/Objects) | — | `s3://bucket/prefix`, `--recursive` | None | NATS `s3.listobjects` → Predastore lists objects with prefix → return list | 1. List all buckets<br>2. List objects in bucket<br>3. List with prefix filter | **NOT STARTED** |
|
||||
| `s3 cp` (PutObject/GetObject) | — | `source dest`, `--recursive`, `--acl` | Bucket must exist | NATS `s3.putobject`/`s3.getobject` → Predastore stores/retrieves with Reed-Solomon encoding | 1. Upload single file<br>2. Download single file<br>3. Recursive upload/download<br>4. Large file (multipart) | **NOT STARTED** |
|
||||
| `s3 rm` (DeleteObject) | — | `s3://bucket/key`, `--recursive` | Object must exist | NATS `s3.deleteobject` → Predastore deletes object shards | 1. Delete single object<br>2. Recursive delete<br>3. Delete non-existent (idempotent) | **NOT STARTED** |
|
||||
| `s3 sync` | — | `source dest`, `--delete`, `--exclude`, `--include` | Bucket must exist | Compare source and dest → upload changed/new files → optionally delete removed files | 1. Sync local dir to S3<br>2. Sync with --delete<br>3. Sync with exclude filter | **NOT STARTED** |
|
||||
|
||||
### IAM - User Management
|
||||
|
||||
All IAM operations are account-scoped. Users, policies, and access keys are namespaced per account. The gateway extracts the account ID from the authenticated access key via `c.Locals("sigv4.accountId")` and passes it to all IAM service methods. Root user (account `000000000000`) bypasses policy evaluation entirely.
|
||||
@@ -475,5 +524,146 @@ The gateway evaluates IAM policies on every request (except root user bypass). P
|
||||
| Explicit deny | Deny statements override Allow (deny wins) | **DONE** |
|
||||
| Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** |
|
||||
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
|
||||
| EC2 action mapping | 54 EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
|
||||
|
||||
### IAM - Roles
|
||||
|
||||
IAM roles are required for instance profiles, cross-account access, and service-linked permissions. Without roles, workloads must use static access keys. Roles should be stored in a `spinifex-iam-roles` KV bucket keyed by `{accountID}.{roleName}`. Trust policies (AssumeRolePolicyDocument) define who can assume the role.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-role` | — | `--role-name`, `--assume-role-policy-document`, `--path`, `--description`, `--max-session-duration`, `--permissions-boundary`, `--tags` | None | IAMService.CreateRole(accountID, input) → validate RoleName + AssumeRolePolicyDocument (required) → validate trust policy JSON → generate AROA-prefixed RoleId → store RoleRecord in `spinifex-iam-roles` KV at `{accountID}.{roleName}` → ARN: `arn:aws:iam::{accountID}:role/{path}{roleName}` → return Role | 1. Create with valid trust policy<br>2. Malformed trust policy (MalformedPolicyDocument)<br>3. Duplicate name (EntityAlreadyExists)<br>4. Missing RoleName (InvalidInput)<br>5. With path and description | **NOT STARTED** |
|
||||
| `get-role` | — | `--role-name` | Role must exist | IAMService.GetRole(accountID, input) → reads from KV at `{accountID}.{roleName}` → returns Role with ARN, Path, AssumeRolePolicyDocument, CreateDate | 1. Get existing role<br>2. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-roles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListRoles(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix → returns role list scoped to caller's account | 1. List all roles<br>2. List with path prefix<br>3. Empty list<br>4. Account isolation | **NOT STARTED** |
|
||||
| `delete-role` | — | `--role-name` | Role must exist, no attached policies, no instance profiles | IAMService.DeleteRole(accountID, input) → verify no attached policies (DeleteConflict) → verify not in any instance profile (DeleteConflict) → delete from KV | 1. Delete role with no attachments<br>2. Delete role with policies (DeleteConflict)<br>3. Delete role in instance profile (DeleteConflict)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `update-role` | — | `--role-name`, `--description`, `--max-session-duration` | Role must exist | IAMService.UpdateRole(accountID, input) → update description and/or session duration in KV → return success | 1. Update description<br>2. Update max session duration<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `update-assume-role-policy` | — | `--role-name`, `--policy-document` | Role must exist | IAMService.UpdateAssumeRolePolicy(accountID, input) → validate trust policy JSON → update AssumeRolePolicyDocument in KV → return success | 1. Update trust policy<br>2. Malformed policy (MalformedPolicyDocument)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `attach-role-policy` | — | `--role-name`, `--policy-arn` | Role and policy must exist | IAMService.AttachRolePolicy(accountID, input) → verify policy exists → add ARN to role's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent role (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **NOT STARTED** |
|
||||
| `detach-role-policy` | — | `--role-name`, `--policy-arn` | Policy must be attached | IAMService.DetachRolePolicy(accountID, input) → remove ARN from role's AttachedPolicies | 1. Detach attached policy<br>2. Not attached (NoSuchEntity)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-attached-role-policies` | — | `--role-name`, `--path-prefix`, `--max-items`, `--marker` | Role must exist | IAMService.ListAttachedRolePolicies(accountID, input) → reads role's AttachedPolicies list → returns ARNs and names | 1. List attached policies<br>2. No policies (empty list)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
|
||||
### IAM - Instance Profiles
|
||||
|
||||
Instance profiles are containers for IAM roles that allow EC2 instances to assume a role and obtain temporary credentials. Required for `run-instances --iam-instance-profile`. Stored in `spinifex-iam-instance-profiles` KV bucket keyed by `{accountID}.{profileName}`.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-instance-profile` | — | `--instance-profile-name`, `--path`, `--tags` | None | IAMService.CreateInstanceProfile(accountID, input) → validate name (required) → generate AIPA-prefixed InstanceProfileId → store in `spinifex-iam-instance-profiles` KV with empty Roles array → ARN: `arn:aws:iam::{accountID}:instance-profile/{path}{name}` → return InstanceProfile | 1. Create profile<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
|
||||
| `get-instance-profile` | — | `--instance-profile-name` | Profile must exist | IAMService.GetInstanceProfile(accountID, input) → reads from KV → returns InstanceProfile with Roles array | 1. Get existing profile<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-instance-profiles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListInstanceProfiles(accountID, input) → prefix-scans KV → returns list | 1. List all profiles<br>2. Empty list | **NOT STARTED** |
|
||||
| `list-instance-profiles-for-role` | — | `--role-name`, `--max-items`, `--marker` | Role must exist | IAMService.ListInstanceProfilesForRole(accountID, input) → scan all profiles → filter where Roles contains role name → return list | 1. List profiles for role<br>2. Role not in any profile (empty) | **NOT STARTED** |
|
||||
| `delete-instance-profile` | — | `--instance-profile-name` | Profile must exist, Roles must be empty | IAMService.DeleteInstanceProfile(accountID, input) → verify Roles empty (DeleteConflict if not) → delete from KV | 1. Delete empty profile<br>2. Delete profile with role (DeleteConflict)<br>3. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `add-role-to-instance-profile` | — | `--instance-profile-name`, `--role-name` | Both must exist, max 1 role per profile | IAMService.AddRoleToInstanceProfile(accountID, input) → verify profile exists → verify role exists → check Roles array is empty (LimitExceeded if role already present) → append role → update KV | 1. Add role to empty profile<br>2. Profile already has role (LimitExceeded)<br>3. Non-existent profile (NoSuchEntity)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `remove-role-from-instance-profile` | — | `--instance-profile-name`, `--role-name` | Role must be in profile | IAMService.RemoveRoleFromInstanceProfile(accountID, input) → remove role from Roles array → update KV | 1. Remove role<br>2. Role not in profile (NoSuchEntity) | **NOT STARTED** |
|
||||
|
||||
### IAM - Groups
|
||||
|
||||
Groups provide a way to attach policies to multiple users. Stored in `spinifex-iam-groups` KV bucket keyed by `{accountID}.{groupName}`.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-group` | — | `--group-name`, `--path` | None | IAMService.CreateGroup(accountID, input) → validate GroupName (required) → generate AGPA-prefixed GroupId → store in KV → return Group | 1. Create group<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
|
||||
| `get-group` | — | `--group-name` | Group must exist | IAMService.GetGroup(accountID, input) → reads group from KV → returns Group with member Users list | 1. Get existing group<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-groups` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListGroups(accountID, input) → prefix-scans KV → returns list | 1. List all groups<br>2. Empty list | **NOT STARTED** |
|
||||
| `delete-group` | — | `--group-name` | Group must exist, no members, no attached policies | IAMService.DeleteGroup(accountID, input) → verify no members → verify no attached policies → delete from KV | 1. Delete empty group<br>2. Delete group with members (DeleteConflict)<br>3. Delete group with policies (DeleteConflict)<br>4. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `add-user-to-group` | — | `--group-name`, `--user-name` | Both must exist | IAMService.AddUserToGroup(accountID, input) → verify group + user exist → add user to group's Members list → idempotent | 1. Add user<br>2. Already a member (idempotent)<br>3. Non-existent group (NoSuchEntity)<br>4. Non-existent user (NoSuchEntity) | **NOT STARTED** |
|
||||
| `remove-user-from-group` | — | `--group-name`, `--user-name` | User must be in group | IAMService.RemoveUserFromGroup(accountID, input) → remove user from Members list → update KV | 1. Remove member<br>2. Not a member (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-groups-for-user` | — | `--user-name`, `--max-items`, `--marker` | User must exist | IAMService.ListGroupsForUser(accountID, input) → scan all groups → filter where Members contains user → return list | 1. List groups for user<br>2. User in no groups (empty) | **NOT STARTED** |
|
||||
| `attach-group-policy` | — | `--group-name`, `--policy-arn` | Group and policy must exist | IAMService.AttachGroupPolicy(accountID, input) → verify policy exists → add ARN to group's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent group (NoSuchEntity) | **NOT STARTED** |
|
||||
| `detach-group-policy` | — | `--group-name`, `--policy-arn` | Policy must be attached | IAMService.DetachGroupPolicy(accountID, input) → remove ARN from AttachedPolicies | 1. Detach policy<br>2. Not attached (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-attached-group-policies` | — | `--group-name`, `--path-prefix`, `--max-items`, `--marker` | Group must exist | IAMService.ListAttachedGroupPolicies(accountID, input) → reads group's AttachedPolicies → returns ARNs and names | 1. List attached policies<br>2. No policies (empty) | **NOT STARTED** |
|
||||
|
||||
### STS (Security Token Service)
|
||||
|
||||
STS provides temporary credentials and identity verification. `GetCallerIdentity` is called by every AWS SDK/CLI invocation. `AssumeRole` is required once IAM roles are implemented. STS needs its own gateway service endpoint (`sts` service in the `supportedServices` map) with a dedicated action map.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `get-caller-identity` | — | *(no flags)* | Valid SigV4 credentials | Gateway extracts accountID and username from authenticated request → returns Account, Arn (`arn:aws:iam::{accountID}:user/{userName}` or `arn:aws:sts::{accountID}:assumed-role/{roleName}/{sessionName}`), UserId. Note: a Spinifex-custom version exists at `spinifex.GetCallerIdentity` but needs a proper STS endpoint. | 1. Get identity for IAM user<br>2. Get identity for assumed role<br>3. Get identity for root user<br>4. Invalid credentials (AuthFailure) | **NOT STARTED** |
|
||||
| `assume-role` | — | `--role-arn`, `--role-session-name`, `--duration-seconds`, `--policy`, `--external-id`, `--serial-number`, `--token-code`, `--tags`, `--transitive-tag-keys` | Role must exist, caller must be allowed by trust policy | Gateway validates RoleArn + RoleSessionName (required) → looks up role → evaluates trust policy (AssumeRolePolicyDocument) against caller → generates temporary credentials (AccessKeyId with ASIA prefix, SecretAccessKey, SessionToken) → credentials expire after DurationSeconds (default 3600, max 43200) → return Credentials + AssumedRoleUser (ARN, AssumedRoleId) | 1. Assume role with valid trust policy<br>2. Trust policy denies caller (AccessDenied)<br>3. Non-existent role (NoSuchEntity)<br>4. Expired session token rejected at SigV4<br>5. Custom duration<br>6. Session tags | **NOT STARTED** |
|
||||
| `get-session-token` | — | `--duration-seconds`, `--serial-number`, `--token-code` | Valid IAM user credentials (not role) | Gateway validates caller is IAM user (not assumed role) → generates temporary credentials (ASIA prefix) → session inherits user's permissions → return Credentials | 1. Get token for IAM user<br>2. Duration within range<br>3. Reject if caller is assumed role | **NOT STARTED** |
|
||||
|
||||
### ELBv2 (Elastic Load Balancing v2 — Application & Network Load Balancer)
|
||||
|
||||
All ELBv2 resources are stored in the `spinifex-elbv2` JetStream KV bucket. Key format: `lb.{lbId}`, `tg.{tgId}`, `listener.{listenerId}`. The data plane uses a system-managed LB VM running HAProxy, launched automatically during CreateLoadBalancer. HAProxy configuration is pushed via NATS (`elbv2.lb.{lbId}.config` topic) whenever listeners or targets change. Agent readiness is polled via `elbv2.lb.{lbId}.ping`.
|
||||
|
||||
#### ELBv2 - Load Balancers
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-load-balancer` | `--name`, `--subnets`, `--security-groups`, `--scheme` (internet-facing/internal), `--tags`, `--ip-address-type` (ipv4 only) | `--type` (hardcoded application), `--customer-owned-ipv4-pool`, `--dry-run` | None (subnets optional) | Gateway validates Name (required) → NATS `elbv2.CreateLoadBalancer` with `spinifex-workers` queue group → daemon validates scheme (internet-facing or internal, InvalidScheme if other) → checks duplicate name in account (DuplicateLoadBalancerName) → generates LB ID → builds DNS name `{name}-{lbId}.{region}.elb.spinifex.local` → if subnets provided, creates one ENI per subnet with `spinifex:managed-by=elbv2` tag (rolls back on failure) → if system AMI available, launches sys.micro LB VM with cloud-init running `/usr/local/bin/lb-agent` → state=provisioning → background goroutine polls agent ping for ~5min → on agent ready: state=active, on timeout: state=failed → if no VM launcher: state=active immediately → persists to KV → returns LoadBalancer with ARN, DNSName, State | 1. Create with name (happy path)<br>2. Internal scheme<br>3. Invalid scheme (InvalidScheme)<br>4. Duplicate name (DuplicateLoadBalancerName)<br>5. Missing name (MissingParameter)<br>6. With tags<br>7. With subnets (ENI creation) | **DONE** |
|
||||
| `delete-load-balancer` | `--load-balancer-arn` | `--dry-run` | LB must exist | Gateway validates LoadBalancerArn (required) → NATS `elbv2.DeleteLoadBalancer` → daemon verifies LB exists and belongs to account → terminates LB VM (async background) → deletes all listeners for this LB → deletes system-managed ENIs → deletes LB record from KV → returns success | 1. Delete existing LB<br>2. Not found (LoadBalancerNotFound)<br>3. Cascades listener cleanup<br>4. ENI cleanup | **DONE** |
|
||||
| `describe-load-balancers` | `--load-balancer-arns`, `--names` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeLoadBalancers` → daemon lists all LB records from KV → filters by account ID → applies ARN filter and/or Name filter → returns LoadBalancer list with State, DNSName, Scheme, VpcId, AvailabilityZones | 1. List all LBs<br>2. Filter by name<br>3. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Target Groups
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-target-group` | `--name`, `--protocol` (HTTP), `--port` (default 80), `--vpc-id`, `--target-type` (instance only), `--health-check-protocol`, `--health-check-port`, `--health-check-path` (default /), `--health-check-interval-seconds` (default 30), `--health-check-timeout-seconds` (default 5), `--healthy-threshold-count` (default 5), `--unhealthy-threshold-count` (default 2), `--matcher` (HttpCode, default 200), `--tags` | `--health-check-enabled`, `--protocol-version`, `--ip-address-type`, `--dry-run` | None | Gateway validates Name (required) → NATS `elbv2.CreateTargetGroup` → daemon checks duplicate name in same VPC (DuplicateTargetGroupName) → generates TG ID → creates HealthCheckConfig with provided values or defaults → stores record with empty Targets array → returns TargetGroup with ARN | 1. Create with defaults<br>2. Custom health check<br>3. Duplicate name (DuplicateTargetGroupName)<br>4. Missing name (MissingParameter) | **DONE** |
|
||||
| `delete-target-group` | `--target-group-arn` | `--dry-run` | TG must exist, not referenced by listener | Gateway validates TargetGroupArn (required) → NATS `elbv2.DeleteTargetGroup` → daemon verifies TG exists → scans all listeners to check if any DefaultActions reference this TG (TargetGroupInUse if referenced) → deletes from KV | 1. Delete TG<br>2. TG in use by listener (TargetGroupInUse)<br>3. Not found (TargetGroupNotFound) | **DONE** |
|
||||
| `describe-target-groups` | `--target-group-arns`, `--names`, `--load-balancer-arn` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeTargetGroups` → daemon lists all TG records → filters by account → applies ARN/Name filters → if LoadBalancerArn specified, lists listeners for that LB and includes only TGs referenced by those listeners → returns TargetGroup list | 1. List all TGs<br>2. Filter by LB ARN (via listener lookup)<br>3. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Targets
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `register-targets` | `--target-group-arn`, `--targets` (Id=instance-id, Port=optional override) | `--dry-run` | TG must exist, instances should exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.RegisterTargets` → daemon fetches TG → deduplicates against existing targets (key: `{id}:{port}`, idempotent) → resolves instance ID → private IP via VPC ENI lookup → adds target with state=initial → persists to KV → calls `pushConfigForTargetGroup()` to regenerate HAProxy config on all LBs using this TG → returns success | 1. Register targets<br>2. Idempotent re-registration<br>3. TG not found (TargetGroupNotFound)<br>4. Missing ARN or targets (MissingParameter) | **DONE** |
|
||||
| `deregister-targets` | `--target-group-arn`, `--targets` (Id, Port) | `--dry-run` | TG must exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.DeregisterTargets` → daemon fetches TG → builds removal set (key: `{id}:{port}`) → filters Targets array to keep only non-matching → persists to KV → calls `pushConfigForTargetGroup()` to update HAProxy config → returns success | 1. Deregister targets<br>2. TG not found (TargetGroupNotFound)<br>3. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `describe-target-health` | `--target-group-arn`, `--targets` (optional filter to specific targets) | `--include` | TG must exist | Gateway validates TargetGroupArn (required) → NATS `elbv2.DescribeTargetHealth` → daemon fetches TG → builds filter set from Targets param (if specified) → for each target, returns TargetHealthDescription with Target (Id, Port) and TargetHealth (State, Description). Note: health state is stored in records, not actively polled — HAProxy runs its own independent health checks. | 1. Describe all targets<br>2. Filter to specific targets<br>3. TG not found (TargetGroupNotFound) | **DONE** |
|
||||
|
||||
#### ELBv2 - Listeners
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP, default), `--port` (default 80) | `--ssl-policy`, `--certificates`, `--alpn-policy`, `--mutual-authentication`, `--dry-run` | LB must exist | Gateway validates LoadBalancerArn + DefaultActions (required) → NATS `elbv2.CreateListener` → daemon verifies LB exists → checks for duplicate port on same LB (DuplicateListener) → generates listener ID → builds ARN → stores ListenerRecord in KV → calls `pushConfig(lb)` to regenerate HAProxy config (creates frontend for listener port, backend for target group with health checks) → returns Listener with ARN, Protocol, Port, DefaultActions | 1. Create listener<br>2. Duplicate port (DuplicateListener)<br>3. LB not found (LoadBalancerNotFound)<br>4. Graceful handling when NATS unavailable | **DONE** |
|
||||
| `delete-listener` | `--listener-arn` | `--dry-run` | Listener must exist | Gateway validates ListenerArn (required) → NATS `elbv2.DeleteListener` → daemon verifies listener exists and belongs to account → deletes from KV → fetches parent LB → calls `pushConfig(lb)` to regenerate HAProxy config without this listener → returns success | 1. Delete listener<br>2. Not found (ListenerNotFound) | **DONE** |
|
||||
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeListeners` → daemon lists listeners → if LoadBalancerArn specified, filters to that LB → applies ARN filter → account isolation → returns Listener list | 1. Filter by LB ARN<br>2. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Tags
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-tags` | `--resource-arns` (loadbalancer, targetgroup, listener) | — | Resource(s) must exist | Gateway validates non-empty ResourceArns → NATS `elbv2.DescribeTags` → daemon parses each ARN's resource segment to dispatch by type → looks up record (LB/TG/listener) → enforces account isolation (cross-account → not-found) → returns TagDescriptions in input order with sorted-by-key Tag list. Listener records don't store tags yet, so they always return an empty Tags slice (matches AWS behaviour for untagged resources). Required by Terraform AWS provider post-create refresh on `aws_lb`, `aws_lb_target_group`, and `aws_lb_listener`. | 1. LB tags round-trip<br>2. TG tags round-trip<br>3. Listener returns empty Tags (no error)<br>4. Multiple ARNs in one call<br>5. Unknown LB/TG/listener (per-type not-found)<br>6. Cross-account ARN (not-found, no leak)<br>7. Invalid/non-ELBv2 ARN (InvalidParameterValue)<br>8. Empty/nil ResourceArns (MissingParameter)<br>9. Untagged LB returns empty Tags | **DONE** |
|
||||
|
||||
#### ELBv2 - Not Implemented
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|--------|
|
||||
| Listener rules | Path-based and hostname-based routing (CreateRule, DeleteRule, DescribeRules, ModifyRule) | High | **NOT STARTED** |
|
||||
| HTTPS/TLS termination | SSL certificates, SSL policies, ALPN | High | **NOT STARTED** |
|
||||
| Modify operations | ModifyLoadBalancerAttributes, ModifyTargetGroup, ModifyTargetGroupAttributes, ModifyListener | Medium | **NOT STARTED** |
|
||||
| Connection draining | Deregistration delay for graceful target removal | Medium | **NOT STARTED** |
|
||||
| Stickiness | Session affinity / sticky sessions on target groups | Medium | **NOT STARTED** |
|
||||
| Active health checking | API-driven health state updates (currently HAProxy-only) | Medium | **NOT STARTED** |
|
||||
| Target types | IP and Lambda target types (only instance supported) | Low | **NOT STARTED** |
|
||||
| Access logs | S3 access log delivery | Low | **NOT STARTED** |
|
||||
| WAF integration | AWS WAF association for web application firewall | Low | **NOT STARTED** |
|
||||
|
||||
### CloudWatch (Basic Monitoring)
|
||||
|
||||
Basic CloudWatch metrics support is required for `monitor-instances`/`unmonitor-instances` to be meaningful. Daemon nodes should publish CPU, disk, and network metrics for running instances. Metrics stored in a time-series-friendly structure in JetStream KV or dedicated NATS subjects.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `put-metric-data` | — | `--namespace`, `--metric-data` (MetricName, Value, Unit, Timestamp, Dimensions) | None | Gateway validates Namespace + MetricData (required) → NATS `cloudwatch.PutMetricData` → daemon stores metric data points in time-series KV (key: `{namespace}.{metricName}.{dimensionHash}`) → return success | 1. Put single metric<br>2. Put with dimensions (InstanceId)<br>3. Missing namespace (MissingParameter) | **NOT STARTED** |
|
||||
| `get-metric-statistics` | — | `--namespace`, `--metric-name`, `--start-time`, `--end-time`, `--period`, `--statistics` (Average, Sum, Min, Max, SampleCount), `--dimensions` | None | Gateway validates required fields → NATS `cloudwatch.GetMetricStatistics` → daemon queries time-series data → aggregates by period → returns Datapoints with requested statistics | 1. Get CPU utilization over 1 hour<br>2. Get with dimensions (InstanceId)<br>3. No data returns empty | **NOT STARTED** |
|
||||
| `list-metrics` | — | `--namespace`, `--metric-name`, `--dimensions`, `--recently-active` | None | NATS `cloudwatch.ListMetrics` → daemon scans metric namespaces → returns Metrics list with Namespace, MetricName, Dimensions | 1. List all metrics<br>2. Filter by namespace<br>3. Filter by metric name | **NOT STARTED** |
|
||||
| `describe-alarms` | — | `--alarm-names`, `--alarm-name-prefix`, `--state-value`, `--action-prefix` | None | NATS `cloudwatch.DescribeAlarms` → daemon lists alarms from KV → return MetricAlarms with State, Threshold, ComparisonOperator | 1. List all alarms<br>2. Filter by state (OK, ALARM, INSUFFICIENT_DATA) | **NOT STARTED** |
|
||||
| `put-metric-alarm` | — | `--alarm-name`, `--namespace`, `--metric-name`, `--statistic`, `--period`, `--evaluation-periods`, `--threshold`, `--comparison-operator`, `--alarm-actions`, `--dimensions` | None | NATS `cloudwatch.PutMetricAlarm` → daemon stores alarm config in KV → alarm evaluation runs on metric publish → state transitions trigger alarm actions | 1. Create alarm<br>2. Update existing alarm<br>3. Missing required fields | **NOT STARTED** |
|
||||
| `delete-alarms` | — | `--alarm-names` | Alarms must exist | NATS `cloudwatch.DeleteAlarms` → daemon deletes alarm records from KV → return success | 1. Delete existing alarm<br>2. Non-existent alarm (ResourceNotFound) | **NOT STARTED** |
|
||||
|
||||
### Auto Scaling
|
||||
|
||||
Auto Scaling manages instance fleets based on scaling policies. Depends on Launch Templates (EC2) and optionally ELBv2 target groups for health-based scaling. Stored in `spinifex-autoscaling` KV bucket.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-auto-scaling-group` | — | `--auto-scaling-group-name`, `--launch-template` (Id/Name, Version), `--min-size`, `--max-size`, `--desired-capacity`, `--vpc-zone-identifier` (subnet IDs), `--target-group-arns`, `--health-check-type` (EC2/ELB), `--health-check-grace-period`, `--tags` | Launch template must exist | NATS `autoscaling.CreateAutoScalingGroup` → daemon validates launch template → stores ASG record in KV → launches DesiredCapacity instances using template → registers with target groups if specified → starts health check loop → return success | 1. Create ASG with launch template<br>2. Scale to desired capacity<br>3. Missing launch template (ValidationError)<br>4. Min > Max (ValidationError) | **NOT STARTED** |
|
||||
| `update-auto-scaling-group` | — | `--auto-scaling-group-name`, `--min-size`, `--max-size`, `--desired-capacity`, `--launch-template`, `--health-check-type`, `--health-check-grace-period` | ASG must exist | NATS `autoscaling.UpdateAutoScalingGroup` → daemon updates ASG config in KV → if DesiredCapacity changed, scale up/down accordingly → return success | 1. Update desired capacity<br>2. Update launch template<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
|
||||
| `delete-auto-scaling-group` | — | `--auto-scaling-group-name`, `--force-delete` | ASG must exist | NATS `autoscaling.DeleteAutoScalingGroup` → if force: terminate all instances → deregister from target groups → delete from KV → return success. Without force: reject if instances > 0 | 1. Delete empty ASG<br>2. Force delete with instances<br>3. Delete with instances (ResourceInUse) | **NOT STARTED** |
|
||||
| `describe-auto-scaling-groups` | — | `--auto-scaling-group-names`, `--filters`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingGroups` → daemon lists ASG records → return list with Instances, DesiredCapacity, MinSize, MaxSize | 1. List all ASGs<br>2. Filter by name | **NOT STARTED** |
|
||||
| `set-desired-capacity` | — | `--auto-scaling-group-name`, `--desired-capacity`, `--honor-cooldown` | ASG must exist | NATS `autoscaling.SetDesiredCapacity` → daemon updates desired count → scale up (launch) or scale down (terminate oldest) → return success | 1. Scale up<br>2. Scale down<br>3. Exceeds max (ValidationError) | **NOT STARTED** |
|
||||
| `describe-auto-scaling-instances` | — | `--instance-ids`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingInstances` → daemon lists all ASG-managed instances → return list with ASG name, lifecycle state, health status | 1. List all ASG instances<br>2. Filter by instance ID | **NOT STARTED** |
|
||||
| `put-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name`, `--policy-type` (TargetTrackingScaling, StepScaling, SimpleScaling), `--target-tracking-configuration`, `--scaling-adjustment`, `--cooldown` | ASG must exist | NATS `autoscaling.PutScalingPolicy` → daemon stores policy in KV → links to CloudWatch alarms for metric-based triggers → return PolicyARN | 1. Create target tracking policy<br>2. Create step scaling policy<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
|
||||
| `delete-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name` | Policy must exist | NATS `autoscaling.DeleteScalingPolicy` → daemon deletes policy → remove CloudWatch alarm links → return success | 1. Delete existing policy<br>2. Non-existent policy | **NOT STARTED** |
|
||||
|
||||
@@ -4,181 +4,129 @@ Spinifex is an AWS-compatible infrastructure platform for bare-metal, edge, and
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ AWS SDK / CLI │
|
||||
│ (aws ec2 run-instances --endpoint-url ...) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS (SigV4 Auth)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ AWS Gateway (Port 9999) │
|
||||
│ │
|
||||
│ • TLS termination • AWS SigV4 authentication │
|
||||
│ • EC2 Query Protocol parsing • Routes to service handlers │
|
||||
│ • XML response generation • NATS client connection │
|
||||
│ │
|
||||
│ File: spinifex/services/awsgw/awsgw.go │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ JSON over NATS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NATS Message Broker │
|
||||
│ │
|
||||
│ Topics: Queue Groups: │
|
||||
│ • ec2.RunInstances • spinifex-workers (load balanced) │
|
||||
│ • ec2.DescribeInstances • No queue (fan-out to all nodes) │
|
||||
│ • ec2.cmd.{instanceID} (per-instance start/stop/terminate) │
|
||||
│ • ebs.mount / ebs.unmount │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ Spinifex Daemon │ │ Spinifex Daemon │ │ Spinifex Daemon │
|
||||
│ (Node 1) │ │ (Node 2) │ │ (Node N) │
|
||||
│ │ │ │ │ │
|
||||
│ • NATS subscriber │ │ • NATS subscriber │ │ • NATS subscriber │
|
||||
│ • Resource manager │ │ • Resource manager │ │ • Resource manager │
|
||||
│ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │
|
||||
│ • QMP monitoring │ │ • QMP monitoring │ │ • QMP monitoring │
|
||||
│ │ │ │ │ │
|
||||
│ File: daemon.go │ │ File: daemon.go │ │ File: daemon.go │
|
||||
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ QEMU/KVM VMs │ │ QEMU/KVM VMs │ │ QEMU/KVM VMs │
|
||||
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
<p align="center">
|
||||
<img src="../.github/assets/design-architecture.svg" alt="Spinifex high-level architecture — AWS SDK, gateway, NATS broker, per-node daemons, QEMU/KVM VMs" width="900">
|
||||
</p>
|
||||
|
||||
## Request Flow
|
||||
|
||||
### 1. AWS SDK Request
|
||||
|
||||
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by pointing to a custom endpoint:
|
||||
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by using the spinifex profile.
|
||||
|
||||
```bash
|
||||
aws --endpoint-url https://localhost:9999 --no-verify-ssl ec2 run-instances \
|
||||
AWS_PROFILE=spinifex aws ec2 run-instances \
|
||||
--image-id ami-debian12 \
|
||||
--instance-type t3.micro \
|
||||
--key-name my-keypair
|
||||
```
|
||||
|
||||
The AWS SDK formats this as an HTTP POST with:
|
||||
The AWS SDK formats this as an HTTPS POST with:
|
||||
- AWS SigV4 authentication headers
|
||||
- EC2 Query Protocol body (Action=RunInstances&ImageId=ami-debian12...)
|
||||
- EC2 Query Protocol body (`Action=RunInstances&ImageId=ami-debian12&...`)
|
||||
|
||||
### 2. AWS Gateway
|
||||
|
||||
The gateway (`spinifex/services/awsgw/awsgw.go:57-107`) is the entry point:
|
||||
The gateway (`spinifex/services/awsgw/awsgw.go`) is the entry point:
|
||||
|
||||
```go
|
||||
// awsgw.go:74 - Connect to NATS
|
||||
natsConn, err := nats.Connect(nodeConfig.NATS.Host, opts...)
|
||||
// Connect to NATS (retries while the local broker comes up)
|
||||
natsConn, err := utils.ConnectNATSWithRetry(...)
|
||||
|
||||
// awsgw.go:89-94 - Create gateway with NATS connection
|
||||
// Load IAM (master key + JetStream KV) and create the gateway
|
||||
gw := gateway.GatewayConfig{
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
IAMService: iamService,
|
||||
// ...
|
||||
}
|
||||
|
||||
// awsgw.go:103 - Start TLS listener
|
||||
log.Fatal(app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey))
|
||||
// Serve over TLS
|
||||
server.ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
Request routing (`spinifex/gateway/gateway.go:125-156`):
|
||||
Request routing (`spinifex/gateway/gateway.go`):
|
||||
|
||||
1. **Authentication**: SigV4 middleware validates AWS credentials (`gateway.go:104`)
|
||||
2. **Service Detection**: Extracts service name (ec2, iam, account) from Authorization header (`gateway.go:158-186`)
|
||||
3. **Action Dispatch**: Routes to service-specific handler (`gateway.go:136-145`)
|
||||
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
|
||||
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
|
||||
3. **Service Detection**: Extracts service name (ec2, iam, account, elasticloadbalancing, spinifex) from the Authorization header
|
||||
4. **Action Dispatch**: Routes to service-specific handler
|
||||
|
||||
```go
|
||||
// gateway.go:136-145
|
||||
switch svc {
|
||||
case "ec2":
|
||||
err = gw.EC2_Request(ctx)
|
||||
err = gw.EC2_Request(w, r)
|
||||
case "account":
|
||||
err = gw.Account_Request(ctx)
|
||||
err = gw.Account_Request(w, r)
|
||||
case "iam":
|
||||
err = gw.IAM_Request(ctx)
|
||||
err = gw.IAM_Request(w, r)
|
||||
case "elasticloadbalancing":
|
||||
err = gw.ELBv2_Request(w, r)
|
||||
case "spinifex":
|
||||
err = gw.Spinifex_Request(w, r)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. EC2 Handler
|
||||
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via a generic `ec2Handler` wrapper. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
|
||||
|
||||
```go
|
||||
// ec2.go - Action routing
|
||||
switch action {
|
||||
case "RunInstances":
|
||||
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
|
||||
case "DescribeInstances":
|
||||
output, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, expectedNodes)
|
||||
// ... more actions
|
||||
}
|
||||
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.RunInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
|
||||
}),
|
||||
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
|
||||
// security groups, network interfaces, placement groups, key pairs, images, tags
|
||||
```
|
||||
|
||||
### 4. NATS Messaging
|
||||
|
||||
The gateway communicates with daemons via NATS request/response (`spinifex/handlers/ec2/instance/service_nats.go:24-57`):
|
||||
The gateway communicates with daemons via NATS request/response. Most calls go through `utils.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
|
||||
|
||||
```go
|
||||
// service_nats.go:24-57
|
||||
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
|
||||
// Marshal input to JSON
|
||||
jsonData, err := json.Marshal(input)
|
||||
|
||||
// Send request to daemon via NATS with 5 minute timeout
|
||||
msg, err := s.natsConn.Request("ec2.RunInstances", jsonData, 5*time.Minute)
|
||||
|
||||
// Unmarshal response
|
||||
var reservation ec2.Reservation
|
||||
err = json.Unmarshal(msg.Data, &reservation)
|
||||
|
||||
return &reservation, nil
|
||||
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
|
||||
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
|
||||
return utils.NATSRequest[ec2.Reservation](s.natsConn, topic, input, 5*time.Minute, accountID)
|
||||
}
|
||||
```
|
||||
|
||||
`RunInstances` uses a per-instance-type subject so NATS only delivers the request to a node with spare capacity for that type — no application-level reject-and-retry.
|
||||
|
||||
### 5. Daemon Processing
|
||||
|
||||
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests:
|
||||
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests. A table-driven `subscribeAll()` registers the static EC2/ELBv2 surface at startup:
|
||||
|
||||
```go
|
||||
// daemon.go:319 - Subscribe with queue group for load balancing
|
||||
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
|
||||
"ec2.RunInstances",
|
||||
"spinifex-workers", // Queue group - only one daemon handles each request
|
||||
d.handleEC2RunInstances,
|
||||
)
|
||||
// Queue group → load-balanced (one daemon handles each request)
|
||||
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "spinifex-workers"},
|
||||
|
||||
// daemon.go:373 - Subscribe without queue group for fan-out
|
||||
d.natsSubscriptions["ec2.DescribeInstances"], err = d.natsConn.Subscribe(
|
||||
"ec2.DescribeInstances",
|
||||
d.handleEC2DescribeInstances, // All daemons respond
|
||||
)
|
||||
// No queue group → fan-out (every daemon responds)
|
||||
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
|
||||
```
|
||||
|
||||
**Queue Groups**: Topics subscribed with a queue group (`spinifex-workers`) are load-balanced - only one daemon handles each request. Topics without a queue group fan out to all daemons.
|
||||
`ec2.RunInstances` is dynamic: `ResourceManager` subscribes to `ec2.RunInstances.{type}` (and `ec2.RunInstances.{type}.{nodeId}` for targeted dispatch) only while the node has capacity for that type, and unsubscribes when full. Per-instance commands flow over `ec2.cmd.{instanceID}`, subscribed only by the owning node.
|
||||
|
||||
**Queue Groups**: topics with the `spinifex-workers` queue group are load-balanced — only one daemon handles each request. Topics without a queue group fan out to all daemons.
|
||||
|
||||
### 6. VM Launch
|
||||
|
||||
When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability (`daemon.go:66-74`)
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot, cloud-init, and EFI volumes via Viperblock
|
||||
3. **Volume Mount**: Sends `ebs.mount` request to Viperblock, receives NBD URI
|
||||
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
|
||||
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
|
||||
6. **Command subscription**: Subscribes to `ec2.cmd.{instanceID}` for subsequent commands on this VM
|
||||
|
||||
```go
|
||||
// daemon.go - Volume mounting via NATS
|
||||
// Volume mounting via NATS
|
||||
msg, err := d.natsConn.Request("ebs.mount", ebsMountRequest, 10*time.Second)
|
||||
|
||||
// daemon.go - QEMU launch with NBD storage
|
||||
// QEMU launch with NBD storage
|
||||
cmd := exec.Command("qemu-system-x86_64",
|
||||
"-drive", fmt.Sprintf("file=nbd:%s,format=raw", nbdURI),
|
||||
// ... additional QEMU args
|
||||
@@ -190,66 +138,56 @@ cmd := exec.Command("qemu-system-x86_64",
|
||||
### Daemon Structure
|
||||
|
||||
```go
|
||||
// daemon.go:76-102
|
||||
type Daemon struct {
|
||||
node string // Node identifier
|
||||
clusterConfig *config.ClusterConfig // Cluster-wide configuration
|
||||
config *config.Config // Node-specific configuration
|
||||
natsConn *nats.Conn // NATS connection
|
||||
resourceMgr *ResourceManager // CPU/Memory tracking
|
||||
instanceService *InstanceServiceImpl // EC2 instance operations
|
||||
keyService *KeyServiceImpl // SSH key operations
|
||||
imageService *ImageServiceImpl // AMI operations
|
||||
Instances vm.Instances // Local VMs
|
||||
clusterApp *fiber.App // HTTP cluster manager
|
||||
node string // Node identifier
|
||||
clusterConfig *config.ClusterConfig // Cluster-wide configuration
|
||||
config *config.Config // Node-specific configuration
|
||||
natsConn *nats.Conn // NATS connection
|
||||
resourceMgr *ResourceManager // CPU/Memory tracking + dynamic RunInstances subscriptions
|
||||
Instances vm.Instances // Local VMs
|
||||
// ... + one service struct per EC2/ELBv2 resource
|
||||
// (instance, key, image, volume, snapshot, tags, vpc, subnet,
|
||||
// igw, eigw, natgw, routetable, eip, placementgroup, elbv2, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Manager
|
||||
|
||||
Tracks available and allocated CPU/memory to prevent overcommit:
|
||||
Tracks available and allocated CPU/memory to prevent overcommit, and drives the dynamic `ec2.RunInstances.{type}` subscriptions:
|
||||
|
||||
```go
|
||||
// daemon.go:66-74
|
||||
type ResourceManager struct {
|
||||
mu sync.RWMutex
|
||||
availableVCPU int
|
||||
availableMem float64
|
||||
hostVCPU int // raw runtime.NumCPU
|
||||
hostMemGB float64 // raw /proc/meminfo MemTotal
|
||||
reservedVCPU int // held back for spinifex services
|
||||
reservedMem float64 // held back for spinifex services
|
||||
allocatedVCPU int
|
||||
allocatedMem float64
|
||||
instanceTypes map[string]InstanceType // t3.micro, t3.small, etc.
|
||||
instanceTypes map[string]*ec2.InstanceTypeInfo // t3.micro, t3.small, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### NATS Topics
|
||||
|
||||
| Topic | Queue Group | Purpose |
|
||||
|-------|-------------|---------|
|
||||
| `ec2.RunInstances` | `spinifex-workers` | Launch new instances |
|
||||
| `ec2.DescribeInstances` | None (fan-out) | Query all nodes for instances |
|
||||
| `ec2.cmd.{instanceID}` | None (owner only) | Per-instance commands (start/stop/terminate, attach/detach volume) |
|
||||
| `ec2.CreateKeyPair` | `spinifex-workers` | Generate SSH keypair |
|
||||
| `ec2.DescribeKeyPairs` | `spinifex-workers` | List SSH keypairs |
|
||||
| `ec2.DescribeImages` | `spinifex-workers` | List AMIs |
|
||||
| `ebs.mount` | - | Mount volume via Viperblock |
|
||||
| `ebs.unmount` | - | Unmount volume |
|
||||
| `spinifex.admin.{node}.health` | None | Node health checks |
|
||||
|
||||
### Multi-Node Aggregation
|
||||
|
||||
For operations that need data from all nodes (like `DescribeInstances`), the gateway uses inbox-based fan-out (`spinifex/gateway/ec2/instance/DescribeInstances.go`):
|
||||
|
||||
```go
|
||||
// DescribeInstances.go:16-111
|
||||
func DescribeInstances(...) {
|
||||
// Create unique inbox for collecting responses
|
||||
inbox := nats.NewInbox()
|
||||
sub, err := natsConn.SubscribeSync(inbox)
|
||||
sub, _ := natsConn.SubscribeSync(inbox)
|
||||
|
||||
// Publish to all nodes (no queue group = all daemons respond)
|
||||
err = natsConn.PublishRequest("ec2.DescribeInstances", inbox, jsonData)
|
||||
// Publish to all nodes (no queue group = all daemons respond).
|
||||
// Account ID is propagated as a NATS header so each daemon scopes its response.
|
||||
pubMsg := nats.NewMsg("ec2.DescribeInstances")
|
||||
pubMsg.Reply = inbox
|
||||
pubMsg.Data = jsonData
|
||||
pubMsg.Header.Set(utils.AccountIDHeader, accountID)
|
||||
_ = natsConn.PublishMsg(pubMsg)
|
||||
|
||||
// Collect responses from all nodes with 3-second timeout
|
||||
// Collect responses, returning early once expectedNodes have replied
|
||||
// or the 3s timeout is hit.
|
||||
for {
|
||||
msg, err := sub.NextMsg(3 * time.Second)
|
||||
allReservations = append(allReservations, nodeOutput.Reservations...)
|
||||
@@ -300,74 +238,72 @@ type ClusterConfig struct {
|
||||
|
||||
type Config struct {
|
||||
Node, Host, Region, AZ string
|
||||
DataDir string
|
||||
Daemon DaemonConfig // Cluster manager port
|
||||
NATS NATSConfig // NATS broker address
|
||||
Predastore PredastoreConfig // S3 endpoint
|
||||
AWSGW AWSGWConfig // Gateway port, TLS certs
|
||||
AccessKey, SecretKey string
|
||||
BaseDir, DataDir string
|
||||
Daemon DaemonConfig
|
||||
NATS NATSConfig // Broker address, JetStream, ACL token, CA cert
|
||||
AWSGW AWSGWConfig // Gateway host, TLS certs, throttle config
|
||||
Predastore PredastoreConfig // S3 endpoint + service credentials
|
||||
Viperblock ViperblockConfig
|
||||
VPCD VPCDConfig // OVN/OVS endpoints
|
||||
Network NetworkConfig
|
||||
}
|
||||
```
|
||||
|
||||
Configs are generated by `spx admin init` (leader) or `spx admin join` (followers).
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
spinifex/
|
||||
├── spinifex/
|
||||
│ ├── services/
|
||||
│ │ └── awsgw/
|
||||
│ │ └── awsgw.go # AWS Gateway service entry point
|
||||
│ ├── gateway/
|
||||
│ │ ├── gateway.go # Request routing, auth middleware
|
||||
│ │ ├── ec2.go # EC2 action dispatcher
|
||||
│ │ └── ec2/
|
||||
│ │ └── instance/
|
||||
│ │ ├── RunInstances.go
|
||||
│ │ └── DescribeInstances.go
|
||||
│ ├── handlers/
|
||||
│ │ └── ec2/
|
||||
│ │ ├── instance/
|
||||
│ │ │ ├── service_nats.go # NATS request/response
|
||||
│ │ │ └── service_impl.go # Business logic
|
||||
│ │ ├── key/
|
||||
│ │ └── image/
|
||||
│ ├── services/ # Long-running services (awsgw, spinifex, viperblockd,
|
||||
│ │ # predastore, nats, vpcd, 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 point, NATS subscriptions
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Configuration structures
|
||||
│ ├── vm/
|
||||
│ │ └── instance.go # VM instance representation
|
||||
│ └── qmp/
|
||||
│ └── qmp.go # QEMU Machine Protocol client
|
||||
│ │ ├── 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
|
||||
│ └── qmp/ # QEMU Machine Protocol client
|
||||
└── cmd/
|
||||
└── spinifex/
|
||||
└── main.go # CLI entry point
|
||||
├── spinifex/ # spx CLI
|
||||
├── lb-agent/ # ELB target agent binary
|
||||
└── installer/ # Platform installer
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running the Stack
|
||||
|
||||
1. Start NATS broker
|
||||
2. Start Predastore (S3)
|
||||
3. Start Viperblock (EBS)
|
||||
4. Start Spinifex Gateway (`spx awsgw`)
|
||||
5. Start Spinifex Daemon(s) (`spx daemon`)
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
`spx admin init` generates the cluster CA, IAM credentials, TLS certs, and AWS CLI `spinifex` profile, then writes config to `/etc/spinifex/` and data to `/var/lib/spinifex/`. `spinifex.target` brings up all services (NATS, Predastore, Viperblock, vpcd, the daemon, awsgw, the UI) under systemd. Use `spx admin join` for additional nodes.
|
||||
|
||||
### Testing with AWS CLI
|
||||
|
||||
```bash
|
||||
# Set endpoint
|
||||
export AWS_ENDPOINT_URL=https://localhost:9999
|
||||
export AWS_PROFILE=spinifex
|
||||
|
||||
# Run instance
|
||||
aws --no-verify-ssl ec2 run-instances \
|
||||
--image-id ami-debian12 \
|
||||
--instance-type t3.micro
|
||||
aws ec2 run-instances --image-id ami-debian12 --instance-type t3.micro
|
||||
|
||||
# List instances (queries all nodes)
|
||||
aws --no-verify-ssl ec2 describe-instances
|
||||
aws ec2 describe-instances
|
||||
|
||||
# Terminate instance
|
||||
aws --no-verify-ssl ec2 terminate-instances --instance-ids i-xxxxx
|
||||
aws ec2 terminate-instances --instance-ids i-xxxxx
|
||||
```
|
||||
|
||||
For cluster-state inspection that bypasses the AWS API surface, use the `spx` CLI directly: `spx get nodes`, `spx get vms`, `spx top nodes`.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Spinifex Tag Conventions
|
||||
|
||||
Spinifex uses a small set of reserved tag keys to mark resources that are
|
||||
owned and managed by the platform itself (as opposed to resources created
|
||||
directly by customer AWS API calls). The UI uses these tags to hide
|
||||
system-owned resources from customer-facing listings so operators do not
|
||||
mistake them for user workloads and accidentally terminate or modify
|
||||
them.
|
||||
|
||||
## `spinifex:managed-by`
|
||||
|
||||
Identifies the Spinifex platform component that owns a resource.
|
||||
|
||||
| Value | Component | Tagged resource types |
|
||||
|---------|---------------------------------|---------------------------------------|
|
||||
| `elbv2` | ALB / HAProxy load balancers | LB VM (`instance`), LB ENI, LB AMI |
|
||||
|
||||
The tag is applied by the backend at create/launch time:
|
||||
|
||||
- **Instance** — set in
|
||||
`spinifex/spinifex/daemon/daemon_system_instance.go` when
|
||||
`LaunchSystemInstance` builds the `RunInstancesInput`, and mirrored onto
|
||||
the stored `vm.VM.ManagedBy` field for fast lookup.
|
||||
- **ENI** — set in
|
||||
`spinifex/spinifex/handlers/elbv2/service_impl.go` when
|
||||
`CreateLoadBalancer` calls `CreateNetworkInterface`.
|
||||
- **AMI** — declared on the catalog entry in
|
||||
`spinifex/spinifex/utils/images.go` and copied into
|
||||
`AMIMetadata.Tags` by the image importer in
|
||||
`spinifex/cmd/spinifex/cmd/admin.go`.
|
||||
|
||||
Tag keys and values are defined once in `spinifex/spinifex/tags/tags.go`;
|
||||
new system components should register their value there and reuse the
|
||||
constants rather than hardcoding strings.
|
||||
|
||||
## UI behaviour
|
||||
|
||||
The admin **Nodes** page and the **Images** (AMIs) page filter out
|
||||
resources that carry `spinifex:managed-by`. Engineers who need to see
|
||||
system resources for debugging can append `?system=1` to the URL — the
|
||||
filter is bypassed and a hidden-count banner is replaced with the full
|
||||
listing. Direct navigation to a system AMI detail page without
|
||||
`?system=1` renders the "Image not found" fallback.
|
||||
|
||||
The EC2 **Instances** page does not need UI-side filtering: backend
|
||||
SigV4 authentication scopes `DescribeInstances` by account ID, and
|
||||
system instances live under the global system account
|
||||
(`000000000000`), so customers never see them.
|
||||
|
||||
## `spinifex:lb-arn`
|
||||
|
||||
Applied to ELBv2-managed ENIs alongside `spinifex:managed-by=elbv2`.
|
||||
Stores the ARN of the parent load balancer so reconciliation can map an
|
||||
ENI back to its owner without a separate lookup. Customer-facing
|
||||
filtering keys off `spinifex:managed-by`; `spinifex:lb-arn` is
|
||||
informational.
|
||||
|
||||
## Adding a new system component
|
||||
|
||||
1. Add a new value constant next to `ManagedByELBv2` in
|
||||
`spinifex/spinifex/tags/tags.go`.
|
||||
2. Tag every resource the component creates (instance, ENI, AMI,
|
||||
volume, …) with `spinifex:managed-by=<value>`.
|
||||
3. Add the value to the table above so operators can recognise it.
|
||||
4. No frontend changes are required — the UI filters on the key, not
|
||||
the value.
|
||||
@@ -32,19 +32,13 @@ Spinifex supports multi-tenant account isolation. Each account gets its own IAM
|
||||
## Create Account
|
||||
|
||||
```bash
|
||||
./bin/spx admin account create --name myteam
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Credentials not working
|
||||
## Credentials Not Working
|
||||
|
||||
Verify the AWS CLI configuration files exist and contain the correct profile:
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
---
|
||||
title: "IAM Users and Policies"
|
||||
description: "Create IAM users, manage access keys, and control permissions with policies."
|
||||
category: "Administration"
|
||||
tags:
|
||||
- iam
|
||||
- users
|
||||
- policies
|
||||
- access keys
|
||||
- security
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS IAM CLI Reference"
|
||||
url: "https://docs.aws.amazon.com/cli/latest/reference/iam/"
|
||||
- title: "IAM Policy Reference"
|
||||
url: "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html"
|
||||
---
|
||||
|
||||
# IAM Users and Policies
|
||||
|
||||
> Create IAM users, manage access keys, and control permissions with policies.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Users](#users)
|
||||
- [Access Keys](#access-keys)
|
||||
- [Policies](#policies)
|
||||
- [Attaching Policies](#attaching-policies)
|
||||
- [Putting It All Together](#putting-it-all-together)
|
||||
- [Command Reference](#command-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements AWS-compatible IAM with 16 operations covering user management, access key lifecycle, policy CRUD, and policy attachment. All IAM resources are scoped to the account that creates them — users in one account cannot see or modify resources in another.
|
||||
|
||||
When you create an account with `spx admin account create`, Spinifex bootstraps a root user with an `AdministratorAccess` policy and writes the credentials to `~/.aws/credentials`. From there, you use the standard AWS CLI to manage additional users and permissions.
|
||||
|
||||
**How authentication works:** Every AWS CLI request is signed with SigV4 using an access key pair. The gateway verifies the signature, resolves the caller's account, and evaluates attached policies before routing the request. The root user (account `000000000000`) bypasses policy evaluation entirely.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Spinifex cluster with the AWS gateway started
|
||||
- An account created via `spx admin account create` (see [Creating Accounts](/docs/creating-accounts))
|
||||
- AWS CLI configured with the account's profile:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Users
|
||||
|
||||
### Create a User
|
||||
|
||||
```bash
|
||||
aws iam create-user --user-name alice
|
||||
```
|
||||
|
||||
The response includes the user's ARN, unique ID, and creation date:
|
||||
|
||||
```json
|
||||
{
|
||||
"User": {
|
||||
"UserName": "alice",
|
||||
"UserId": "AIDA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:user/alice",
|
||||
"Path": "/",
|
||||
"CreateDate": "2026-03-24T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `--path` to organise users into hierarchical groups:
|
||||
|
||||
```bash
|
||||
aws iam create-user --user-name bob --path /developers/
|
||||
```
|
||||
|
||||
### Get a User
|
||||
|
||||
```bash
|
||||
aws iam get-user --user-name alice
|
||||
```
|
||||
|
||||
### List Users
|
||||
|
||||
```bash
|
||||
aws iam list-users
|
||||
```
|
||||
|
||||
Filter by path prefix:
|
||||
|
||||
```bash
|
||||
aws iam list-users --path-prefix /developers/
|
||||
```
|
||||
|
||||
### Delete a User
|
||||
|
||||
Before deleting a user, remove all access keys and detach all policies:
|
||||
|
||||
```bash
|
||||
# Remove access keys
|
||||
aws iam list-access-keys --user-name alice
|
||||
aws iam delete-access-key --user-name alice --access-key-id AKIA...
|
||||
|
||||
# Detach policies
|
||||
aws iam list-attached-user-policies --user-name alice
|
||||
aws iam detach-user-policy --user-name alice \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
|
||||
|
||||
# Now delete
|
||||
aws iam delete-user --user-name alice
|
||||
```
|
||||
|
||||
## Access Keys
|
||||
|
||||
Access keys are how users authenticate with the AWS CLI. Each user can have up to **2 access keys** at a time, allowing key rotation without downtime.
|
||||
|
||||
### Create an Access Key
|
||||
|
||||
```bash
|
||||
aws iam create-access-key --user-name alice
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"AccessKey": {
|
||||
"UserName": "alice",
|
||||
"AccessKeyId": "AKIA1A2B3C4D5E6F7890ABCD",
|
||||
"Status": "Active",
|
||||
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"CreateDate": "2026-03-24T10:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **The secret access key is only shown once.** Save it immediately. If lost, delete the key and create a new one.
|
||||
|
||||
Add the credentials to `~/.aws/credentials` under a new profile and set `AWS_PROFILE` to use them.
|
||||
|
||||
### List Access Keys
|
||||
|
||||
```bash
|
||||
aws iam list-access-keys --user-name alice
|
||||
```
|
||||
|
||||
### Rotate Access Keys
|
||||
|
||||
Create a second key, update your configuration, then delete the old one:
|
||||
|
||||
```bash
|
||||
# 1. Create new key (while old key still works)
|
||||
aws iam create-access-key --user-name alice
|
||||
|
||||
# 2. Update ~/.aws/credentials with the new key
|
||||
|
||||
# 3. Verify the new key works
|
||||
AWS_PROFILE=spinifex-alice aws sts get-caller-identity
|
||||
|
||||
# 4. Delete the old key
|
||||
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD_KEY_ID
|
||||
```
|
||||
|
||||
### Deactivate an Access Key
|
||||
|
||||
Temporarily disable a key without deleting it:
|
||||
|
||||
```bash
|
||||
aws iam update-access-key --user-name alice \
|
||||
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
|
||||
--status Inactive
|
||||
```
|
||||
|
||||
Reactivate it later:
|
||||
|
||||
```bash
|
||||
aws iam update-access-key --user-name alice \
|
||||
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
|
||||
--status Active
|
||||
```
|
||||
|
||||
## Policies
|
||||
|
||||
Policies are JSON documents that define what actions a user can perform on which resources.
|
||||
|
||||
### Policy Document Format
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each statement has:
|
||||
|
||||
- **Effect** — `Allow` or `Deny`
|
||||
- **Action** — Service actions (e.g. `ec2:RunInstances`, `s3:GetObject`). Supports wildcards: `ec2:*`, `s3:Get*`, or `*` for all actions.
|
||||
- **Resource** — Target resources. Use `*` for all resources.
|
||||
|
||||
**Evaluation order:** An explicit `Deny` always wins. If no statement matches, access is denied by default.
|
||||
|
||||
### Create a Policy
|
||||
|
||||
Save the policy document to a file, then create the policy:
|
||||
|
||||
```bash
|
||||
cat > /tmp/ec2-readonly.json << 'EOF'
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeImages",
|
||||
"ec2:DescribeKeyPairs",
|
||||
"ec2:DescribeSecurityGroups",
|
||||
"ec2:DescribeSubnets",
|
||||
"ec2:DescribeVpcs"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
aws iam create-policy \
|
||||
--policy-name EC2ReadOnly \
|
||||
--policy-document file:///tmp/ec2-readonly.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"Policy": {
|
||||
"PolicyName": "EC2ReadOnly",
|
||||
"PolicyId": "ANPA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:policy/EC2ReadOnly",
|
||||
"Path": "/",
|
||||
"DefaultVersionId": "v1",
|
||||
"CreateDate": "2026-03-24T10:10:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Policy Examples
|
||||
|
||||
**Full administrator access:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**S3 read/write (for hybrid sync, backups):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:ListBucket",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**EC2 operator (launch and manage instances, no VPC changes):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:RunInstances",
|
||||
"ec2:StartInstances",
|
||||
"ec2:StopInstances",
|
||||
"ec2:TerminateInstances",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeImages"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Deny terminate (attach alongside a broader Allow to prevent accidental termination):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": "ec2:TerminateInstances",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get a Policy
|
||||
|
||||
```bash
|
||||
aws iam get-policy \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
### Get the Policy Document
|
||||
|
||||
Use `get-policy-version` with version `v1` to retrieve the actual JSON document:
|
||||
|
||||
```bash
|
||||
aws iam get-policy-version \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly \
|
||||
--version-id v1
|
||||
```
|
||||
|
||||
### List Policies
|
||||
|
||||
```bash
|
||||
aws iam list-policies
|
||||
```
|
||||
|
||||
### Delete a Policy
|
||||
|
||||
A policy must be detached from all users before it can be deleted:
|
||||
|
||||
```bash
|
||||
aws iam delete-policy \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
## Attaching Policies
|
||||
|
||||
Policies have no effect until attached to a user.
|
||||
|
||||
### Attach a Policy to a User
|
||||
|
||||
```bash
|
||||
aws iam attach-user-policy --user-name alice \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
### List a User's Policies
|
||||
|
||||
```bash
|
||||
aws iam list-attached-user-policies --user-name alice
|
||||
```
|
||||
|
||||
### Detach a Policy
|
||||
|
||||
```bash
|
||||
aws iam detach-user-policy --user-name alice \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
## Putting It All Together
|
||||
|
||||
A complete workflow for onboarding a developer with scoped EC2 and S3 access:
|
||||
|
||||
```bash
|
||||
# 1. Create the user
|
||||
aws iam create-user --user-name dev-carol --path /developers/
|
||||
|
||||
# 2. Create an access key
|
||||
aws iam create-access-key --user-name dev-carol
|
||||
# Save the AccessKeyId and SecretAccessKey from the output
|
||||
|
||||
# 3. Create a scoped policy
|
||||
cat > /tmp/dev-policy.json << 'EOF'
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "EC2Access",
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:*"],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "S3ReadOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:ListBucket"],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "NoTerminate",
|
||||
"Effect": "Deny",
|
||||
"Action": "ec2:TerminateInstances",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
aws iam create-policy \
|
||||
--policy-name DeveloperAccess \
|
||||
--policy-document file:///tmp/dev-policy.json
|
||||
|
||||
# 4. Attach the policy
|
||||
aws iam attach-user-policy --user-name dev-carol \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/DeveloperAccess
|
||||
|
||||
# 5. Configure the developer's AWS CLI
|
||||
aws configure --profile spinifex-carol
|
||||
# Enter the access key and secret from step 2
|
||||
|
||||
# 6. Verify
|
||||
AWS_PROFILE=spinifex-carol aws ec2 describe-instances
|
||||
AWS_PROFILE=spinifex-carol aws ec2 terminate-instances --instance-ids i-123
|
||||
# ^ This will be denied by the NoTerminate statement
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
|
||||
| Command | Key Parameters | Notes |
|
||||
| ----------------------------- | -------------------------------------------- | --------------------------------- |
|
||||
| `create-user` | `--user-name`, `--path` | Path default: `/` |
|
||||
| `get-user` | `--user-name` | |
|
||||
| `list-users` | `--path-prefix` | |
|
||||
| `delete-user` | `--user-name` | Must have no keys or policies |
|
||||
| `create-access-key` | `--user-name` | Max 2 per user; secret shown once |
|
||||
| `list-access-keys` | `--user-name` | |
|
||||
| `delete-access-key` | `--user-name`, `--access-key-id` | |
|
||||
| `update-access-key` | `--user-name`, `--access-key-id`, `--status` | `Active` or `Inactive` |
|
||||
| `create-policy` | `--policy-name`, `--policy-document` | Max 6144 bytes |
|
||||
| `get-policy` | `--policy-arn` | Metadata only |
|
||||
| `get-policy-version` | `--policy-arn`, `--version-id` | Use `v1`; includes document |
|
||||
| `list-policies` | | |
|
||||
| `delete-policy` | `--policy-arn` | Must not be attached |
|
||||
| `attach-user-policy` | `--user-name`, `--policy-arn` | Idempotent |
|
||||
| `detach-user-policy` | `--user-name`, `--policy-arn` | |
|
||||
| `list-attached-user-policies` | `--user-name` | |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### AccessDenied on IAM Commands
|
||||
|
||||
The calling user must have IAM permissions. Attach a policy with `iam:*` actions, or use the root account profile:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
aws iam list-users
|
||||
```
|
||||
|
||||
### InvalidClientTokenId
|
||||
|
||||
The access key is either inactive or does not exist. Check the key status:
|
||||
|
||||
```bash
|
||||
aws iam list-access-keys --user-name alice
|
||||
```
|
||||
|
||||
If the key shows `Inactive`, reactivate it:
|
||||
|
||||
```bash
|
||||
aws iam update-access-key --user-name alice \
|
||||
--access-key-id AKIA... --status Active
|
||||
```
|
||||
|
||||
If the key was deleted, create a new one.
|
||||
|
||||
### DeleteConflict When Deleting a User
|
||||
|
||||
The user still has access keys or attached policies. Remove them first:
|
||||
|
||||
```bash
|
||||
# Check for access keys
|
||||
aws iam list-access-keys --user-name alice
|
||||
|
||||
# Check for attached policies
|
||||
aws iam list-attached-user-policies --user-name alice
|
||||
```
|
||||
|
||||
### DeleteConflict When Deleting a Policy
|
||||
|
||||
The policy is still attached to one or more users. Detach it from all users before deleting:
|
||||
|
||||
```bash
|
||||
# Find who has it attached, then detach
|
||||
aws iam detach-user-policy --user-name alice \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
|
||||
|
||||
aws iam delete-policy \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
|
||||
```
|
||||
|
||||
### LimitExceeded When Creating Access Keys
|
||||
|
||||
Each user can have at most 2 access keys. Delete an existing key before creating a new one:
|
||||
|
||||
```bash
|
||||
aws iam list-access-keys --user-name alice
|
||||
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD
|
||||
aws iam create-access-key --user-name alice
|
||||
```
|
||||
|
||||
### MalformedPolicyDocument
|
||||
|
||||
The policy JSON is invalid. Check that:
|
||||
|
||||
- `Version` is exactly `"2012-10-17"`
|
||||
- At least one `Statement` exists
|
||||
- Each statement has `Effect` (`Allow`/`Deny`), `Action`, and `Resource`
|
||||
- The document is under 6144 bytes
|
||||
- The JSON is well-formed (no trailing commas, correct quoting)
|
||||
@@ -6,7 +6,6 @@ tags:
|
||||
- cli
|
||||
- admin
|
||||
- reference
|
||||
badge: cli
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
@@ -28,7 +27,7 @@ resources:
|
||||
|
||||
The `spx` binary is the central administration tool for managing your Spinifex infrastructure. It provides commands for cluster initialization, account management, node operations, VM lifecycle, and service control.
|
||||
|
||||
**Binary location:** `~/Development/mulga/spinifex/bin/spx`
|
||||
**Binary location:** `/usr/local/bin/spx` (binary install) or `~/Development/mulga/spinifex/bin/spx` (source install)
|
||||
|
||||
All services in the Spinifex platform are managed through this single binary.
|
||||
|
||||
@@ -36,15 +35,27 @@ All services in the Spinifex platform are managed through this single binary.
|
||||
|
||||
## Account Management
|
||||
|
||||
Create a new isolated account with an admin user and AdministratorAccess policy:
|
||||
|
||||
```bash
|
||||
./bin/spx admin account create --name myteam
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Node Management
|
||||
List all accounts:
|
||||
|
||||
```bash
|
||||
./bin/spx get nodes
|
||||
spx admin account list
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Node Management
|
||||
|
||||
List nodes in the cluster:
|
||||
|
||||
```bash
|
||||
spx get nodes
|
||||
```
|
||||
|
||||
```
|
||||
@@ -57,41 +68,49 @@ node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
|
||||
## Monitor Resources
|
||||
|
||||
```bash
|
||||
./bin/spx top nodes
|
||||
spx top nodes
|
||||
```
|
||||
|
||||
Prints per-node CPU/memory usage and cluster-wide instance type availability.
|
||||
|
||||
## Image Management
|
||||
|
||||
```bash
|
||||
./bin/spx admin images list
|
||||
./bin/spx admin images import --name debian-12-arm64
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-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.
|
||||
|
||||
|
||||
## Cluster Shutdown
|
||||
|
||||
Coordinated, phased shutdown of the entire cluster (API/UI → VMs → viperblock → predastore → NATS/daemon):
|
||||
|
||||
```bash
|
||||
./bin/spx admin cluster shutdown
|
||||
spx admin cluster shutdown
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Permission denied running spinifex
|
||||
### Permission Denied Running Spinifex
|
||||
|
||||
The binary may not be executable. Fix permissions:
|
||||
|
||||
```bash
|
||||
chmod +x ./bin/spx
|
||||
chmod +x /usr/local/bin/spx
|
||||
```
|
||||
|
||||
If you get permission errors during operations, ensure you're running with appropriate privileges. Some OVN and networking commands require `sudo`.
|
||||
|
||||
## Services fail to start
|
||||
### Services Fail to Start
|
||||
|
||||
Check the daemon logs for specific errors:
|
||||
Check the daemon logs for specific errors via `systemctl`/`journalctl`:
|
||||
|
||||
```bash
|
||||
ls ~/spinifex/logs/
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
systemctl status 'spinifex-*'
|
||||
journalctl -u spinifex-daemon -f
|
||||
journalctl -u 'spinifex-*' -f
|
||||
```
|
||||
|
||||
Common causes include port conflicts, missing OVN configuration, or untrusted CA certificates.
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: "Launching Instances"
|
||||
description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex."
|
||||
category: "Compute"
|
||||
tags:
|
||||
- ec2
|
||||
- instances
|
||||
- vm
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS EC2 CLI"
|
||||
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
|
||||
---
|
||||
|
||||
# Launching Instances
|
||||
|
||||
> Launch, manage, and connect to EC2-compatible virtual machines on Spinifex.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Launch](#launch)
|
||||
- [Manage](#manage)
|
||||
- [Reboot](#reboot)
|
||||
- [Modify Instance Attributes](#modify-instance-attributes)
|
||||
- [Console Output](#console-output)
|
||||
- [Instance Types](#instance-types)
|
||||
- [SSH (Development)](#ssh-development)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Instance Fails to Boot](#instance-fails-to-boot)
|
||||
- [Cannot SSH Into Instance](#cannot-ssh-into-instance)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex provides EC2-compatible VM management built on QEMU/KVM. Instances support cloud-init, SSH key injection, VPC networking, and standard AWS lifecycle operations.
|
||||
|
||||
**Supported operations:**
|
||||
|
||||
- `run-instances` — Launch new VMs
|
||||
- `describe-instances` — Query state
|
||||
- `stop-instances` / `start-instances` — Lifecycle
|
||||
- `reboot-instances` — In-place restart (QMP reset)
|
||||
- `terminate-instances` — Permanent removal
|
||||
- `modify-instance-attribute` — Change type, user data, or source/dest check
|
||||
- `get-console-output` — Retrieve serial console log
|
||||
- `describe-instance-types` — List available instance types
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key
|
||||
|
||||
export INSTANCE_ID="i-XXX"
|
||||
```
|
||||
|
||||
## Manage
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 stop-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 start-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
## Modify Instance Attributes
|
||||
|
||||
Change instance type, user data, or source/dest check. Instance type and user data require the instance to be **stopped** first.
|
||||
|
||||
### Change Instance Type
|
||||
|
||||
```bash
|
||||
aws ec2 stop-instances --instance-ids $INSTANCE_ID
|
||||
|
||||
aws ec2 modify-instance-attribute \
|
||||
--instance-id $INSTANCE_ID \
|
||||
--instance-type t3.medium
|
||||
|
||||
aws ec2 start-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
## Console Output
|
||||
|
||||
Retrieve the serial console log for a running instance. Output is base64-encoded.
|
||||
|
||||
```bash
|
||||
aws ec2 get-console-output --instance-id $INSTANCE_ID
|
||||
```
|
||||
|
||||
Decode the output:
|
||||
|
||||
```bash
|
||||
aws ec2 get-console-output --instance-id $INSTANCE_ID \
|
||||
--query 'Output' --output text | base64 -d
|
||||
```
|
||||
|
||||
## Instance Types
|
||||
|
||||
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
Filter to a specific type:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types --instance-types t3.micro t3.small
|
||||
```
|
||||
|
||||
Show capacity (how many of each type can still be launched):
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types \
|
||||
--filters Name=capacity,Values=true
|
||||
```
|
||||
|
||||
## SSH (Development)
|
||||
|
||||
In development mode, find the QEMU port forward and connect via localhost:
|
||||
|
||||
```bash
|
||||
ps auxw | grep $INSTANCE_ID
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
For production SSH via public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance Fails to Boot
|
||||
|
||||
Check QEMU logs for the instance and verify the AMI architecture matches your host:
|
||||
|
||||
```bash
|
||||
journalctl -u spinifex-daemon -f
|
||||
aws ec2 describe-images --image-ids $SPINIFEX_AMI
|
||||
```
|
||||
|
||||
If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), import the correct image:
|
||||
|
||||
```bash
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-x86_64
|
||||
```
|
||||
|
||||
### Cannot SSH Into Instance
|
||||
|
||||
cloud-init needs time to configure the instance after boot. Wait 30-60 seconds and retry.
|
||||
|
||||
Verify the SSH key was specified correctly when launching:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
Check the `KeyName` field matches the key you're using to connect.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: "Placement Groups"
|
||||
description: "Create and manage spread and cluster placement groups for hardware-level instance placement control."
|
||||
category: "Compute"
|
||||
tags:
|
||||
- ec2
|
||||
- placement
|
||||
- spread
|
||||
- cluster
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS Placement Groups"
|
||||
url: "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-groups.html"
|
||||
---
|
||||
|
||||
# Placement Groups
|
||||
|
||||
> Create and manage spread and cluster placement groups for hardware-level instance placement control.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Create](#create)
|
||||
- [Launch Into a Placement Group](#launch-into-a-placement-group)
|
||||
- [Describe](#describe)
|
||||
- [Delete](#delete)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [InsufficientInstanceCapacity Error](#insufficientinstancecapacity-error)
|
||||
- [Cannot Delete Placement Group](#cannot-delete-placement-group)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Placement groups control how Spinifex distributes instances across physical hosts. Two strategies are supported:
|
||||
|
||||
- **Spread** — One instance per physical host, maximising fault isolation
|
||||
- **Cluster** — All instances on the same host, minimising latency
|
||||
|
||||
The `partition` strategy is not supported.
|
||||
|
||||
**Supported operations:**
|
||||
|
||||
- `create-placement-group` — Create a new group
|
||||
- `describe-placement-groups` — Query groups with optional filters
|
||||
- `delete-placement-group` — Remove an empty group
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Create
|
||||
|
||||
```bash
|
||||
aws ec2 create-placement-group \
|
||||
--group-name my-spread-group \
|
||||
--strategy spread
|
||||
```
|
||||
|
||||
```bash
|
||||
aws ec2 create-placement-group \
|
||||
--group-name my-cluster-group \
|
||||
--strategy cluster
|
||||
```
|
||||
|
||||
## Launch Into a Placement Group
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--placement GroupName=my-spread-group \
|
||||
--count 3
|
||||
```
|
||||
|
||||
## Describe
|
||||
|
||||
```bash
|
||||
aws ec2 describe-placement-groups
|
||||
aws ec2 describe-placement-groups --group-names my-spread-group
|
||||
aws ec2 describe-placement-groups --group-ids pg-abc123
|
||||
aws ec2 describe-placement-groups \
|
||||
--filters Name=strategy,Values=spread
|
||||
```
|
||||
|
||||
## Delete
|
||||
|
||||
The group must have no running instances before it can be deleted:
|
||||
|
||||
```bash
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 delete-placement-group --group-name my-spread-group
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### InsufficientInstanceCapacity Error
|
||||
|
||||
Not enough distinct physical hosts for a spread launch. Reduce `--count` or terminate existing instances to free host slots:
|
||||
|
||||
```bash
|
||||
spx admin nodes list
|
||||
```
|
||||
|
||||
### Cannot Delete Placement Group
|
||||
|
||||
The group still has running instances. Terminate them first:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances \
|
||||
--filters Name=placement-group-name,Values=my-spread-group
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
title: "Creating an Environment"
|
||||
description: "End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances."
|
||||
category: "Environments"
|
||||
tags:
|
||||
- environment
|
||||
- vpc
|
||||
- ec2
|
||||
- ssh
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Debian Cloud Images"
|
||||
url: "https://cloud.debian.org/images/cloud/bookworm/latest/"
|
||||
---
|
||||
|
||||
# Creating an Environment
|
||||
|
||||
> End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide walks through the complete workflow for creating a production-ready Spinifex environment.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create a user account
|
||||
2. Import or create SSH keys
|
||||
3. Create a VPC and subnet
|
||||
4. Select and import an AMI
|
||||
5. Launch EC2 instances
|
||||
6. Connect via SSH
|
||||
7. Monitor instance health
|
||||
|
||||
## Instructions
|
||||
|
||||
## 1. Import SSH Key
|
||||
|
||||
```bash
|
||||
aws ec2 import-key-pair --key-name "spinifex-key" --public-key-material fileb://~/.ssh/id_rsa.pub
|
||||
```
|
||||
|
||||
## 2. Create VPC
|
||||
|
||||
```bash
|
||||
aws ec2 create-vpc --cidr-block 10.200.0.0/16
|
||||
export SPINIFEX_VPC="vpc-XXX"
|
||||
|
||||
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
|
||||
export SPINIFEX_SUBNET="subnet-XXX"
|
||||
```
|
||||
|
||||
## 3. Import AMI
|
||||
|
||||
```bash
|
||||
./bin/spx admin images list
|
||||
./bin/spx admin images import --name debian-12-x86_64
|
||||
export SPINIFEX_AMI="ami-XXX"
|
||||
```
|
||||
|
||||
## 4. Launch Instance
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SPINIFEX_SUBNET \
|
||||
--count 1
|
||||
```
|
||||
|
||||
## 5. Connect via SSH
|
||||
|
||||
```bash
|
||||
ps auxw | grep $INSTANCE_ID
|
||||
# Look for: hostfwd=tcp:127.0.0.1:<port>-:22
|
||||
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Instance stuck in pending
|
||||
|
||||
Check the Spinifex daemon and QEMU logs for boot errors:
|
||||
|
||||
```bash
|
||||
ls ~/spinifex/logs/
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
```
|
||||
|
||||
Verify the AMI exists and architecture matches your host:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --image-ids $SPINIFEX_AMI
|
||||
```
|
||||
|
||||
## SSH connection refused
|
||||
|
||||
cloud-init takes 30-60 seconds to configure networking and SSH after boot. Wait and retry.
|
||||
|
||||
Verify the SSH port forwarding is active:
|
||||
|
||||
```bash
|
||||
ps auxw | grep hostfwd
|
||||
```
|
||||
|
||||
Ensure you're using the correct key and port:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: "Air-Gapped Install"
|
||||
description: "Deploy Spinifex in environments without internet connectivity. Covers using a pre-built release tarball, mirrored APT packages, and locally-staged cloud images."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- air-gapped
|
||||
- offline
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "External Connection Inventory"
|
||||
url: "/docs/security/network-connections"
|
||||
---
|
||||
|
||||
# Air-Gapped Installation
|
||||
|
||||
> Deploy Spinifex in environments without internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Download the Release (on a connected machine)
|
||||
|
||||
Each Spinifex release publishes a self-contained tarball, the matching `setup.sh`, and a SHA-256 checksum to GitHub Releases. Resolve the latest tag and download the assets for your architecture:
|
||||
|
||||
```bash
|
||||
ARCH=amd64 # or arm64
|
||||
TAG=$(curl -fsSL https://api.github.com/repos/mulgadc/spinifex/releases/latest \
|
||||
| grep '"tag_name"' | cut -d'"' -f4)
|
||||
|
||||
BASE="https://github.com/mulgadc/spinifex/releases/download/${TAG}"
|
||||
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz"
|
||||
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
|
||||
curl -fsSLO "${BASE}/setup.sh"
|
||||
|
||||
sha256sum -c "spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
|
||||
```
|
||||
|
||||
## Step 2. Stage Dependencies (on the connected machine)
|
||||
|
||||
Pre-download the APT packages installed by the production setup. `apt install --download-only` writes `.deb` files to `/var/cache/apt/archives/` without installing them, so the connected machine isn't modified:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install --download-only -y \
|
||||
nbdkit nbdkit-plugin-dev pkg-config \
|
||||
qemu-system-x86 qemu-utils qemu-kvm \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev \
|
||||
ovn-central ovn-host openvswitch-switch \
|
||||
dhcpcd-base make gcc jq curl iproute2 netcat-openbsd \
|
||||
wget unzip xz-utils file
|
||||
```
|
||||
|
||||
Download AWS CLI v2:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
mkdir -p images
|
||||
curl -fsSL "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" \
|
||||
-o images/debian-12-amd64.qcow2
|
||||
```
|
||||
|
||||
## Step 3. Assemble Transfer Media
|
||||
|
||||
```bash
|
||||
mkdir -p /media/spinifex-deploy/{tarball,apt-packages,aws,images}
|
||||
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/
|
||||
```
|
||||
|
||||
## Step 4. Install on the Air-Gapped Target
|
||||
|
||||
Mount the media, install APT dependencies, install AWS CLI, then run `setup.sh` with the local tarball:
|
||||
|
||||
```bash
|
||||
sudo mount /dev/sdb1 /mnt/usb
|
||||
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
sudo apt-get install -f --no-download # resolve any leftover deps from local cache
|
||||
|
||||
cd /tmp && unzip /mnt/usb/aws/awscliv2.zip && sudo ./aws/install
|
||||
|
||||
INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-*-linux-*.tar.gz \
|
||||
INSTALL_SPINIFEX_SKIP_APT=1 \
|
||||
INSTALL_SPINIFEX_SKIP_AWS=1 \
|
||||
bash /mnt/usb/setup.sh
|
||||
```
|
||||
|
||||
Run `setup.sh` without `sudo` — the script handles privilege escalation internally and ends by `exec`-ing into a `newgrp spinifex` subshell so your current shell picks up `spinifex` group membership. Without that membership, AWS CLI cannot traverse `/etc/spinifex/` (mode `0750`) to read `ca.pem` and Step 9 will fail with a TLS error. Type `exit` to leave the subshell when finished.
|
||||
|
||||
## Step 5. Setup OVN Networking
|
||||
|
||||
`spx admin init` requires OVN/OVS to be configured before the daemon can manage tenant networks. `setup-ovn.sh` ships in the tarball and runs purely against local commands (no network access required):
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
If your WAN interface is a physical NIC rather than a bridge, pass `--wan-bridge=br-wan --wan-iface=<nic>`.
|
||||
|
||||
## Step 6. Initialize Without Telemetry
|
||||
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1 --no-telemetry
|
||||
```
|
||||
|
||||
This generates the cluster configuration, TLS certificates, installs the CA into the system trust store, and writes AWS CLI credentials. See [Single-Node Install](/docs/install) for what `spx admin init` does.
|
||||
|
||||
## Step 7. Start Services
|
||||
|
||||
`setup.sh` enables `spinifex.target` but does not start it on a fresh install. Start it now so predastore is online before you import images:
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Step 8. Import Cloud Images
|
||||
|
||||
Register the pre-staged image with Spinifex:
|
||||
|
||||
```bash
|
||||
sudo spx admin images import --file /mnt/usb/images/debian-12-amd64.qcow2 \
|
||||
--distro debian --version 12 --arch x86_64
|
||||
```
|
||||
|
||||
## Step 9. Verify
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
If both calls return data, your air-gapped install is working. Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing APT Dependencies
|
||||
|
||||
`dpkg -i` does not resolve transitive dependencies. After installing, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -f --no-download
|
||||
```
|
||||
|
||||
The `--no-download` flag forces apt to use only what's in the local cache. If a dependency is genuinely missing, add it to the `apt --download-only` step on the connected machine and re-stage.
|
||||
|
||||
### Setup.sh Tries to Download Anyway
|
||||
|
||||
Confirm both skip flags are exported and that `INSTALL_SPINIFEX_TARBALL` points at a readable file:
|
||||
|
||||
```bash
|
||||
sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
|
||||
INSTALL_SPINIFEX_SKIP_APT=1 \
|
||||
INSTALL_SPINIFEX_SKIP_AWS=1 \
|
||||
bash -x /mnt/usb/setup.sh
|
||||
```
|
||||
|
||||
### Init Telemetry Attempted
|
||||
|
||||
`spx admin init` posts a one-shot record to `https://install.mulgadc.com/install`. Pass `--no-telemetry` on every `init` and `join` invocation, or export `SPX_NO_TELEMETRY=1` in the operator's shell profile.
|
||||
|
||||
### Image Import Fails
|
||||
|
||||
`spx admin images import --file` requires the distro/version/arch 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.
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: "Multi-Node Install"
|
||||
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- multi node
|
||||
- cluster
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
---
|
||||
|
||||
# Multi-Node Installation
|
||||
|
||||
> Deploy Spinifex across multiple servers to create an availability zone.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Spinifex cluster distributes services across multiple servers for high availability, data durability, and fault tolerance. Cluster formation is automatic — the init node waits for peers to join, then distributes credentials, CA certificates, and configuration.
|
||||
|
||||
**Network Requirements:**
|
||||
|
||||
- Minimum 1 NIC per server (2 recommended for production)
|
||||
- UDP port 6081 open between hosts (Geneve tunnels)
|
||||
- TCP ports 4222, 4248, 6641, 6642 open between hosts (NATS, OVN)
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Spinifex on Each Server
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
## Step 2. Set Node IP Variables
|
||||
|
||||
On **each server**, export the management IPs for all nodes:
|
||||
|
||||
```bash
|
||||
export SPINIFEX_NODE1=192.168.1.10
|
||||
export SPINIFEX_NODE2=192.168.1.11
|
||||
export SPINIFEX_NODE3=192.168.1.12
|
||||
export AWS_REGION=us-east-1
|
||||
export AWS_AZ=us-east-1a
|
||||
```
|
||||
|
||||
## Step 3. Setup OVN Networking
|
||||
|
||||
Server 1 runs OVN central and must be set up first. If your WAN interface is already a bridge, setup-ovn.sh auto-detects it. Otherwise use `--wan-bridge=br-wan --wan-iface=eth1` (dedicated WAN NIC).
|
||||
|
||||
**Server 1:**
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh \
|
||||
--management \
|
||||
--encap-ip=$SPINIFEX_NODE1
|
||||
```
|
||||
|
||||
**Server 2** (after server 1 is ready):
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh \
|
||||
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
|
||||
--encap-ip=$SPINIFEX_NODE2
|
||||
```
|
||||
|
||||
**Server 3** (after server 1 is ready):
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh \
|
||||
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
|
||||
--encap-ip=$SPINIFEX_NODE3
|
||||
```
|
||||
|
||||
Verify all 3 chassis registered:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
```
|
||||
|
||||
## Step 4. Form the Cluster
|
||||
|
||||
Run init and join concurrently — init blocks until all nodes join.
|
||||
|
||||
**Server 1 — Initialize:**
|
||||
|
||||
```bash
|
||||
sudo spx admin init \
|
||||
--node node1 --nodes 3 \
|
||||
--bind $SPINIFEX_NODE1 --cluster-bind $SPINIFEX_NODE1 \
|
||||
--port 4432 --region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
The init output displays the join command including the token:
|
||||
|
||||
```
|
||||
📡 Formation server started on 10.0.0.1:4432
|
||||
Waiting for 2 more node(s) to join...
|
||||
Token expires in 30m0s
|
||||
|
||||
Other nodes should run:
|
||||
sudo spx admin join --host 10.0.0.1:4432 --token spx_join_a8Bf3x9Kz2mN --node <name> --bind <ip>
|
||||
```
|
||||
|
||||
**Server 2 — Join** (while init is running):
|
||||
|
||||
```bash
|
||||
sudo spx admin join \
|
||||
--node node2 --bind $SPINIFEX_NODE2 --cluster-bind $SPINIFEX_NODE2 \
|
||||
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
|
||||
--region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
**Server 3 — Join** (while init is running):
|
||||
|
||||
```bash
|
||||
sudo spx admin join \
|
||||
--node node3 --bind $SPINIFEX_NODE3 --cluster-bind $SPINIFEX_NODE3 \
|
||||
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
|
||||
--region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
**Note:** The join token expires 30 minutes after init by default. For larger deployments with slower provisioning, use `--token-ttl 2h`
|
||||
|
||||
## Step 5. Start Services
|
||||
|
||||
On **all servers**:
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Step 6. Verify
|
||||
|
||||
From any node:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
If this returns a list of available instance types, your cluster is working.
|
||||
|
||||
**Congratulations! Your Spinifex cluster is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Nodes Not Joining
|
||||
|
||||
The init command must still be running when join executes. If init exited, re-run with `--force`.
|
||||
|
||||
```bash
|
||||
curl -sk https://$SPINIFEX_NODE1:4432/health
|
||||
```
|
||||
|
||||
### OVN Chassis Not Registering
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
sudo ss -tlnp | grep 6642
|
||||
```
|
||||
|
||||
### CA Certificate Not Trusted
|
||||
|
||||
```bash
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
### Cross-Host VMs Cannot Communicate
|
||||
|
||||
```bash
|
||||
sudo ovs-vsctl show | grep -i geneve
|
||||
sudo ss -ulnp | grep 6081
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: "Source Install"
|
||||
description: "Build Spinifex from source for development, custom builds, or contributing."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- source
|
||||
- development
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Go Downloads"
|
||||
url: "https://go.dev/dl/"
|
||||
---
|
||||
|
||||
# Source Installation
|
||||
|
||||
> Build Spinifex from source for development, custom builds, or contributing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide builds Spinifex from source. For production deployments, the [binary installer](/docs/install) is recommended.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Ubuntu 22.04+ / Debian 12+
|
||||
- Go 1.26.2+
|
||||
- GCC, make, pkg-config
|
||||
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Development/mulga && cd ~/Development/mulga
|
||||
git clone https://github.com/mulgadc/spinifex.git
|
||||
sudo make -C spinifex quickinstall
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
```
|
||||
|
||||
## Step 2. Clone and Build
|
||||
|
||||
```bash
|
||||
cd spinifex
|
||||
./scripts/clone-deps.sh
|
||||
```
|
||||
|
||||
## Step 3. Development Initialisation
|
||||
|
||||
```bash
|
||||
./scripts/dev-install.sh
|
||||
```
|
||||
|
||||
This bootstraps a single-node development environment: builds binaries, configures OVN, initialises the cluster, installs the CA certificate, and starts all services.
|
||||
|
||||
## Step 4. Verify Installation
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
If this returns a list of available instance types, your installation is working.
|
||||
|
||||
**Congratulations! Spinifex is installed from source.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Go Not Found in PATH
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
```
|
||||
|
||||
### CA Certificate Not Trusted
|
||||
|
||||
```bash
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: "Bootable USB Install"
|
||||
description: "Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it."
|
||||
category: "Getting Started"
|
||||
sections:
|
||||
- overview
|
||||
- troubleshooting
|
||||
tags:
|
||||
- install
|
||||
- usb
|
||||
- iso
|
||||
- bare-metal
|
||||
resources:
|
||||
- title: "Spinifex ISO"
|
||||
url: "https://iso.mulgadc.com/spinifex.iso"
|
||||
- title: "Balena Etcher"
|
||||
url: "https://etcher.balena.io"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
---
|
||||
|
||||
# Installing Spinifex from Bootable USB
|
||||
|
||||
> Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it.
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. Follow this guide to install Spinifex from a bootable USB using the Spinifex ISO.
|
||||
|
||||
**Note:** this tutorial is for x86 architecture.
|
||||
|
||||
**Warning:** this procedure COMPLETELY WIPES the target disk. For systems with multiple disks, ensure the correct one is targeted.
|
||||
|
||||
### Booting Media
|
||||
|
||||
For this tutorial, a USB drive with at least 8GB of memory is required.
|
||||
|
||||
**Note:** flashing the Spinifex ISO onto the USB completely wipes the USB, so ensure no important data is stored on the USB used.
|
||||
|
||||
### Balena Etcher Installation
|
||||
|
||||
For this tutorial download Balena Etcher to simplify the ISO flash process.
|
||||
|
||||
- [Balena Etcher](https://etcher.balena.io)
|
||||
|
||||
### Download Spinifex ISO
|
||||
|
||||
Download the Spinifex ISO (x86)
|
||||
|
||||
- [Spinifex ISO](https://iso.mulgadc.com/spinifex.iso)
|
||||
|
||||
### Flash Media
|
||||
|
||||
Once installed, open Balena Etcher. Select "Flash From Image," then select the downloaded `spinifex.iso` file.
|
||||
|
||||
Next, click "Select target" and choose the USB drive to be used as the boot media. Then click "Flash!"
|
||||
|
||||
Balena Etcher will now flash the USB drive with the `spinifex.iso` file.
|
||||
|
||||
<img src="../../../.github/assets/images/balena-complete.png" alt="Balena complete">
|
||||
|
||||
You can now safely eject the USB drive if it was not ejected automatically.
|
||||
|
||||
### Boot From USB Drive
|
||||
|
||||
Insert your newly flashed USB drive into the target device and turn it on.
|
||||
|
||||
<img src="../../../.github/assets/images/asus-box-new.png" alt="ASUS Box">
|
||||
|
||||
As it boots, quickly press the correct key for your device to bring up the BIOS/UEFI menu (commonly F2, F10, F12, ESC or DEL) and change the boot order such that the flashed USB drive has first priority, then continue to boot.
|
||||
|
||||
If done successfully, the Spinifex ISO GRUB menu will appear.
|
||||
|
||||
<img src="../../../.github/assets/images/grub-new.png" alt="GRUB menu">
|
||||
|
||||
From this menu you can select which method of install is used (console recommended). Headless mode can be configured by mounting the USB on a host device after flashing and editing the `grub.cfg` file with the desired values.
|
||||
|
||||
### Setting Up the Spinifex Node
|
||||
|
||||
In console mode, follow the installation prompts to set the required networking values for the Spinifex node.
|
||||
|
||||
The installer will default to the most sensible disk to install on depending on system configuration — ensure this default is correct before installation, as the process will wipe the disk.
|
||||
|
||||
For network configuration, it is recommended to use automatic IP (DHCP), but this can also be configured manually.
|
||||
|
||||
Network interfaces will be automatically detected. In the event that none are detected, the user can manually input the name of the network interface. In the event that multiple network interfaces are detected, the installer will prompt for WAN selection first, followed by LAN.
|
||||
|
||||
A hostname (eg `node1`) and admin password must be set, followed by the "cluster role" — whether the node is the first in a new cluster, or joining an already existing one.
|
||||
|
||||
Once configuration is complete, a summary of the configuration will be shown.
|
||||
|
||||
<img src="../../../.github/assets/images/installer-complete.png" alt="Installer complete">
|
||||
|
||||
The installer will then complete the installation of Spinifex onto the target device. Once complete, remove the USB drive from the device before automatic reboot.
|
||||
|
||||
Once the USB drive is removed, press enter or wait for the auto-reboot.
|
||||
|
||||
### Log In
|
||||
|
||||
The device will reboot and spend ~2-5 minutes configuring the Spinifex node. The user will then be prompted to log in. Use the following credentials for login:
|
||||
|
||||
- Login: `spinifex`
|
||||
- Password: Set by user during installation
|
||||
|
||||
Both before and after login, a banner will be printed specifying important information, such as details for SSH into the node and accessing the web dashboard for further configuration of the node.
|
||||
|
||||
<img src="../../../.github/assets/images/banner1.png" alt="banner">
|
||||
|
||||
### Setup Complete
|
||||
|
||||
**Congratulations! Spinifex is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Can't Access BIOS/UEFI to Change Boot Order
|
||||
|
||||
It can be difficult to get into the BIOS/UEFI — there is only a short window to press the correct key, and this key changes depending on the manufacturer. Search online for your device's BIOS/UEFI key, and press it rapidly as the device boots.
|
||||
|
||||
### GRUB Menu Not Appearing
|
||||
|
||||
Once in the BIOS/UEFI menu, ensure the correct boot order is set. First boot priority should be set to the USB drive flashed with the ISO — if the USB doesn't appear in the BIOS/UEFI menu, ensure it has been flashed with the ISO correctly. Take note of the name and storage capacity of the USB when flashing, as this should match what appears in the BIOS/UEFI.
|
||||
|
||||
### Networking Issues
|
||||
|
||||
For a Spinifex node to be properly provisioned, the target device must have at least one NIC.
|
||||
|
||||
Spinifex uses DHCP to assign instances within a node their public IP addresses. Instead of a static range, public IPs come from the upstream router's DHCP server. When a VM launches, Spinifex requests a DHCP lease from the router on behalf of the VM. When the VM terminates, the lease is released.
|
||||
|
||||
The VM itself never talks to the router's DHCP — it only sees its private VPC IP (from OVN's internal DHCP). The host-side DHCP conversation is invisible to the guest.
|
||||
|
||||
**Use when:** You don't control a static IP block but the router's DHCP server has enough leases. Homelabs where you don't want to carve out a range. Environments where IPs are managed centrally by the network team's DHCP.
|
||||
|
||||
For further troubleshooting suggestions, refer to the [VPC Networking](/docs/vpc-networking) guide.
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Single-Node Install"
|
||||
description: "Install Spinifex on a single server using the binary installer."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- single node
|
||||
- quickstart
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
---
|
||||
|
||||
# Installing Spinifex
|
||||
|
||||
> Install Spinifex on a single server using the binary installer.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex is an open-source infrastructure platform that brings core AWS services to bare-metal, edge, and on-prem environments.
|
||||
|
||||
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Install](/docs/install-multi-node). To build from source, see [Source Install](/docs/install-source).
|
||||
|
||||
**Supported Operating Systems:**
|
||||
|
||||
- Ubuntu 22.04 / 24.04 / 25.10
|
||||
- Debian 12 / 13
|
||||
|
||||
**What Gets Installed:**
|
||||
|
||||
- Spinifex daemon and CLI
|
||||
- QEMU/KVM for virtual machine management
|
||||
- OVN/Open vSwitch for VPC networking
|
||||
- Predastore (S3-compatible object storage)
|
||||
- Viperblock (EBS-compatible block storage)
|
||||
- AWS CLI v2
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Spinifex
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
|
||||
|
||||
## Step 2. Setup OVN Networking
|
||||
|
||||
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
If your WAN is a physical NIC:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
|
||||
```
|
||||
|
||||
**Separating VPC tunnel traffic from WAN:** If you want Geneve tunnel traffic (inter-VM east-west for VPC traffic) to use a dedicated interface instead of the WAN IP, add `--encap-ip=<IP>` to specify the tunnel endpoint address:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=10.0.0.1
|
||||
```
|
||||
|
||||
This is recommended for production and required in multi-node deployments. See [Multi-Node Install](/docs/install-multi-node) for details.
|
||||
|
||||
## Step 3. Initialize
|
||||
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials (saved in `~/.aws/credentials`)
|
||||
|
||||
## Step 4. Start Services
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Step 5. Verify
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
If this returns a list of available instance types, your installation is working.
|
||||
|
||||
**Congratulations! Spinifex is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### spx Command Not Found
|
||||
|
||||
```bash
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
```
|
||||
|
||||
### CA Certificate Not Trusted
|
||||
|
||||
`sudo spx admin init` installs the CA automatically. If you need to re-install it manually:
|
||||
|
||||
```bash
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
### OVN Services Not Starting
|
||||
|
||||
```bash
|
||||
sudo systemctl is-active ovn-controller
|
||||
journalctl -u ovn-controller --no-pager -n 20
|
||||
```
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: "Setting Up Your Cluster"
|
||||
description: "Import an AMI, create a VPC with a public subnet, and launch your first EC2 instance on Spinifex."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- setup
|
||||
- vpc
|
||||
- ec2
|
||||
- quickstart
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS EC2 CLI Reference"
|
||||
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
|
||||
---
|
||||
|
||||
# Setting Up Your Cluster
|
||||
|
||||
> Import an AMI, create a VPC with a public subnet, and launch your first instance.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Import an AMI](#1-import-an-ami)
|
||||
- [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)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
|
||||
- [Single-Node Install)](/docs/install)
|
||||
- [Multi-Node Install](/docs/install-multi-node)
|
||||
- [Source Install](/docs/install-source)
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## 1. Import an AMI
|
||||
|
||||
List available images and import one matching your architecture:
|
||||
|
||||
```bash
|
||||
spx admin images list
|
||||
```
|
||||
|
||||
```
|
||||
NAME | DISTRO | VERSION | ARCH | BOOT
|
||||
debian-12-arm64 | debian | 12 | arm64 | bios
|
||||
debian-12-x86_64 | debian | 12 | x86_64 | bios
|
||||
lb-alpine-3.21.6-x86_64 | alpine | 3.21.6 | x86_64 | bios
|
||||
ubuntu-24.04-arm64 | ubuntu | 24.04 | arm64 | bios
|
||||
ubuntu-24.04-x86_64 | ubuntu | 24.04 | x86_64 | bios
|
||||
```
|
||||
|
||||
Import an image:
|
||||
|
||||
```bash
|
||||
spx admin images import --name ubuntu-24.04-x86_64
|
||||
```
|
||||
|
||||
Or import a local image file:
|
||||
|
||||
```bash
|
||||
spx admin images import --file ~/images/ubuntu-24.04.img --distro ubuntu --version 24.04 --arch x86_64
|
||||
```
|
||||
|
||||
Verify the import and note the AMI ID:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
## 2. Create an SSH Key
|
||||
|
||||
### Option A: Import an existing key
|
||||
|
||||
```bash
|
||||
aws ec2 import-key-pair \
|
||||
--key-name "spinifex-key" \
|
||||
--public-key-material fileb://~/.ssh/id_rsa.pub
|
||||
```
|
||||
|
||||
### Option B: Create a new key pair
|
||||
|
||||
```bash
|
||||
aws ec2 create-key-pair --key-name spinifex-key \
|
||||
| jq -r '.KeyMaterial | rtrimstr("\n")' > ~/.ssh/spinifex-key
|
||||
|
||||
chmod 600 ~/.ssh/spinifex-key
|
||||
ssh-keygen -y -f ~/.ssh/spinifex-key > ~/.ssh/spinifex-key.pub
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-key-pairs
|
||||
```
|
||||
|
||||
## 3. Create a VPC and Public Subnet
|
||||
|
||||
### Create a VPC
|
||||
|
||||
```bash
|
||||
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
|
||||
--query 'Vpc.VpcId' --output text)
|
||||
|
||||
echo "VPC: $VPC_ID"
|
||||
```
|
||||
|
||||
### Create an Internet Gateway
|
||||
|
||||
An Internet Gateway enables instances in public subnets to reach the internet and be reachable from the LAN/WAN.
|
||||
|
||||
```bash
|
||||
IGW_ID=$(aws ec2 create-internet-gateway \
|
||||
--query 'InternetGateway.InternetGatewayId' --output text)
|
||||
|
||||
aws ec2 attach-internet-gateway \
|
||||
--internet-gateway-id $IGW_ID \
|
||||
--vpc-id $VPC_ID
|
||||
|
||||
echo "IGW: $IGW_ID (attached to $VPC_ID)"
|
||||
```
|
||||
|
||||
### Create a Public Subnet
|
||||
|
||||
A public subnet auto-assigns a routable IP to each instance, making it directly reachable from your network.
|
||||
|
||||
```bash
|
||||
SUBNET_ID=$(aws ec2 create-subnet \
|
||||
--vpc-id $VPC_ID \
|
||||
--cidr-block 10.200.1.0/24 \
|
||||
--query 'Subnet.SubnetId' --output text)
|
||||
|
||||
# Enable auto-assign public IP
|
||||
aws ec2 modify-subnet-attribute \
|
||||
--subnet-id $SUBNET_ID \
|
||||
--map-public-ip-on-launch
|
||||
|
||||
echo "Subnet: $SUBNET_ID (public)"
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
aws ec2 describe-vpcs --vpc-ids $VPC_ID
|
||||
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
|
||||
```
|
||||
|
||||
## 4. Launch an Instance
|
||||
|
||||
Query available instance types for your hardware:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
Launch an instance in the public subnet, note this will select an instance type with 2 VCPU and 1024MB of memory.
|
||||
|
||||
> **Note:** Replace `AMI_NAME` with the previously imported image above with the `ami-` prefix.
|
||||
|
||||
```bash
|
||||
AMI_NAME="ami-ubuntu-24.04-x86_64"
|
||||
|
||||
AMI_ID=$(aws ec2 describe-images \
|
||||
--filters "Name=name,Values=$AMI_NAME" \
|
||||
--query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' \
|
||||
--output text)
|
||||
|
||||
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
|
||||
--query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
|
||||
--output text)
|
||||
|
||||
INSTANCE_ID=$(aws ec2 run-instances \
|
||||
--image-id $AMI_ID \
|
||||
--instance-type $INSTANCE_TYPE \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SUBNET_ID \
|
||||
--count 1 \
|
||||
--query 'Instances[0].InstanceId' --output text)
|
||||
|
||||
echo "Instance: $INSTANCE_ID"
|
||||
```
|
||||
|
||||
Wait for the instance to reach `running` state:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances --instance-ids $INSTANCE_ID \
|
||||
--query 'Reservations[0].Instances[0].[State.Name, PrivateIpAddress, PublicIpAddress]' \
|
||||
--output text
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
running 10.200.1.4 192.168.1.155
|
||||
```
|
||||
|
||||
The instance has both a private IP (VPC overlay) and a public IP (from your external pool, routable on your network).
|
||||
|
||||
## 5. Connect via SSH
|
||||
|
||||
SSH directly to the instance's public IP:
|
||||
|
||||
```bash
|
||||
PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID \
|
||||
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
|
||||
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@$PUBLIC_IP
|
||||
```
|
||||
|
||||
> **Note:** cloud-init takes 30-60 seconds to configure the instance. If SSH is refused, wait and retry.
|
||||
|
||||
Once connected, verify the instance has internet access:
|
||||
|
||||
```bash
|
||||
curl -s http://ifconfig.me
|
||||
```
|
||||
|
||||
This should return the instance's public IP or the gateway's SNAT address.
|
||||
|
||||
## 6. Managing Instances
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
aws ec2 stop-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
### Start
|
||||
|
||||
```bash
|
||||
aws ec2 start-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
### Terminate
|
||||
|
||||
```bash
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
### Console Output
|
||||
|
||||
View the instance's serial console log (useful for debugging boot issues):
|
||||
|
||||
```bash
|
||||
aws ec2 get-console-output --instance-id $INSTANCE_ID \
|
||||
--query 'Output' --output text
|
||||
```
|
||||
|
||||
### Multi-Node: Check Instance Placement
|
||||
|
||||
On a multi-node cluster, instances are distributed across nodes:
|
||||
|
||||
```bash
|
||||
spx get vms
|
||||
```
|
||||
|
||||
## 7. Launching the Web UI
|
||||
|
||||
Spinifex ships with a built-in web console — an alternative to the AWS CLI, SDKs, and Terraform, analogous to the AWS Management Console. Every action in the UI is a standard AWS SigV4 API call, so the same IAM policies and audit behaviour apply.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../../.github/assets/spinifex-ui.jpg" alt="Spinifex Web UI" width="900">
|
||||
</p>
|
||||
|
||||
### Open the Console
|
||||
|
||||
The UI is served by each node on port `3000` over TLS. Replace `YOUR_NODE_IP` with the address of the node you installed Spinifex on (or `localhost` if you're on the node itself):
|
||||
|
||||
```
|
||||
https://YOUR_NODE_IP:3000
|
||||
```
|
||||
|
||||
### Trust the Self-Signed Certificate (required)
|
||||
|
||||
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*).
|
||||
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:
|
||||
|
||||
- **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*.
|
||||
|
||||
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
|
||||
|
||||
> **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.
|
||||
|
||||
### Log In with AWS Credentials
|
||||
|
||||
The console authenticates against the AWS credentials in `~/.aws/credentials` on the node where Spinifex is installed:
|
||||
|
||||
```ini
|
||||
[spinifex]
|
||||
aws_access_key_id = AKIA...
|
||||
aws_secret_access_key = ...
|
||||
```
|
||||
|
||||
At the Spinifex login screen, paste the **Access Key ID** and **Secret Access Key** from the `[spinifex]` profile (or whichever profile maps to the IAM user or role you want to use). Additional users and policies can be managed through the UI or via `aws iam` commands — see [IAM Users and Policies](/docs/iam-users-and-policies).
|
||||
|
||||
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.
|
||||
|
||||
## Additional Options
|
||||
|
||||
### Private Subnets (No Public IP)
|
||||
|
||||
Create a subnet without `--map-public-ip-on-launch`:
|
||||
|
||||
```bash
|
||||
PRIVATE_SUBNET=$(aws ec2 create-subnet \
|
||||
--vpc-id $VPC_ID \
|
||||
--cidr-block 10.200.2.0/24 \
|
||||
--query 'Subnet.SubnetId' --output text)
|
||||
```
|
||||
|
||||
Instances in private subnets get a private IP only. They can still reach the internet via SNAT through the VPC router, but are not directly reachable from your network.
|
||||
|
||||
### Multiple Accounts
|
||||
|
||||
Create isolated accounts with their own resources:
|
||||
|
||||
```bash
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance Stuck in Pending
|
||||
|
||||
```bash
|
||||
journalctl -u spinifex-daemon -f
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
### SSH Connection Refused
|
||||
|
||||
cloud-init needs 30-60 seconds after boot. Check instance state:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
### No Public IP Assigned
|
||||
|
||||
Verify the subnet has `MapPublicIpOnLaunch` enabled:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
|
||||
```
|
||||
|
||||
If `MapPublicIpOnLaunch` is false:
|
||||
|
||||
```bash
|
||||
aws ec2 modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch
|
||||
```
|
||||
|
||||
Also verify an Internet Gateway is attached to the VPC:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-internet-gateways
|
||||
```
|
||||
|
||||
### Instance Has No Internet Access
|
||||
|
||||
Check the VPC router's NAT rules (from the host):
|
||||
|
||||
```bash
|
||||
sudo ovn-nbctl lr-nat-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
|
||||
```
|
||||
|
||||
Verify the default route exists:
|
||||
|
||||
```bash
|
||||
sudo ovn-nbctl lr-route-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: "Updating Spinifex"
|
||||
description: "Upgrade an existing Spinifex installation to the latest release."
|
||||
category: "Administration"
|
||||
tags:
|
||||
- update
|
||||
- upgrade
|
||||
- migrate
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Single-Node Install"
|
||||
url: "/docs/install"
|
||||
---
|
||||
|
||||
# Updating Spinifex
|
||||
|
||||
> Upgrade an existing Spinifex installation to the latest release.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Manual Upgrade](#manual-upgrade)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Updating Spinifex is the same command used to install it. The installer detects an existing installation, downloads the latest binary and runs any pending configuration migrations before restarting services.
|
||||
|
||||
For operators who want to review migrations before they are applied, a manual upgrade path is also supported.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Re-run the Installer
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
That's it. The installer will:
|
||||
|
||||
1. Download and install the latest Spinifex binary.
|
||||
2. Reinstall systemd units so new services are picked up.
|
||||
3. Run any pending configuration migrations automatically (equivalent to `spx admin upgrade --yes`).
|
||||
4. Restart `spinifex.target` if the services were already running.
|
||||
|
||||
## Step 2. Verify
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
If this returns a list of instance types, your upgrade is complete.
|
||||
|
||||
## Manual Upgrade
|
||||
|
||||
If you prefer to review pending migrations before they are applied, Spinifex supports running `spx admin init` to allow you to verify config file migrations.
|
||||
|
||||
## Step 1. Install the New Binary Without Running Migrations
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | INSTALL_SPINIFEX_SKIP_MIGRATE=1 bash
|
||||
```
|
||||
|
||||
The installer will download the new binary and reinstall systemd units, but will **not** apply any configuration migrations.
|
||||
|
||||
## Step 2. Review Pending Migrations
|
||||
|
||||
```bash
|
||||
sudo spx admin upgrade
|
||||
```
|
||||
|
||||
The command prints the current version of each config file, the migrations that would be applied, and a `from → to` description for each. It then prompts for confirmation before making any changes. Answer `n` to abort without touching your config.
|
||||
|
||||
## Step 3. Apply Migrations
|
||||
|
||||
When you are ready, answer `y` at the prompt, or re-run with `--yes` to apply non-interactively:
|
||||
|
||||
```bash
|
||||
sudo spx admin upgrade --yes
|
||||
```
|
||||
|
||||
## Step 4. Restart Services
|
||||
|
||||
Migrations modify config files on disk but do not restart running services. Apply the new config with:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart spinifex.target
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Pending Config Migrations
|
||||
|
||||
```
|
||||
No pending config migrations.
|
||||
```
|
||||
|
||||
Your config is already at the latest version. Nothing to do.
|
||||
|
||||
### No Spinifex Installation Found
|
||||
|
||||
```
|
||||
No Spinifex installation found at /etc/spinifex
|
||||
Run 'spx admin init' first.
|
||||
```
|
||||
|
||||
`spx admin upgrade` requires an initialized installation. If this is a fresh host, follow the [Single-Node Install](/docs/install) guide instead.
|
||||
|
||||
### Migration Failure
|
||||
|
||||
If a migration fails, the installer and `spx admin upgrade` exit non-zero and leave the config in its prior state where possible. Review the error output, then re-run `sudo spx admin upgrade` once the underlying issue is resolved.
|
||||
|
||||
### Services Did Not Pick Up New Config
|
||||
|
||||
Migrations edit config files on disk but the running daemons continue to use the config they loaded at start-up. Restart with:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart spinifex.target
|
||||
```
|
||||
@@ -0,0 +1,310 @@
|
||||
# Installing Spinifex in a VM (MacOS)
|
||||
|
||||
For evaluation / quick POC use only.
|
||||
|
||||
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. However, follow this guide to install Spinifex on a MacOS VM to demonstrate the capabilities (dev/testing only).
|
||||
|
||||
Note this tutorial is for Apple Silicon (ARM64)
|
||||
|
||||
## UTM installation
|
||||
|
||||
For this tutorial download UTM.app on Mac to simplify the VM installation.
|
||||
|
||||
* UTM - [https://mac.getutm.app](https://mac.getutm.app)
|
||||
|
||||
|
||||
|
||||
## Download Debian ISO
|
||||
|
||||
Next, download the Debian ISO (ARM64, Apple Silicon)
|
||||
|
||||
* Debian 13.4 - [https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso](https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso)
|
||||
|
||||
## Login
|
||||
|
||||
Using the VM console, login using the `spinifex` user and your specified password. A few additional steps are required via the console, before you can SSH into the machine for easier administration.
|
||||
|
||||
## Install prerequisites
|
||||
|
||||
For a minimal Debian install additional dependencies are required prior to the Spinifex installation:
|
||||
|
||||
```
|
||||
su root
|
||||
apt update
|
||||
apt install sudo curl bridge-utils
|
||||
```
|
||||
|
||||
### Enable sudoers
|
||||
|
||||
Enable sudo for `spinifex` user as the root user:
|
||||
|
||||
```
|
||||
su root
|
||||
echo "spinifex ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/spinifex
|
||||
```
|
||||
|
||||
## Networking
|
||||
|
||||
Spinifex requires a Linux bridge for networking to correctly function, given EC2 instances and resources can obtain an IP address from the upstream DHCP server or a static public IP address range you specify.
|
||||
|
||||
This is a requirement step for valid installation.
|
||||
|
||||
View the existing `/etc/network/interfaces` for the network configuration.
|
||||
|
||||
```sh
|
||||
# This file describes the network interfaces available on your system
|
||||
# and how to activate them. For more information, see interfaces(5).
|
||||
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
# The loopback network interface
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
# The primary network interface
|
||||
allow-hotplug enp0s1
|
||||
iface enp0s1 inet dhcp
|
||||
# This is an autoconfigured IPv6 interface
|
||||
iface enp0s1 inet6 auto
|
||||
```
|
||||
|
||||
Edit `/etc/network/interfaces` note replace `enp0s1` with your network adapter.
|
||||
|
||||
```sh
|
||||
# This file describes the network interfaces available on your system
|
||||
# and how to activate them. For more information, see interfaces(5).
|
||||
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
# The loopback network interface
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
# The primary network interface - toggle to manual
|
||||
allow-hotplug enp0s1
|
||||
iface enp0s1 inet manual
|
||||
# This is an autoconfigured IPv6 interface - toggle to manual
|
||||
iface enp0s1 inet6 manual
|
||||
|
||||
# Spinifex Config
|
||||
# WAN bridge (Spinifex EC2 instances connect here)
|
||||
# VMs (EC2) / containers <-> br-wan <-> enp0s1 <-> external network
|
||||
auto br-wan
|
||||
iface br-wan inet dhcp
|
||||
bridge_ports enp0s1
|
||||
bridge_stp off
|
||||
bridge_fd 0
|
||||
bridge_maxwait 0
|
||||
```
|
||||
|
||||
Walkthrough of changes:
|
||||
|
||||
* Adds eno3 into the bridge
|
||||
* eno3 becomes a slave interface
|
||||
* All traffic goes through br-wan
|
||||
* Spinifex EC2 (VMs) in a public subnet, obtain an IP address from the WAN upstream
|
||||
|
||||
### Restart networking
|
||||
|
||||
Make sure you are logged into the console, not via SSH for these steps. Otherwise you will lose networking to the instance.
|
||||
|
||||
Replace `enp0s1` with your network adapter.
|
||||
|
||||
```bash
|
||||
ifdown enp0s1
|
||||
systemctl restart networking
|
||||
```
|
||||
|
||||
Next, retrieve the IP address of the new `br-wan` bridge from your upstream DHCP server.
|
||||
|
||||
```bash
|
||||
ip a show dev enp0s1
|
||||
```
|
||||
|
||||
```bash
|
||||
6: br-wan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
|
||||
link/ether 12:c2:bc:f3:f6:7b brd ff:ff:ff:ff:ff:ff
|
||||
inet 192.168.0.249/24 brd 192.168.0.255 scope global dynamic noprefixroute br-wan
|
||||
valid_lft 86103sec preferred_lft 75303sec
|
||||
inet6 fd0d:9d60:d09b:0:ee77:630d:3237:9a10/64 scope global dynamic mngtmpaddr noprefixroute
|
||||
valid_lft 86282sec preferred_lft 86282sec
|
||||
inet6 fe80::85c0:f2aa:f0bf:e951/64 scope link
|
||||
valid_lft forever preferred_lft forever
|
||||
```
|
||||
|
||||
Note the IP `192.168.0.249`
|
||||
|
||||
# Spinifex Installation
|
||||
|
||||
## Launch installer
|
||||
|
||||
Next, optionally SSH into your instance (using the IP above) or continue using the console.
|
||||
|
||||
Replace with your IP below.
|
||||
|
||||
```
|
||||
ssh spinifex@<your-ip>
|
||||
```
|
||||
|
||||
As the `spinifex` user launch the binary installer:
|
||||
|
||||
```bash
|
||||
su spinifex
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
[INFO] Spinifex installer
|
||||
|
||||
[INFO] Detected OS: Debian GNU/Linux 13 (trixie)
|
||||
[INFO] Detected architecture: aarch64 (arm64)
|
||||
[INFO] Installing system dependencies...
|
||||
[INFO] System dependencies installed
|
||||
[INFO] Installing AWS CLI v2...
|
||||
[INFO] AWS CLI installed: aws-cli/2.34.30 Python/3.14.4 Linux/6.12.74+deb13+1-arm64 exe/aarch64.debian.13
|
||||
[INFO] Service users created (spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui})
|
||||
/etc/sudoers.d/spinifex-network: parsed OK
|
||||
[INFO] Scoped sudoers rules installed for spinifex-daemon and spinifex-vpcd
|
||||
[INFO] Downloading Spinifex (latest channel, arm64)...
|
||||
[INFO] Verifying checksum...
|
||||
[INFO] Checksum verified
|
||||
[INFO] Extracting...
|
||||
[INFO] Installing files...
|
||||
[INFO] /usr/local/bin/spx
|
||||
[INFO] /usr/lib/aarch64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so
|
||||
[INFO] /usr/local/share/spinifex/setup-ovn.sh
|
||||
[INFO] Creating directories...
|
||||
[INFO] Generated /etc/spinifex/systemd.env
|
||||
[INFO] /var/lib/spinifex/predastore-start.sh
|
||||
[INFO] /var/lib/spinifex/wait-for-nats.sh
|
||||
[INFO] Fixing file ownership for privilege separation...
|
||||
[INFO] File ownership updated
|
||||
[INFO] Installing systemd units...
|
||||
[INFO] /etc/systemd/system/spinifex-awsgw.service
|
||||
[INFO] /etc/systemd/system/spinifex-daemon.service
|
||||
[INFO] /etc/systemd/system/spinifex-nats.service
|
||||
[INFO] /etc/systemd/system/spinifex-predastore.service
|
||||
[INFO] /etc/systemd/system/spinifex.target
|
||||
[INFO] /etc/systemd/system/spinifex-ui.service
|
||||
[INFO] /etc/systemd/system/spinifex-viperblock.service
|
||||
[INFO] /etc/systemd/system/spinifex-vpcd.service
|
||||
Created symlink '/etc/systemd/system/multi-user.target.wants/spinifex.target' → '/etc/systemd/system/spinifex.target'.
|
||||
[INFO] Systemd units installed and enabled (per-service users)
|
||||
[INFO] Fresh install detected, skipping migrations
|
||||
[INFO] Logrotate config installed
|
||||
|
||||
============================================
|
||||
Spinifex installed successfully
|
||||
============================================
|
||||
|
||||
Version: spinifex v1.1.0 (b7b6390) linux/arm64
|
||||
Architecture: arm64
|
||||
Service users: spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui}
|
||||
Binary: /usr/local/bin/spx
|
||||
Config: /etc/spinifex/
|
||||
Data: /var/lib/spinifex/
|
||||
Logs: /var/log/spinifex/
|
||||
```
|
||||
|
||||
## Setup OVN networking
|
||||
|
||||
Since the WAN interface is already a bridge (e.g. br-wan) run the Openvswitch (OVN) install script:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```bash
|
||||
Auto-detected Linux bridge: br-wan (default route)
|
||||
Will create OVS bridge br-ext + veth pair to link them
|
||||
Auto-detected encap IP: 192.168.0.249
|
||||
=== Spinifex OVN Compute Node Setup ===
|
||||
Management node: true
|
||||
WAN bridge: br-ext (veth)
|
||||
Linux bridge: br-wan (linked via veth pair)
|
||||
OVN Remote (SB): tcp:127.0.0.1:6642
|
||||
Encap IP: 192.168.0.249
|
||||
...
|
||||
system-id: "add0104e-9aec-43f0-af53-5fc89b4d91bf"
|
||||
...
|
||||
```
|
||||
|
||||
The `system-id` is the OVS-managed chassis UUID (read back from
|
||||
`/etc/openvswitch/system-id.conf`); this is the same value that appears as
|
||||
`Chassis.name` in `ovn-sbctl show`. Cross-reference with `Chassis.hostname`
|
||||
when you need the human-readable host:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl --columns=name,hostname list Chassis
|
||||
```
|
||||
|
||||
## Spinifex Initialize
|
||||
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
## Start services
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Verify installation
|
||||
|
||||
Connect to the local AWS gateway and issue a sample command using the AWS CLI tool, all running on your local infrastructure, no cloud required.
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
> ***Note:*** The system may require ~30 seconds to initalise prior to accepting connections for the command to complete above.
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"InstanceTypes": [
|
||||
{
|
||||
"InstanceType": "t4g.nano",
|
||||
"CurrentGeneration": true,
|
||||
"SupportedRootDeviceTypes": [
|
||||
"ebs"
|
||||
],
|
||||
"SupportedVirtualizationTypes": [
|
||||
"hvm"
|
||||
],
|
||||
"Hypervisor": "kvm",
|
||||
"ProcessorInfo": {
|
||||
"SupportedArchitectures": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"VCpuInfo": {
|
||||
"DefaultVCpus": 2
|
||||
},
|
||||
"MemoryInfo": {
|
||||
"SizeInMiB": 512
|
||||
},
|
||||
"PlacementGroupInfo": {
|
||||
"SupportedStrategies": [
|
||||
"cluster",
|
||||
"spread"
|
||||
]
|
||||
},
|
||||
"BurstablePerformanceSupported": true
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Installation Complete
|
||||
|
||||
Congratulations! 🥳
|
||||
|
||||
Your installation of Spinifex is now complete on a VM running on a Mac.
|
||||
|
||||
# Next steps
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
title: "Air-Gapped Installation"
|
||||
description: "Deploy Spinifex in environments without internet connectivity. Covers offline package caching, USB deployment, and package verification."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- air-gapped
|
||||
- offline
|
||||
badge: advanced
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "apt-cacher-ng"
|
||||
url: "https://wiki.debian.org/AptCacherNg"
|
||||
---
|
||||
|
||||
# Air-Gapped Installation
|
||||
|
||||
> Deploy Spinifex in environments without internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
|
||||
|
||||
**Deployment methods:**
|
||||
- **apt-cacher-ng** — Local APT cache for Debian/Ubuntu packages
|
||||
- **Go module cache** — Pre-downloaded Go dependencies
|
||||
- **USB deployment** — Portable installation media with all dependencies
|
||||
- **GPG verification** — Cryptographic package integrity checks
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Prepare packages (on a connected machine)
|
||||
|
||||
Set up a local APT cache to collect all required packages:
|
||||
|
||||
```bash
|
||||
sudo apt install apt-cacher-ng
|
||||
sudo systemctl enable --now apt-cacher-ng
|
||||
```
|
||||
|
||||
Pre-download all Spinifex dependencies:
|
||||
|
||||
```bash
|
||||
sudo apt install --download-only nbdkit nbdkit-plugin-dev pkg-config \
|
||||
qemu-system qemu-utils qemu-kvm libvirt-daemon-system \
|
||||
libvirt-clients libvirt-dev make gcc unzip xz-utils file \
|
||||
ovn-central ovn-host openvswitch-switch
|
||||
```
|
||||
|
||||
## Step 2. Create USB deployment media
|
||||
|
||||
```bash
|
||||
mkdir -p /media/spinifex-deploy/{apt-packages,go-cache,spinifex-source}
|
||||
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
|
||||
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
|
||||
cp -r ~/Development/mulga/spinifex /media/spinifex-deploy/spinifex-source/
|
||||
```
|
||||
|
||||
## Step 3. Verify package integrity
|
||||
|
||||
Sign and verify packages before transferring to the target:
|
||||
|
||||
```bash
|
||||
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
|
||||
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
|
||||
sha256sum -c /media/spinifex-deploy/checksums.sha256
|
||||
```
|
||||
|
||||
## Step 4. Install on the target server
|
||||
|
||||
Mount the USB and install:
|
||||
|
||||
```bash
|
||||
sudo mount /dev/sdb1 /mnt/usb
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
cp -r /mnt/usb/spinifex-source ~/Development/mulga/spinifex
|
||||
cd ~/Development/mulga/spinifex && make build
|
||||
```
|
||||
|
||||
Then follow the [Source Install](/docs/source-install) guide from Step 3 (Setup OVN) onwards.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Missing dependencies after dpkg install
|
||||
|
||||
Some packages may have unresolved dependencies. Fix with:
|
||||
|
||||
```bash
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
sudo apt-get install -f
|
||||
```
|
||||
|
||||
The `-f` flag tells apt to fix broken dependencies using what's available locally.
|
||||
|
||||
## GPG verification fails
|
||||
|
||||
The signing key may not match or the download was corrupted. Re-import the key and verify:
|
||||
|
||||
```bash
|
||||
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
|
||||
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
|
||||
```
|
||||
|
||||
If verification still fails, re-download the package on the connected machine and compare checksums:
|
||||
|
||||
```bash
|
||||
sha256sum spinifex-v1.0.tar.gz
|
||||
```
|
||||
|
||||
## Go module cache errors
|
||||
|
||||
The Go module cache may have been incompletely copied. On the connected machine, re-export the full cache:
|
||||
|
||||
```bash
|
||||
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
|
||||
```
|
||||
|
||||
On the target, restore it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/go/pkg/mod/
|
||||
cp -r /mnt/usb/go-cache ~/go/pkg/mod/cache
|
||||
```
|
||||
@@ -1,288 +0,0 @@
|
||||
---
|
||||
title: "Multi-Node Installation"
|
||||
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- multi node
|
||||
- cluster
|
||||
badge: availability-zone
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "Viperblock (EBS)"
|
||||
url: "https://github.com/mulgadc/viperblock"
|
||||
---
|
||||
|
||||
# Multi-Node Installation
|
||||
|
||||
> Deploy Spinifex across multiple servers to create an availability zone.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Spinifex cluster operates as a fully distributed infrastructure region — similar to an AWS Region — where multiple nodes provide high availability, data durability, and fault tolerance.
|
||||
|
||||
**Services distributed across nodes:**
|
||||
|
||||
- **NATS** — Clustered message bus for request routing and JetStream replication
|
||||
- **Predastore** — Raft consensus with Reed-Solomon erasure coding (RS 2+1)
|
||||
- **Viperblock** — Block storage co-located with compute nodes
|
||||
- **Spinifex Daemon** — Independent AWS API request serving per node
|
||||
|
||||
Cluster formation is automatic. When initializing with `--nodes 3`, the init node starts a formation server and waits for peers to join. Once all nodes register, each receives the full cluster topology — credentials, CA certificates, NATS routes, Predastore peer lists — no manual configuration synchronization required.
|
||||
|
||||
**Network requirements:**
|
||||
|
||||
- Minimum 1 NIC per server
|
||||
- 2 NICs recommended for production (management + overlay)
|
||||
- UDP port 6081 open between hosts (Geneve tunnels)
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Spinifex on each server
|
||||
|
||||
Run the binary installer on **every server** in the cluster:
|
||||
|
||||
```bash
|
||||
curl https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
## Step 2. Set node IP variables
|
||||
|
||||
On **each server**, export the IPs for all nodes (replace with your actual IPs):
|
||||
|
||||
```bash
|
||||
export SPINIFEX_NODE1=192.168.1.10
|
||||
export SPINIFEX_NODE2=192.168.1.11
|
||||
export SPINIFEX_NODE3=192.168.1.12
|
||||
```
|
||||
|
||||
To find your server's IP:
|
||||
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
```
|
||||
|
||||
Next, define the AWS region and AZ you are deploying locally.
|
||||
|
||||
```bash
|
||||
export AWS_REGION=us-east-1
|
||||
export AWS_AZ=us-east-1a
|
||||
```
|
||||
|
||||
## Step 3. Setup OVN networking
|
||||
|
||||
OVN must be configured on every server before forming the cluster. Server 1 runs OVN central (NB and SB databases) and must be set up first.
|
||||
|
||||
**Server 1 — OVN central + compute (run first):**
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=$SPINIFEX_NODE1
|
||||
```
|
||||
|
||||
Verify OVN central is ready before proceeding:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
```
|
||||
|
||||
**Server 2 — Compute node (after server 1 is ready):**
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --ovn-remote=tcp:$SPINIFEX_NODE1:6642 --encap-ip=$SPINIFEX_NODE2
|
||||
```
|
||||
|
||||
**Server 3 — Compute node (after server 1 is ready):**
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --ovn-remote=tcp:$SPINIFEX_NODE1:6642 --encap-ip=$SPINIFEX_NODE3
|
||||
```
|
||||
|
||||
Verify all chassis have registered (from server 1):
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
```
|
||||
|
||||
You should see 3 chassis entries, one per server, each with a Geneve encap IP.
|
||||
|
||||
## Step 4. Form the cluster
|
||||
|
||||
The formation process requires running init and join commands concurrently. The init node blocks until all expected nodes have joined.
|
||||
|
||||
**Server 1 — Initialize the cluster:**
|
||||
|
||||
```bash
|
||||
sudo spx admin init \
|
||||
--node node1 \
|
||||
--nodes 3 \
|
||||
--bind $SPINIFEX_NODE1 \
|
||||
--cluster-bind $SPINIFEX_NODE1 \
|
||||
--port 4432 \
|
||||
--region $AWS_REGION \
|
||||
--az $AWS_AZ
|
||||
```
|
||||
|
||||
**Server 2 — Join the cluster (while init is running):**
|
||||
|
||||
```bash
|
||||
spx admin join \
|
||||
--node node2 \
|
||||
--bind $SPINIFEX_NODE2 \
|
||||
--cluster-bind $SPINIFEX_NODE2 \
|
||||
--host $SPINIFEX_NODE1:4432 \
|
||||
--region $AWS_REGION \
|
||||
--az $AWS_AZ
|
||||
```
|
||||
|
||||
**Server 3 — Join the cluster (while init is running):**
|
||||
|
||||
```bash
|
||||
spx admin join \
|
||||
--node node3 \
|
||||
--bind $SPINIFEX_NODE3 \
|
||||
--cluster-bind $SPINIFEX_NODE3 \
|
||||
--host $SPINIFEX_NODE1:4432 \
|
||||
--region $AWS_REGION \
|
||||
--az $AWS_AZ
|
||||
```
|
||||
|
||||
All three processes will exit with a cluster summary once formation is complete.
|
||||
|
||||
## Step 5. Start services and verify
|
||||
|
||||
Start services on **all servers**:
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
From any node, verify the cluster:
|
||||
|
||||
```bash
|
||||
spx get nodes
|
||||
```
|
||||
|
||||
```
|
||||
NAME STATUS IP REGION AZ UPTIME VMs
|
||||
node1 Ready 192.168.1.10 us-east-1 us-east-1a 2m 0
|
||||
node2 Ready 192.168.1.11 us-east-1 us-east-1a 2m 0
|
||||
node3 Ready 192.168.1.12 us-east-1 us-east-1a 2m 0
|
||||
```
|
||||
|
||||
## Step 6. Trust the CA certificate
|
||||
|
||||
On **each server**, trust the CA generated during init:
|
||||
|
||||
```bash
|
||||
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Step 7. Launch instances across the cluster
|
||||
|
||||
Connect to any node's AWS Gateway — all nodes serve the same cluster state:
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SPINIFEX_SUBNET \
|
||||
--count 3
|
||||
```
|
||||
|
||||
Spinifex distributes instances across nodes based on available capacity. Check which node each instance landed on:
|
||||
|
||||
```bash
|
||||
spx get vms
|
||||
```
|
||||
|
||||
## Shutdown
|
||||
|
||||
A graceful shutdown coordinates all nodes — drains VMs, flushes storage, and stops services:
|
||||
|
||||
```bash
|
||||
spx admin cluster shutdown
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Nodes not joining the cluster
|
||||
|
||||
The init command must still be running when you execute join on other servers. If init has already exited, re-run it.
|
||||
|
||||
Check that all servers can reach the init node on the formation port:
|
||||
|
||||
```bash
|
||||
curl -s http://$SPINIFEX_NODE1:4432/health
|
||||
```
|
||||
|
||||
## OVN chassis not registering
|
||||
|
||||
Compute nodes need to reach OVN central on server 1. Verify from server 1:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl show
|
||||
```
|
||||
|
||||
If chassis are missing, check firewall rules for port 6642:
|
||||
|
||||
```bash
|
||||
sudo ss -tlnp | grep 6642
|
||||
```
|
||||
|
||||
Re-run the OVN setup on the affected compute node.
|
||||
|
||||
## Cross-host VMs cannot communicate
|
||||
|
||||
This indicates a Geneve tunnel issue. Verify tunnels are established:
|
||||
|
||||
```bash
|
||||
sudo ovs-vsctl show | grep -i geneve
|
||||
```
|
||||
|
||||
Ensure UDP port 6081 is open between all hosts:
|
||||
|
||||
```bash
|
||||
sudo ss -ulnp | grep 6081
|
||||
```
|
||||
|
||||
If tunnels are missing, verify `--encap-ip` was set correctly during OVN setup.
|
||||
|
||||
## Health check fails on a node
|
||||
|
||||
Check if the daemon is running and listening on the correct IP:
|
||||
|
||||
```bash
|
||||
curl -s http://$SPINIFEX_NODE1:4432/health
|
||||
```
|
||||
|
||||
Review the daemon logs for errors:
|
||||
|
||||
```bash
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
```
|
||||
|
||||
Verify `--bind` was set to the correct IP during cluster formation.
|
||||
|
||||
## NATS cluster routing issues
|
||||
|
||||
Check NATS logs for connection errors between nodes:
|
||||
|
||||
```bash
|
||||
grep -i "route\|cluster" ~/spinifex/logs/nats.log
|
||||
```
|
||||
|
||||
Ensure `--cluster-bind` IPs are reachable between all servers.
|
||||