Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caf6c438d2 |
@@ -1,3 +0,0 @@
|
||||
# The committed bundle carries the AWS SDK's template strings verbatim,
|
||||
# including trailing spaces that are part of their values.
|
||||
spinifex/runtime/roles/spinifexui/frontend/dist/** -whitespace
|
||||
@@ -0,0 +1,15 @@
|
||||
# CODEOWNERS — GitHub PR review enforcement
|
||||
#
|
||||
# Format: <path pattern> <owner> [<owner> …]
|
||||
# Order matters — last matching pattern wins.
|
||||
#
|
||||
# Set the matching branch protection rule on `dev` and `main` to
|
||||
# "Require review from Code Owners" or this file is advisory only.
|
||||
|
||||
# Network layer is governed by its invariant tests.
|
||||
# Changes here must respect those clauses
|
||||
# see spinifex/domains/network/invariants/
|
||||
# mulga docs/development/proposals/region-local-architecture/0006-spinifex-network-layer-contract.md (proposal, not accepted)
|
||||
/spinifex/domains/network/ @joshsiv-mulga
|
||||
/spinifex/runtime/roles/vpcd/ @joshsiv-mulga
|
||||
/spinifex/domains/network/invariants/ @joshsiv-mulga
|
||||
|
Before Width: | Height: | Size: 167 KiB |
@@ -19,8 +19,8 @@
|
||||
</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 · PUBLIC SUBNETS · SPREAD ACROSS NODES</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 · server_count = 3</text>
|
||||
<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"/>
|
||||
@@ -41,45 +41,58 @@
|
||||
<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="150" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<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">10.20.1.0/24</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="22" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="122" 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="22" y="70" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="122" y="86" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-1</text>
|
||||
<text x="122" y="100" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
|
||||
<rect x="22" y="110" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="122" y="126" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-3</text>
|
||||
<text x="122" y="140" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
|
||||
|
||||
<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="150" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<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">10.20.2.0/24</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="22" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="122" 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="22" y="70" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="122" y="86" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-2</text>
|
||||
<text x="122" y="100" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- spread placement group -->
|
||||
<rect x="108" y="372" width="684" height="88" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="128" y="394" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">nginx-alb-spread</text>
|
||||
<text x="772" y="394" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">placement group · strategy spread · one server per node</text>
|
||||
<line x1="124" y1="402" x2="776" y2="402" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
|
||||
<rect x="128" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="230" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node1</text>
|
||||
<text x="230" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
|
||||
<rect x="348" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="450" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node2</text>
|
||||
<text x="450" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
|
||||
<rect x="568" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="670" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node3</text>
|
||||
<text x="670" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
|
||||
<!-- 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.1 KiB |
@@ -1,110 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580">
|
||||
<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="vpc-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#58a6ff14"/>
|
||||
<stop offset="100%" style="stop-color:#58a6ff05"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="node-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="svc-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#a855f71f"/>
|
||||
<stop offset="100%" style="stop-color:#a855f708"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#71717a"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="900" height="580" 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">SPINIFEX ON VULTR · ONE NODE OR THREE</text>
|
||||
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">--nodes 1 or 3 · vx1-g-16c-64g or bare metal</text>
|
||||
<rect x="30" y="52" width="250" height="56" rx="8" fill="#e4e4e70d" stroke="#e4e4e7" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="155.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#e4e4e7" text-anchor="middle" font-weight="700">Your machine</text>
|
||||
<text x="155.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">deploy-vultr.sh · OpenTofu or Terraform</text>
|
||||
<rect x="325" y="52" width="250" height="56" rx="8" fill="#3fb9500d" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="450.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" text-anchor="middle" font-weight="700">Internet</text>
|
||||
<text x="450.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">customers reach your EC2 and load balancers</text>
|
||||
<rect x="620" y="52" width="250" height="56" rx="8" fill="#58a6ff12" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
|
||||
<text x="745.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#79c0ff" text-anchor="middle" font-weight="700">Vultr API</text>
|
||||
<text x="745.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a5d6ff" text-anchor="middle">VPC · servers · reserved IPs</text>
|
||||
<rect x="30" y="160" width="840" height="400" rx="10" fill="url(#vpc-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.55" stroke-dasharray="6,4"/>
|
||||
<text x="50" y="184" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#79c0ff" font-weight="700">Vultr VPC</text>
|
||||
<text x="850" y="184" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#58a6ff" text-anchor="end">private network 10.10.8.0/24</text>
|
||||
<line x1="155" y1="108" x2="155" y2="158" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
|
||||
<text x="165" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="start">Terraform builds · SSH installs</text>
|
||||
<line x1="450" y1="108" x2="450" y2="158" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
|
||||
<text x="460" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="start">reserved IPs</text>
|
||||
<line x1="745" y1="158" x2="745" y2="110" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
|
||||
<text x="735" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="end">Spinifex calls the API</text>
|
||||
<rect x="60" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6"/>
|
||||
<text x="78" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node1</text>
|
||||
<text x="292" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">Ubuntu 24.04 · Spinifex</text>
|
||||
<line x1="74" y1="237" x2="296" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
|
||||
<rect x="76" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="113.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
|
||||
<rect x="160" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="190.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
|
||||
<rect x="230" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="260.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
|
||||
<text x="185" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
|
||||
<rect x="325" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="5,3"/>
|
||||
<text x="343" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node2</text>
|
||||
<text x="557" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">with --nodes 3</text>
|
||||
<line x1="339" y1="237" x2="561" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
|
||||
<rect x="341" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="378.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
|
||||
<rect x="425" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="455.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
|
||||
<rect x="495" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="525.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
|
||||
<text x="450" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
|
||||
<rect x="590" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="5,3"/>
|
||||
<text x="608" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node3</text>
|
||||
<text x="822" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">with --nodes 3</text>
|
||||
<line x1="604" y1="237" x2="826" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
|
||||
<rect x="606" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="643.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
|
||||
<rect x="690" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="720.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
|
||||
<rect x="760" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="790.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
|
||||
<text x="715" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
|
||||
<line x1="310" y1="264" x2="325" y2="264" stroke="#58a6ff" stroke-width="1.6" stroke-dasharray="3,3" opacity="0.9"/>
|
||||
<line x1="575" y1="264" x2="590" y2="264" stroke="#58a6ff" stroke-width="1.6" stroke-dasharray="3,3" opacity="0.9"/>
|
||||
<text x="450" y="348" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#79c0ff" text-anchor="middle">nodes cluster over the private network: control plane, EBS replicas and S3 shards</text>
|
||||
<rect x="60" y="370" width="780" height="166" rx="8" fill="url(#svc-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
|
||||
<text x="80" y="394" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">AWS services across the fleet</text>
|
||||
<text x="820" y="394" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">spread across the nodes; with three, moved to a survivor if one fails</text>
|
||||
<line x1="76" y1="402" x2="824" y2="402" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.6"/>
|
||||
<rect x="76" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="119.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EC2</text>
|
||||
<text x="119.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">instances</text>
|
||||
<rect x="169" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="212.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">ALB / NLB</text>
|
||||
<text x="212.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">load balancers</text>
|
||||
<rect x="262" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="305.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EKS</text>
|
||||
<text x="305.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">Kubernetes</text>
|
||||
<rect x="355" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="398.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">ECS</text>
|
||||
<text x="398.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">containers</text>
|
||||
<rect x="448" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="491.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">RDS</text>
|
||||
<text x="491.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">databases</text>
|
||||
<rect x="541" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="584.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">S3</text>
|
||||
<text x="584.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">object storage</text>
|
||||
<rect x="634" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="677.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EBS</text>
|
||||
<text x="677.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">volumes</text>
|
||||
<rect x="727" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
|
||||
<text x="770.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">IAM</text>
|
||||
<text x="770.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">access</text>
|
||||
<text x="450" y="500" font-family="'Segoe UI', system-ui, sans-serif" font-size="10.5" fill="#a1a1aa" text-anchor="middle">Use the AWS CLI, SDKs and Terraform against your own endpoint, unmodified.</text>
|
||||
<text x="450" y="520" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#71717a" text-anchor="middle">Public addresses are Vultr reserved IPs, attached to the node each resource runs on.</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 725 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -8,8 +8,7 @@
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": "",
|
||||
"timeout_minutes": 60
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 2,
|
||||
@@ -20,8 +19,7 @@
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": "",
|
||||
"timeout_minutes": 60
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 3,
|
||||
@@ -32,8 +30,7 @@
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": "",
|
||||
"timeout_minutes": 60
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 8,
|
||||
@@ -168,7 +165,7 @@
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": "instancerecovery",
|
||||
"timeout_minutes": 125
|
||||
"timeout_minutes": 105
|
||||
},
|
||||
{
|
||||
"cell": 32,
|
||||
@@ -203,8 +200,7 @@
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-single",
|
||||
"extra": "",
|
||||
"suite": "",
|
||||
"timeout_minutes": 60
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 35,
|
||||
@@ -227,27 +223,5 @@
|
||||
"runner": "ci-single",
|
||||
"extra": "nat-single",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 37,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "dhcp",
|
||||
"host": "oracle-10",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 38,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "nat",
|
||||
"host": "oracle-10",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "nat-multi",
|
||||
"suite": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
|
||||
<!-- The notable changes, as bullets. -->
|
||||
|
||||
## Account-scoped lifecycle impact
|
||||
|
||||
<!-- Complete this section only when the PR changes account-scoped resource lifecycle, account termination, resource cleanup, or a lifecycle boundary. Otherwise write: N/A. -->
|
||||
|
||||
- Lifecycle owner and resource family:
|
||||
- Owner-local ordering, recovery, retained-state, or force impact:
|
||||
- Cross-domain dependency or ownership impact:
|
||||
- Evidence added or rechecked:
|
||||
- ADR-0008 lifecycle register / ADR-0001 migration-record / bead updates:
|
||||
|
||||
## Community Contribution License
|
||||
|
||||
- [ ] All community contributions in this pull request are licensed to Mulga under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request I represent that I have the right to license these contributions to Mulga under the Apache License 2.0.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e-select.sh push <before> <sha> | suites "<e2e-x ...>"
|
||||
# Prints the e2e gate's single_legs, multi_suites and multi_timeout outputs.
|
||||
# Exits non-zero on anything it cannot read, which fails the e2e status.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
# shellcheck source=suite-sets.sh
|
||||
. "${SCRIPTS_DIR}/suite-sets.sh"
|
||||
|
||||
# Provision, install and teardown around the suites; the floor is what the
|
||||
# jobs have always had, so a budget can only be raised from it.
|
||||
SETUP_ALLOWANCE_MIN=15
|
||||
FLOOR_MIN=60
|
||||
|
||||
BASE_RUNNER=ci-single
|
||||
SERVICE_RUNNER=ci-pseudo
|
||||
|
||||
log() { printf '[e2e-select] %s\n' "$*" >&2; }
|
||||
die() { log "error: $*"; exit 1; }
|
||||
|
||||
# in_list <word> <list>
|
||||
in_list() {
|
||||
local w
|
||||
for w in $2; do [ "$w" = "$1" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
# keep <list> <eligible>: the words of <list> that are in <eligible>, deduplicated.
|
||||
keep() {
|
||||
local out="" s
|
||||
for s in $1; do
|
||||
in_list "$s" "$2" || continue
|
||||
in_list "$s" "$out" || out="${out:+$out }$s"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# budget <list>: max over the suites' own timeouts plus the setup allowance.
|
||||
# A sum would demand hours for a set that shares one job and never takes them.
|
||||
budget() {
|
||||
local want="${FLOOR_MIN}" s t
|
||||
for s in $1; do
|
||||
t="$(e2e_suite_timeout "$s")"
|
||||
t=$((${t%m} + SETUP_ALLOWANCE_MIN))
|
||||
[ "$t" -gt "$want" ] && want="$t"
|
||||
done
|
||||
printf '%s' "$want"
|
||||
}
|
||||
|
||||
prefixed() {
|
||||
local out="" s
|
||||
for s in $1; do out="${out:+$out }e2e-$s"; done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# leg <name> <runner> <suites>: one matrix entry, or nothing for no suites.
|
||||
leg() {
|
||||
[ -n "$3" ] || return 0
|
||||
jq -nc --arg leg "$1" --arg runner "$2" --arg suites "$(prefixed "$3")" \
|
||||
--argjson timeout "$(budget "$3")" \
|
||||
'{leg: $leg, runner: $runner, suites: $suites, timeout: $timeout}'
|
||||
}
|
||||
|
||||
# emit <base-single> <service-single> <multi>
|
||||
emit() {
|
||||
local legs
|
||||
legs=$( { leg base "${BASE_RUNNER}" "$1"; leg service "${SERVICE_RUNNER}" "$2"; } | jq -sc .)
|
||||
log "base leg: ${1:-none}"
|
||||
log "service leg: ${2:-none}"
|
||||
log "multi: ${3:-none}"
|
||||
printf 'single_legs=%s\n' "$legs"
|
||||
printf 'multi_suites=%s\n' "$(prefixed "$3")"
|
||||
printf 'multi_timeout=%s\n' "$(budget "$3")"
|
||||
}
|
||||
|
||||
# select_for_paths <newline-separated paths> [all]: base set plus matches.
|
||||
# With "all", every mapped suite is selected, for a push with no baseline.
|
||||
select_for_paths() {
|
||||
local matched="" p s
|
||||
if [ "${2:-}" = all ]; then
|
||||
matched=$(e2e_path_suites_all | tr '\n' ' ')
|
||||
else
|
||||
while IFS= read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
for s in $(e2e_suites_for_path "$p"); do
|
||||
in_list "$s" "$matched" || { matched="${matched:+$matched }$s"; log "${p} -> ${s}"; }
|
||||
done
|
||||
done <<<"$1"
|
||||
fi
|
||||
# A matched suite runs where it is eligible, preferring one node, so route53
|
||||
# goes to the service leg and lb to the multi job.
|
||||
local extra_single="" extra_multi=""
|
||||
for s in $matched; do
|
||||
if in_list "$s" "${E2E_SUITES_SINGLE}"; then
|
||||
extra_single="${extra_single:+$extra_single }$s"
|
||||
elif in_list "$s" "${E2E_SUITES_MULTI}"; then
|
||||
extra_multi="${extra_multi:+$extra_multi }$s"
|
||||
else
|
||||
die "E2E_PATH_SUITES names '${s}', which no topology can run"
|
||||
fi
|
||||
done
|
||||
local base_single base_multi
|
||||
base_single=$(keep "${E2E_SUITES_PUSH_BASE}" "${E2E_SUITES_SINGLE}")
|
||||
base_multi=$(keep "${E2E_SUITES_PUSH_BASE}" "${E2E_SUITES_MULTI}")
|
||||
# A matched suite already in the base leg runs there, not twice.
|
||||
extra_single=$(for s in $extra_single; do in_list "$s" "$base_single" || printf '%s ' "$s"; done)
|
||||
emit "$base_single" "$(keep "$extra_single" "${E2E_SUITES_SINGLE}")" \
|
||||
"$(keep "$base_multi $extra_multi" "${E2E_SUITES_MULTI}")"
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
push)
|
||||
[ $# -eq 3 ] || die "usage: $0 push <before> <sha>"
|
||||
before="$2" sha="$3"
|
||||
git cat-file -e "${sha}^{commit}" 2>/dev/null || die "commit ${sha} is not in this checkout"
|
||||
if [ -z "${before//0/}" ]; then
|
||||
log "no previous commit for this push; selecting every mapped suite"
|
||||
select_for_paths "" all
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
|
||||
git fetch -q --filter=blob:none origin "${before}" 2>/dev/null || true
|
||||
fi
|
||||
if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
|
||||
log "previous commit ${before} is gone; selecting every mapped suite"
|
||||
select_for_paths "" all
|
||||
exit 0
|
||||
fi
|
||||
# A rename counts as both paths, so moving code out of a service selects it.
|
||||
changed=$(git diff --no-renames --name-only "${before}" "${sha}") || die "git diff ${before} ${sha} failed"
|
||||
log "$(printf '%s\n' "$changed" | grep -c . || true) paths changed ${before:0:9}..${sha:0:9}"
|
||||
select_for_paths "$changed"
|
||||
;;
|
||||
suites)
|
||||
[ $# -eq 2 ] || die "usage: $0 suites \"<e2e-x ...>\""
|
||||
if [ -z "${2// /}" ]; then
|
||||
select_for_paths ""
|
||||
exit 0
|
||||
fi
|
||||
requested=$(printf '%s' "$2" | sed 's/e2e-//g')
|
||||
emit "$(keep "$requested" "${E2E_SUITES_SINGLE}")" "" "$(keep "$requested" "${E2E_SUITES_MULTI}")"
|
||||
;;
|
||||
*)
|
||||
die "usage: $0 push <before> <sha> | suites \"<list>\""
|
||||
;;
|
||||
esac
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trims GOCACHE to the entries one CI run used. Go refreshes an entry's mtime
|
||||
# on use only when it is over an hour old, so "mark" backdates every restored
|
||||
# entry first and "trim" drops whatever this run neither read nor wrote.
|
||||
set -euo pipefail
|
||||
|
||||
cache="$(go env GOCACHE)"
|
||||
mark="${RUNNER_TEMP:?}/go-build-cache.mark"
|
||||
|
||||
# stale prints each entry this run did not touch and no touched action entry
|
||||
# names. Go skips rewriting an output it already holds, so a fresh action
|
||||
# entry can point at a backdated output.
|
||||
stale() {
|
||||
local keep="${RUNNER_TEMP}/go-build-cache.keep"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 -name '*-a' -newer "$mark" -exec cat {} + |
|
||||
awk '{ print "/" $3 "-d" }' >"$keep"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 ! -newer "$mark" | { grep -vFf "$keep" || true; }
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
mark)
|
||||
mkdir -p "$cache"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 -exec touch -m -d '2 hours ago' {} +
|
||||
touch "$mark"
|
||||
;;
|
||||
stale | trim)
|
||||
if [[ ! -f "$mark" ]]; then
|
||||
echo "go-build-cache: no mark, leaving the cache untrimmed" >&2
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == stale ]]; then
|
||||
stale
|
||||
exit 0
|
||||
fi
|
||||
before=$(du -sm "$cache" | cut -f1)
|
||||
# An entry is a file, or a directory holding one cached executable whose
|
||||
# mtime Go refreshes in place of the binary's, so it goes as a unit.
|
||||
stale | xargs -r rm -rf --
|
||||
echo "trimmed ${before}MB -> $(du -sm "$cache" | cut -f1)MB"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 mark|stale|trim" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: hypervisor-disk-preflight.sh <env.tfvars>
|
||||
#
|
||||
# Fails fast when a hypervisor named in the tofu env is short of disk, before
|
||||
# `tofu apply` tries to define a domain there. libvirt writes each domain's XML
|
||||
# under /etc/libvirt/qemu on the root filesystem and its disks into the pool, and
|
||||
# a full one fails the apply minutes in as "Domain Creation Failed".
|
||||
#
|
||||
# Hosts are reached the way the libvirt provider reaches them (ssh <user>@<name>,
|
||||
# resolved through ~/.ssh/config). A host that cannot be reached is reported and
|
||||
# skipped: tofu's own error for it is clearer than anything said here.
|
||||
#
|
||||
# Env: HV_SSH_USER (tf-user), HV_MIN_ROOT_FREE_MB (1024), HV_MIN_POOL_FREE_GB (20).
|
||||
set -uo pipefail
|
||||
|
||||
tfvars="${1:?usage: $0 <env.tfvars>}"
|
||||
user="${HV_SSH_USER:-tf-user}"
|
||||
min_root_kb=$(( ${HV_MIN_ROOT_FREE_MB:-1024} * 1024 ))
|
||||
min_pool_kb=$(( ${HV_MIN_POOL_FREE_GB:-20} * 1024 * 1024 ))
|
||||
|
||||
if [ ! -r "$tfvars" ]; then
|
||||
echo "::error::hypervisor disk preflight: cannot read $tfvars" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# One node per line in every env file: { name = "...", ..., storage_pool = "..." }.
|
||||
pairs=$(grep -v '^[[:space:]]*#' "$tfvars" |
|
||||
sed -nE 's/.*name[[:space:]]*=[[:space:]]*"([^"]+)".*storage_pool[[:space:]]*=[[:space:]]*"([^"]+)".*/\1 \2/p')
|
||||
if [ -z "$pairs" ]; then
|
||||
echo "::warning::hypervisor disk preflight: no nodes with a storage_pool in $tfvars; nothing checked"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prints "<root-avail-kb> <pool-avail-kb> <pool-path>" for the pool named in $1.
|
||||
# shellcheck disable=SC2016 # expanded on the hypervisor, not here
|
||||
remote='set -u
|
||||
root=$(df -Pk /etc/libvirt/qemu | awk "NR==2{print \$4}")
|
||||
path=$(virsh -c qemu:///system pool-dumpxml "$1" 2>/dev/null | sed -n "s:.*<path>\(.*\)</path>.*:\1:p" | head -n1)
|
||||
pool=-
|
||||
[ -n "$path" ] && pool=$(df -Pk "$path" | awk "NR==2{print \$4}")
|
||||
echo "$root $pool ${path:--}"'
|
||||
|
||||
fail=0
|
||||
while read -r host pool; do
|
||||
[ -n "$host" ] || continue
|
||||
if ! out=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${user}@${host}" bash -s -- "$pool" <<<"$remote" 2>&1); then
|
||||
echo "::warning::hypervisor disk preflight: cannot reach $host ($out); its disk is unchecked"
|
||||
continue
|
||||
fi
|
||||
read -r root_kb pool_kb pool_path <<<"$(tail -n1 <<<"$out")"
|
||||
if ! [[ "$root_kb" =~ ^[0-9]+$ ]]; then
|
||||
echo "::warning::hypervisor disk preflight: unexpected answer from $host: $out"
|
||||
continue
|
||||
fi
|
||||
echo "$host: root fs $((root_kb / 1024)) MiB free; pool $pool ($pool_path) $([[ "$pool_kb" =~ ^[0-9]+$ ]] && echo "$((pool_kb / 1024 / 1024)) GiB free" || echo "not found")"
|
||||
if [ "$root_kb" -lt "$min_root_kb" ]; then
|
||||
echo "::error::hypervisor $host has $((root_kb / 1024)) MiB free on the filesystem holding /etc/libvirt/qemu (need ${HV_MIN_ROOT_FREE_MB:-1024} MiB): libvirt cannot write a domain definition there. Free space on $host before re-running." >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$pool_kb" =~ ^[0-9]+$ ]] && [ "$pool_kb" -lt "$min_pool_kb" ]; then
|
||||
echo "::error::hypervisor $host has $((pool_kb / 1024 / 1024)) GiB free in storage pool $pool at $pool_path (need ${HV_MIN_POOL_FREE_GB:-20} GiB): the node's disk cannot grow. Free space on $host before re-running." >&2
|
||||
fail=1
|
||||
fi
|
||||
done <<<"$pairs"
|
||||
|
||||
exit "$fail"
|
||||
@@ -66,67 +66,6 @@ E2E_SUITES_MULTI="multinode lb cert quota route53 kvquorum lbrecovery instancere
|
||||
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
|
||||
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
|
||||
|
||||
# The push gate always runs this set. A push adds to it the suites whose code
|
||||
# it changed, by E2E_PATH_SUITES below; a dispatch names its own set instead.
|
||||
E2E_SUITES_PUSH_BASE="single multinode cert iam partialblock"
|
||||
|
||||
# "suite prefix" pairs: a push changing a path under prefix adds suite. The
|
||||
# recovery and fault suites stay nightly, and shared code adds nothing because
|
||||
# the base set covers it. Image directories carry each guest's own scripts.
|
||||
E2E_PATH_SUITES="
|
||||
rds cmd/rds-agent/
|
||||
rds internal/rdsgw/
|
||||
rds spinifex/agents/rds/
|
||||
rds spinifex/domains/rds/
|
||||
rds tests/e2e/rds/
|
||||
rds scripts/images/rds-mariadb/
|
||||
rds scripts/images/rds-postgres/
|
||||
ecs cmd/ecs-agent/
|
||||
ecs contracts/ecs/
|
||||
ecs internal/ecsgw/
|
||||
ecs spinifex/agents/ecs/
|
||||
ecs spinifex/domains/ecs/
|
||||
ecs tests/e2e/ecs/
|
||||
ecs scripts/images/ecs-agent/
|
||||
eks cmd/eks-
|
||||
eks contracts/eks/
|
||||
eks internal/eksgw/
|
||||
eks spinifex/agents/eks/
|
||||
eks spinifex/domains/eks/
|
||||
eks tests/e2e/eks/
|
||||
eks scripts/images/eks-node/
|
||||
rds scripts/images/common/
|
||||
ecs scripts/images/common/
|
||||
eks scripts/images/common/
|
||||
rds scripts/build-system-image.sh
|
||||
ecs scripts/build-system-image.sh
|
||||
eks scripts/build-system-image.sh
|
||||
route53 spinifex/domains/route53/
|
||||
route53 tests/e2e/route53/
|
||||
lb cmd/lb-agent/
|
||||
lb spinifex/agents/elbv2/
|
||||
lb spinifex/domains/elbv2/
|
||||
lb tests/e2e/lb/
|
||||
"
|
||||
|
||||
# Every suite E2E_PATH_SUITES can name, once each, in table order.
|
||||
# e2e_path_suites_all
|
||||
e2e_path_suites_all() {
|
||||
printf '%s\n' "${E2E_PATH_SUITES}" | awk 'NF == 2 && !seen[$1]++ { print $1 }'
|
||||
}
|
||||
|
||||
# The suites a change to <path> triggers, one per line.
|
||||
# e2e_suites_for_path <path>
|
||||
e2e_suites_for_path() {
|
||||
local path="$1" suite prefix
|
||||
while read -r suite prefix; do
|
||||
[ -n "${prefix}" ] || continue
|
||||
case "${path}" in
|
||||
"${prefix}"*) printf '%s\n' "${suite}" ;;
|
||||
esac
|
||||
done <<<"${E2E_PATH_SUITES}"
|
||||
}
|
||||
|
||||
# What every e2e-nightly permutation cell must prove, deliberately narrower
|
||||
# than the two sets above: those cells have a 35-minute budget and exist to show
|
||||
# that every install / network / host-OS combination boots and serves. The
|
||||
@@ -178,9 +117,8 @@ e2e_suite_timeout() {
|
||||
# storage case waits for a pause measured at 4m47s and then holds four
|
||||
# minutes to prove nothing moved. Three tests, each taking a node away and
|
||||
# bringing it back, and the partition case adds a lease validity, a lease
|
||||
# TTL and a boot on top of everything the other two wait for. The data
|
||||
# test lets its guest age past the pending watchdog twice, about 20 more.
|
||||
instancerecovery) echo "110m" ;;
|
||||
# TTL and a boot on top of everything the other two wait for.
|
||||
instancerecovery) echo "90m" ;;
|
||||
# lbrecovery pays for a load balancer twice: once to stand one up with two
|
||||
# backends placed around it, and once for the recovery itself — a stale
|
||||
# owner, a lease, a boot, HAProxy's own start, and its health checks against
|
||||
@@ -191,10 +129,6 @@ e2e_suite_timeout() {
|
||||
# budget on a node stopping and starting: seven units down, a raft election,
|
||||
# and the whole service stack back up with every stream caught up.
|
||||
kvquorum) echo "40m" ;;
|
||||
# single is the broadest suite and finishes, it does not hang: passing runs
|
||||
# took 24-29m, and an Oracle host ran out at 30m inside the last test's
|
||||
# cleanup. The default left it no headroom at all.
|
||||
single) echo "40m" ;;
|
||||
*) echo "30m" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: wait-for-memory.sh <MiB>
|
||||
#
|
||||
# Waits until the runner's MemAvailable reaches <MiB>, then returns. Runners on
|
||||
# one host share its RAM, and a neighbour's build holds several GiB for a few
|
||||
# minutes, so one sample taken during it is not a verdict on the host.
|
||||
# Gives up after MEM_WAIT_TIMEOUT_S and names the biggest processes.
|
||||
#
|
||||
# Env: MEM_WAIT_TIMEOUT_S (900), MEM_WAIT_POLL_S (30), MEMINFO (/proc/meminfo).
|
||||
set -uo pipefail
|
||||
|
||||
want_mb="${1:?usage: $0 <MiB>}"
|
||||
timeout_s="${MEM_WAIT_TIMEOUT_S:-900}"
|
||||
poll_s="${MEM_WAIT_POLL_S:-30}"
|
||||
meminfo="${MEMINFO:-/proc/meminfo}"
|
||||
|
||||
avail_mb() { awk '/^MemAvailable:/ {print int($2 / 1024)}' "$meminfo"; }
|
||||
|
||||
start=$SECONDS
|
||||
while :; do
|
||||
have=$(avail_mb)
|
||||
if [ -n "$have" ] && [ "$have" -ge "$want_mb" ]; then
|
||||
echo "memory: ${have} MiB available, need ${want_mb} MiB"
|
||||
exit 0
|
||||
fi
|
||||
waited=$(( SECONDS - start ))
|
||||
if [ "$waited" -ge "$timeout_s" ]; then
|
||||
echo "::error::need ${want_mb} MiB available, have ${have:-unknown} MiB after waiting ${waited}s"
|
||||
ps -eo rss,pid,args --sort=-rss | head -11 | cut -c1-200
|
||||
exit 1
|
||||
fi
|
||||
echo "memory: ${have:-unknown} MiB available, need ${want_mb} MiB; waiting (${waited}s of ${timeout_s}s)"
|
||||
sleep "$poll_s"
|
||||
done
|
||||
@@ -18,11 +18,6 @@ workflows:
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'hashicorp/setup-terraform@v4.0.1'
|
||||
'.github/workflows/e2e-cloudvendor-vultr.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'hashicorp/setup-terraform@v4.0.1'
|
||||
'.github/workflows/e2e-ddil.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
@@ -78,7 +73,6 @@ workflows:
|
||||
'.github/workflows/ui.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-node@v7.1.0'
|
||||
- 'actions/upload-artifact@v7.0.2'
|
||||
- 'pnpm/action-setup@v6.1.0'
|
||||
dependencies:
|
||||
'actions/cache@v6.1.0':
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Codex review gate: calls bluebottle's reusable workflows from main, so a PR cannot change its own review.
|
||||
name: codex-review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [dev]
|
||||
types: [opened, synchronize, reopened, ready_for_review, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
uses: mulgadc/bluebottle/.github/workflows/codex-pr-review.yml@main
|
||||
secrets: inherit
|
||||
waive:
|
||||
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
|
||||
uses: mulgadc/bluebottle/.github/workflows/codex-waive.yml@main
|
||||
secrets: inherit
|
||||
@@ -30,10 +30,6 @@ concurrency:
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-26.04
|
||||
env:
|
||||
# Dev prereleases, newest published first: GitHub lists releases by tag name
|
||||
# as text, so list order is not build order. The install Worker uses this rule.
|
||||
DEV_RELEASES_JQ: '[.[] | select(.draft == false and .prerelease and .published_at != null and (.tag_name | test("-dev(\\.|$)")))] | sort_by(.published_at) | reverse'
|
||||
steps:
|
||||
# Full history and tags, because the tag name is git describe and a shallow
|
||||
# clone has no tag to describe against.
|
||||
@@ -106,9 +102,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
@@ -168,12 +164,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "latest is still $latest"
|
||||
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq "$DEV_RELEASES_JQ | .[0].tag_name")"
|
||||
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" --jq 'map(select(.prerelease))[0].tag_name')"
|
||||
[ "$dev" = "$TAG" ] || { echo "::error::the dev channel resolves to $dev, not $TAG"; exit 1; }
|
||||
|
||||
# Kept rather than unbounded: the Worker reads one page of releases to resolve
|
||||
# the channel, so enough prereleases would eventually push the newest off it.
|
||||
# Five leaves a few recent builds to roll back to.
|
||||
# Kept rather than unbounded: the Worker reads the first page of releases to
|
||||
# resolve the channel, so enough prereleases would eventually push the real
|
||||
# ones off it. Five leaves a few recent builds to roll back to.
|
||||
- name: Prune old dev prereleases
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
env:
|
||||
@@ -181,7 +177,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
--jq "$DEV_RELEASES_JQ | .[5:] | .[].tag_name" \
|
||||
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[5:] | .[].tag_name' \
|
||||
> stale.txt
|
||||
while read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
|
||||
@@ -71,16 +71,9 @@ jobs:
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ inputs.cell || '20' }}-${{ github.run_id }}
|
||||
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs || format('ci.run_id={0},ci.workflow={1},ci.cell={2},ci.matrix=baremetal/pxe/real-hw', github.run_id, github.workflow, inputs.cell || '20') }}
|
||||
steps:
|
||||
# A cancelled job's root steps outlive it, since the runner cannot reach
|
||||
# them once it kills sudo, and would keep writing here and to the cache.
|
||||
# A rootfs killed before bin/bash exists is one the build refuses to reuse.
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
sudo systemctl stop spinifex-bm-pxe.scope spinifex-bm-e2e.scope 2>/dev/null || true
|
||||
sudo umount -R -l "${BM_ISO_CACHE}/rootfs" 2>/dev/null || true
|
||||
if [ -d "${BM_ISO_CACHE}/rootfs" ] && [ ! -x "${BM_ISO_CACHE}/rootfs/bin/bash" ]; then
|
||||
sudo rm -rf --one-file-system "${BM_ISO_CACHE}/rootfs"
|
||||
fi
|
||||
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
@@ -120,15 +113,10 @@ jobs:
|
||||
"$GITHUB_WORKSPACE" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
"${RUNNER_TEMP}/bm-bins"
|
||||
# Built here, as the runner user, so the PXE build finds it current.
|
||||
make -C "$GITHUB_WORKSPACE" build-microvm-image
|
||||
|
||||
- name: Build PXE Artifacts
|
||||
run: |
|
||||
# -o build-spinifex: the binaries come from Build Artifacts, and a
|
||||
# rebuild here would write root-owned files into the workspace.
|
||||
sudo systemd-run --scope --collect --quiet --unit=spinifex-bm-pxe -- \
|
||||
make -C mulga/scripts/iso-builder -o build-spinifex \
|
||||
sudo make -C mulga/scripts/iso-builder \
|
||||
build-rootfs build-squashfs build-initramfs \
|
||||
CACHE="${BM_ISO_CACHE}" \
|
||||
SPINIFEX_BIN="${RUNNER_TEMP}/bm-bins/spx" \
|
||||
@@ -136,10 +124,6 @@ jobs:
|
||||
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
|
||||
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
|
||||
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always --exclude '*-dev' 2>/dev/null || echo dev)"
|
||||
if root_owned="$(find "$GITHUB_WORKSPACE" -user root -print -quit)" && [ -n "$root_owned" ]; then
|
||||
echo "::error::the PXE build wrote root-owned files into the workspace, e.g. $root_owned"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Write ePDU credentials
|
||||
run: |
|
||||
@@ -211,18 +195,13 @@ jobs:
|
||||
done
|
||||
export BM_TEST_TIMEOUT
|
||||
echo "suites=${BM_SUITES} test-timeout=${BM_TEST_TIMEOUT}"
|
||||
sudo -E systemd-run --scope --collect --quiet --unit=spinifex-bm-e2e -- \
|
||||
mulga/scripts/baremetal/run-baremetal-e2e.sh \
|
||||
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
|
||||
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
|
||||
--initrd "${BM_ISO_CACHE}/initrd.img" \
|
||||
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
|
||||
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
2>&1 | tee "${ARTIFACT_DIR}/test-baremetal.log"
|
||||
|
||||
- name: Stop this job's root steps
|
||||
if: always()
|
||||
run: sudo systemctl stop spinifex-bm-pxe.scope spinifex-bm-e2e.scope 2>/dev/null || true
|
||||
|
||||
- name: Generate JUnit report
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
name: E2E Cloud Vendor Vultr
|
||||
|
||||
# On demand only. Deploys a ref to Vultr with deploy-vultr.sh, runs the same
|
||||
# published Terraform workbooks as the hypervisor nightly and OCI, and destroys
|
||||
# the deployment, failing if anything is left billing.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
nodes:
|
||||
description: "1 or 3 nodes"
|
||||
required: false
|
||||
default: "1"
|
||||
type: choice
|
||||
options: ["1", "3"]
|
||||
plan:
|
||||
description: "Vultr plan: vx1-g-16c-64g (VM) or vbm-8c-64gb-amd (bare metal)"
|
||||
required: false
|
||||
default: vx1-g-16c-64g
|
||||
region:
|
||||
description: "Vultr region"
|
||||
required: false
|
||||
default: ord
|
||||
workbooks:
|
||||
description: "Space-separated workbooks (blank = the driver's full list)"
|
||||
required: false
|
||||
source:
|
||||
description: "tree (build this ref) or release (the published installer)"
|
||||
required: false
|
||||
default: tree
|
||||
type: choice
|
||||
options: [tree, release]
|
||||
channel:
|
||||
description: "With source=release: latest, or dev for the newest prerelease"
|
||||
required: false
|
||||
default: latest
|
||||
keep_on_fail:
|
||||
description: "Leave a failed deployment up to inspect. It keeps billing."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
runner:
|
||||
description: "Runner label (blank = the VULTR_E2E_RUNNER variable, else ubuntu-26.04)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Deployments are named per run, but the account's reserved-IP quota is shared,
|
||||
# so a second dispatch queues rather than competing with the first.
|
||||
concurrency:
|
||||
group: e2e-vultr
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
vultr:
|
||||
# Hosted by default: the job only drives Terraform and SSH. The API key's
|
||||
# access control must admit this runner, so a hosted runner needs a key
|
||||
# whose list holds 0.0.0.0/0; a self-hosted runner can use a fixed address.
|
||||
runs-on: ${{ inputs.runner || vars.VULTR_E2E_RUNNER || 'ubuntu-26.04' }}
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
VULTR_NAME: e2e-${{ github.run_id }}
|
||||
VULTR_NODES: ${{ inputs.nodes }}
|
||||
VULTR_PLAN: ${{ inputs.plan }}
|
||||
VULTR_REGION: ${{ inputs.region }}
|
||||
VULTR_SOURCE: ${{ inputs.source }}
|
||||
VULTR_CHANNEL: ${{ inputs.channel }}
|
||||
VULTR_ARTIFACT_DIR: ${{ github.workspace }}/e2e-vultr
|
||||
SPX_REF: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/create-github-app-token@v3.3.0
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
# build-install-artifacts.sh lives in mulga; the workbooks and their driver
|
||||
# are in spinifex, so only the one directory is needed here.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
path: mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
|
||||
- uses: hashicorp/setup-terraform@v4.0.1
|
||||
with:
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Run Vultr
|
||||
working-directory: spinifex/scripts/terraform/vultr-spx
|
||||
env:
|
||||
VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }}
|
||||
MULGA_ROOT: ${{ github.workspace }}/mulga
|
||||
# Through the environment, never interpolated into the script: a
|
||||
# dispatch input is untrusted text to the shell.
|
||||
WORKBOOKS_INPUT: ${{ inputs.workbooks }}
|
||||
VULTR_KEEP_ON_FAIL: ${{ inputs.keep_on_fail && '1' || '0' }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -n "$WORKBOOKS_INPUT" ]; then export WORKBOOKS="$WORKBOOKS_INPUT"; fi
|
||||
./e2e-vultr.sh 2>&1 | tee "$RUNNER_TEMP/e2e-vultr.log"
|
||||
exit "${PIPESTATUS[0]}"
|
||||
|
||||
# always(): a cancelled or timed-out job is the one that would leave
|
||||
# servers and reserved IPs billing. destroy works from this run's state.
|
||||
- name: Sweep anything left behind
|
||||
if: always() && !inputs.keep_on_fail
|
||||
working-directory: spinifex/scripts/terraform/vultr-spx
|
||||
env:
|
||||
VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }}
|
||||
run: ./e2e-vultr.sh --sweep-only
|
||||
|
||||
- name: Publish the verdict
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "### Vultr: ${VULTR_NODES} x ${VULTR_PLAN} in ${VULTR_REGION}, ${VULTR_SOURCE} build of ${SPX_REF}"
|
||||
echo '```'
|
||||
cat "$VULTR_ARTIFACT_DIR/verdict.txt" 2>/dev/null || echo "no verdict written"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Logs and the verdict only. The throwaway SSH key and the Terraform state
|
||||
# under ~/.spinifex never reach an artifact.
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: e2e-vultr-${{ github.run_id }}
|
||||
path: |
|
||||
${{ runner.temp }}/e2e-vultr.log
|
||||
${{ github.workspace }}/e2e-vultr/*.log
|
||||
${{ github.workspace }}/e2e-vultr/verdict.txt
|
||||
${{ github.workspace }}/e2e-vultr/node*/*.log
|
||||
retention-days: 14
|
||||
@@ -6,32 +6,11 @@ name: E2E Nightly
|
||||
# `main` and `dev` as separate nightlies, and it would always execute `main`'s
|
||||
# copy of this file — leaving a fix to the workflow itself untestable until it
|
||||
# had already shipped. A dispatch takes both the file and the source from its ref.
|
||||
#
|
||||
# Cells (matrix cells are defined in .github/e2e-cells.json):
|
||||
#
|
||||
# Single node Three nodes
|
||||
# 1 Debian 13, DHCP 8 Debian 13, DHCP
|
||||
# 2 Ubuntu 26.04, static 9 Ubuntu 26.04, static
|
||||
# 3 Ubuntu 26.04, DHCP 10 Ubuntu 26.04, DHCP
|
||||
# 17 tofu examples 29 storage fault injection
|
||||
# 18 reboot 30 routed NAT
|
||||
# 19 routed NAT 31 instance recovery
|
||||
# 25 eks 32 load balancer recovery
|
||||
# 26 ecs 33 KV bucket quorum under node loss
|
||||
# 27 rds 37 Oracle Linux 10, DHCP
|
||||
# 34 Oracle Linux 10, DHCP 38 Oracle Linux 10, routed NAT
|
||||
# 35 Oracle Linux 10, reboot
|
||||
# 36 Oracle Linux 10, routed NAT
|
||||
#
|
||||
# Standalone jobs
|
||||
# 20 bare metal (PXE) 23 predastore throughput
|
||||
# 21 ISO, single node 24 predastore fault injection
|
||||
# 22 ISO, three nodes 28 guest disk performance (bare metal)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers, no spaces, e.g. 1,25,27 (blank = all). The cell list is at the top of .github/workflows/e2e-nightly.yml."
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
@@ -205,14 +184,6 @@ jobs:
|
||||
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
|
||||
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
|
||||
|
||||
# A hypervisor out of disk fails the apply minutes in, as "Domain Creation
|
||||
# Failed" from libvirt writing the domain XML. Say so before starting.
|
||||
- name: Preflight hypervisor disk
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/hypervisor-disk-preflight.sh" \
|
||||
"envs/${SPINIFEX_CI_ENV}.tfvars"
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
@@ -270,11 +241,6 @@ jobs:
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
e2e_suites_assert_subset "e2e-nightly single" "${E2E_SUITES_NIGHTLY_SINGLE}" "${E2E_SUITES_SINGLE}"
|
||||
|
||||
# The select job sized this cell's budget from these same timeouts, so
|
||||
# resolve them here and carry the pairs into the remote loop.
|
||||
SUITE_TMOS=""
|
||||
for s in ${E2E_SUITES_NIGHTLY_SINGLE}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
|
||||
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
@@ -284,10 +250,9 @@ jobs:
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_SINGLE}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./\$SUITE.test -test.v -test.timeout=\$TMO \
|
||||
./\$SUITE.test -test.v -test.timeout=30m \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
|
||||
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
|
||||
@@ -359,20 +324,16 @@ jobs:
|
||||
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
|
||||
done
|
||||
|
||||
SUITE_TMOS=""
|
||||
for s in ${E2E_SUITES_NIGHTLY_MULTI}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
|
||||
|
||||
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
|
||||
for SUITE in ${E2E_SUITES_NIGHTLY_MULTI}; do \
|
||||
echo \"::group::suite \$SUITE\"; \
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
|
||||
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
|
||||
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
|
||||
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
|
||||
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
|
||||
ARTIFACT_DIR='${ARTIFACT_DIR}' \
|
||||
./\$SUITE.test -test.v -test.timeout=\$TMO \
|
||||
./\$SUITE.test -test.v -test.timeout=30m \
|
||||
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log'; \
|
||||
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
|
||||
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
|
||||
@@ -1038,7 +999,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ env.GIT_BRANCH }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: mulga/spinifex
|
||||
|
||||
- uses: actions/checkout@v7.0.1
|
||||
@@ -1078,6 +1039,9 @@ jobs:
|
||||
{ echo "::error::br-wan not permitted in /etc/qemu/bridge.conf"; exit 1; }
|
||||
command -v sshpass >/dev/null ||
|
||||
{ echo "::error::sshpass not installed"; exit 1; }
|
||||
free_mb=$(free -m | awk '/^Mem:/ {print $7}')
|
||||
[ "$free_mb" -ge 8192 ] ||
|
||||
{ echo "::error::need 8 GiB available, have ${free_mb} MiB"; exit 1; }
|
||||
# A 40G image on tmpfs competes with the 8 GiB the VM itself needs.
|
||||
fstype=$(df --output=fstype "$TMPDIR" | tail -1 | tr -d ' ')
|
||||
[ "$fstype" != tmpfs ] ||
|
||||
@@ -1108,10 +1072,6 @@ jobs:
|
||||
ssh-keygen -t ed25519 -N "" -f "$HOME/.ssh/$k" -C "$k-ci"
|
||||
done
|
||||
|
||||
# The node's 8 GiB, claimed when the VM boots rather than at preflight.
|
||||
- name: Wait for memory
|
||||
run: mulga/spinifex/.github/scripts/wait-for-memory.sh 8192
|
||||
|
||||
# Boots the ISO, installs unattended, reads the lease off the serial
|
||||
# console, runs the in-guest smoke test, and installs the key for the
|
||||
# next step.
|
||||
@@ -1225,16 +1185,6 @@ jobs:
|
||||
sleep 2
|
||||
rm -f "$TMPDIR"/tmp.*.qcow2 "$TMPDIR"/tmp.*.log "$TMPDIR"/tmp.*-node.log
|
||||
|
||||
# The ISO build runs as root inside the workspace, and the next job on
|
||||
# this runner, from any workflow, cannot remove what it leaves there.
|
||||
- name: Release the workspace
|
||||
if: always()
|
||||
run: |
|
||||
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
|
||||
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
|
||||
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
|
||||
|
||||
iso-multi:
|
||||
name: cell-22 (iso/dhcp/three-node)
|
||||
# After cell-21 rather than beside it: same concurrency group, and running
|
||||
@@ -1296,7 +1246,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ env.GIT_BRANCH }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: mulga/spinifex
|
||||
|
||||
- uses: actions/checkout@v7.0.1
|
||||
@@ -1323,10 +1273,15 @@ jobs:
|
||||
ref: ${{ steps.siblings.outputs.bluebottle }}
|
||||
path: mulga/bluebottle
|
||||
|
||||
# 3 x 8 GiB plus 9 sparse 40G disks, before the instances the cluster
|
||||
# launches on top.
|
||||
- name: Preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$TMPDIR"
|
||||
free_mb=$(free -m | awk '/^Mem:/ {print $7}')
|
||||
[ "$free_mb" -ge 26624 ] ||
|
||||
{ echo "::error::need 26 GiB available, have ${free_mb} MiB"; exit 1; }
|
||||
fstype=$(df --output=fstype "$TMPDIR" | tail -1 | tr -d ' ')
|
||||
[ "$fstype" != tmpfs ] ||
|
||||
{ echo "::error::$TMPDIR is tmpfs — nine node disks would be in RAM"; exit 1; }
|
||||
@@ -1349,11 +1304,6 @@ jobs:
|
||||
# formation port. Both land with mulga-yxtda and the bridged WAN mode;
|
||||
# until then this cell proves the cluster forms and survives, not that a
|
||||
# customer can use it.
|
||||
# 3 x 8 GiB plus headroom for the instances the cluster launches on top,
|
||||
# claimed when the nodes boot rather than at preflight.
|
||||
- name: Wait for memory
|
||||
run: mulga/spinifex/.github/scripts/wait-for-memory.sh 26624
|
||||
|
||||
- name: Install, form and verify the cluster
|
||||
working-directory: mulga/scripts/iso-builder
|
||||
run: |
|
||||
@@ -1386,16 +1336,6 @@ jobs:
|
||||
rm -rf /tmp/cell22-artifacts-$GITHUB_RUN_ID /tmp/cell22-$GITHUB_RUN_ID.log \
|
||||
"$WORK_DIR"
|
||||
|
||||
# The ISO build runs as root inside the workspace, and the next job on
|
||||
# this runner, from any workflow, cannot remove what it leaves there.
|
||||
- name: Release the workspace
|
||||
if: always()
|
||||
run: |
|
||||
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
|
||||
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
|
||||
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
|
||||
|
||||
# Predastore throughput ("cell 23"). Predastore has no runners of its own and
|
||||
# spinifex depends on it, so the benchmark lives here and checks predastore
|
||||
# out as a sibling.
|
||||
@@ -1444,7 +1384,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Predastore
|
||||
@@ -1568,7 +1508,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Predastore
|
||||
|
||||
@@ -12,15 +12,13 @@ on:
|
||||
- go.sum
|
||||
- Makefile
|
||||
- tests/e2e/**
|
||||
- scripts/images/**
|
||||
- scripts/build-system-image.sh
|
||||
- .github/workflows/e2e.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suites:
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks e2e-iam e2e-rds')"
|
||||
required: false
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-iam"
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam"
|
||||
test_run:
|
||||
description: "Optional regex for -test.run (e.g. '^TestMultinodeIPSec$'). Empty = run all tests in suite."
|
||||
required: false
|
||||
@@ -33,75 +31,86 @@ permissions:
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
# A push runs the base set plus the suites its changed paths map to in
|
||||
# suite-sets.sh; a dispatch runs its suites input, or the base set if empty.
|
||||
# Budgets come from the suites' own timeouts there.
|
||||
# The inner timeout must fire before the outer one. `go test` reports where it
|
||||
# stopped and the suite's own failure; a job cancelled on timeout reports only
|
||||
# "The operation was canceled" and takes the artifacts with it.
|
||||
#
|
||||
# A job budget cannot be a literal here because the suite set is a dispatch
|
||||
# input: storagefault asks for 90m and diskperf for 180m, both eligible on one
|
||||
# node, so any fixed number is either wrong for them or wasteful for everyone
|
||||
# else. Resolving it from suite-sets.sh keeps the one source of truth and
|
||||
# leaves the default push set at exactly the 60m it has always had.
|
||||
plan:
|
||||
name: Select Suites
|
||||
name: Resolve Job Budgets
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
single_legs: ${{ steps.select.outputs.single_legs }}
|
||||
multi_suites: ${{ steps.select.outputs.multi_suites }}
|
||||
multi_timeout: ${{ steps.select.outputs.multi_timeout }}
|
||||
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
|
||||
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
|
||||
steps:
|
||||
# Full history without file contents: the diff needs both commits' trees.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Select suites
|
||||
id: select
|
||||
working-directory: spinifex
|
||||
- name: Resolve Budgets
|
||||
id: budgets
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
IN_SUITES: ${{ inputs.suites }}
|
||||
# Provision, install and teardown around the suites, plus the headroom
|
||||
# a multi-suite job needs for whatever ran before the one that hangs.
|
||||
SETUP_ALLOWANCE_MIN: 15
|
||||
# What the job has always had. Kept as a floor so resolving the budget
|
||||
# can only ever raise it, never quietly shorten an existing run.
|
||||
FLOOR_MIN: 60
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SELECT=.github/scripts/e2e-select.sh
|
||||
if [ "${EVENT}" = push ]; then
|
||||
bash "${SELECT}" push "${BEFORE}" "${GITHUB_SHA}" | tee -a "$GITHUB_OUTPUT"
|
||||
else
|
||||
bash "${SELECT}" suites "${IN_SUITES}" | tee -a "$GITHUB_OUTPUT"
|
||||
fi
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
# Only max() is a sound bound: the suites share one job, so a sum would
|
||||
# demand hours for a set that has never taken them. Max is what makes
|
||||
# the hanging suite panic on its own timeout first, which is the point.
|
||||
budget() {
|
||||
local want="${FLOOR_MIN}" s t
|
||||
for s in $(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "$1"); do
|
||||
t="$(e2e_suite_timeout "${s}")"
|
||||
t=$(( ${t%m} + SETUP_ALLOWANCE_MIN ))
|
||||
[ "${t}" -gt "${want}" ] && want="${t}"
|
||||
done
|
||||
echo "${want}"
|
||||
}
|
||||
single=$(budget "${E2E_SUITES_SINGLE_RE}")
|
||||
multi=$(budget "${E2E_SUITES_MULTI_RE}")
|
||||
echo "single_timeout=${single}" >> "$GITHUB_OUTPUT"
|
||||
echo "multi_timeout=${multi}" >> "$GITHUB_OUTPUT"
|
||||
echo "suites='${SUITES}' -> single ${single}m, multi ${multi}m"
|
||||
|
||||
e2e_single:
|
||||
# The base leg keeps the job and artifact names it always had: mulga's
|
||||
# registry intake finds a run's results by them.
|
||||
name: ${{ matrix.leg == 'base' && 'Single-Node E2E' || format('Single-Node E2E ({0})', matrix.leg) }}
|
||||
# `base` runs on ci-single; `service`, on ci-pseudo, carries the matched
|
||||
# single-node suites so a long one stays off the base leg's critical path.
|
||||
if: needs.plan.outputs.single_legs != '[]'
|
||||
name: Single-Node E2E
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists, i.e. E2E_SUITES_SINGLE in .github/scripts/suite-sets.sh —
|
||||
# an `if:` expression cannot source that file, so this one list is
|
||||
# hand-mirrored and must be updated with it. Gating on a suite the job
|
||||
# won't run — lb, which only executes in the multinode job — would
|
||||
# provision a tofu VM and pay the Spinifex install only to run nothing and
|
||||
# tear down. Push triggers keep running via the null-check on
|
||||
# inputs.suites.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock') || contains(github.event.inputs.suites, 'e2e-rds')
|
||||
needs: plan
|
||||
runs-on: [self-hosted, "${{ matrix.runner }}"]
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.plan.outputs.single_legs) }}
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.single_timeout) }}
|
||||
env:
|
||||
SUITES: ${{ matrix.suites }}
|
||||
# Both legs can run on banksia at once, so every host path carries the leg.
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}-${{ matrix.leg }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}-${{ matrix.leg }}
|
||||
# The base leg keeps the cell name it always had, so its history in
|
||||
# Elastic and telemetry continues; any other leg appends its name.
|
||||
E2E_CELL: ${{ matrix.leg == 'base' && 'single' || format('single-{0}', matrix.leg) }}
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
|
||||
# Job-level env wins over the workflow-level default. This job and
|
||||
# e2e_multi share one run id and both name their hosts node1/node2/node3,
|
||||
# so without ci.cell their telemetry collapses into one bucket. This job
|
||||
# is where "Bootstrap via Binary Install" bakes SPINIFEX_OTEL_ATTRS into
|
||||
# the VM's telemetry.env, so the label has to be set here.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.leg == 'base' && 'single' || format('single-{0}', matrix.leg) }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -171,7 +180,7 @@ jobs:
|
||||
- name: Resolve CI job id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: ${{ matrix.leg == 'base' && 'Single-Node E2E' || format('Single-Node E2E ({0})', matrix.leg) }}
|
||||
job-name: Single-Node E2E
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -215,12 +224,6 @@ jobs:
|
||||
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# build-system-image.sh reuses a raw image under ten minutes old from
|
||||
# its build directory, and every runner on banksia shares the default
|
||||
# one in /tmp, so a job could test another commit's guest agent.
|
||||
- name: Use a per-job image build directory
|
||||
run: echo "SYSTEM_IMAGE_BUILD_DIR=${RUNNER_TEMP}/system-image-build" >> "$GITHUB_ENV"
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the
|
||||
# eks-node AMI, so that AMI must exist in the VM's image store before the
|
||||
# suite runs. build-system-image.sh customizes the image with libguestfs
|
||||
@@ -244,7 +247,7 @@ jobs:
|
||||
group_open "make build-eks-node-image"
|
||||
make build-eks-node-image
|
||||
group_close
|
||||
ok "raw: $(du -h "${SYSTEM_IMAGE_BUILD_DIR}/eks-node-image-build/eks-node-alpine.raw" | cut -f1)"
|
||||
ok "raw: $(du -h /tmp/eks-node-image-build/eks-node-alpine.raw | cut -f1)"
|
||||
|
||||
- name: Import eks-node AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-eks')
|
||||
@@ -256,16 +259,13 @@ jobs:
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import eks-node AMI to VM image store"
|
||||
# The VM's /tmp is a tmpfs and scp writes each raw at its full size,
|
||||
# so every import removes its file before the next image arrives.
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/eks-node-image-build/eks-node-alpine.raw" \
|
||||
/tmp/eks-node-image-build/eks-node-alpine.raw \
|
||||
"$VM":/tmp/eks-node-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/eks-node-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}; \
|
||||
rc=\$?; rm -f /tmp/eks-node-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
|
||||
ok "eks-node AMI imported"
|
||||
|
||||
# The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
|
||||
@@ -299,40 +299,6 @@ jobs:
|
||||
EOF
|
||||
ok "kubectl installed on VM"
|
||||
|
||||
# The ecs suite boots its container instance from the ecs-node AMI, found
|
||||
# by its managed-by tag, so the tag matters. The build directory is named
|
||||
# for the manifest's IMAGE_NAME (ecs-agent), not for the make target.
|
||||
- name: Build ecs-node AMI (libguestfs, runner)
|
||||
if: contains(env.SUITES, 'e2e-ecs')
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
|
||||
make build-ecs-node-image
|
||||
|
||||
- name: Import ecs-node AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-ecs')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/ecs-agent/manifest.conf)"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import ecs-node AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/ecs-agent-image-build/ecs-agent-alpine.raw" \
|
||||
"$VM":/tmp/ecs-agent-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/ecs-agent-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}; \
|
||||
rc=\$?; rm -f /tmp/ecs-agent-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
ok "ecs-node AMI imported"
|
||||
|
||||
# CreateDBInstance boots the DB VM from an RDS AMI, so both engines' images
|
||||
# must be in the VM's image store before the suite runs — same split as the
|
||||
# eks-node pair above: build on the runner (needs source + network), import
|
||||
@@ -355,7 +321,7 @@ jobs:
|
||||
group_open "make build-${IMAGE}-image"
|
||||
make "build-${IMAGE}-image"
|
||||
group_close
|
||||
ok "raw: $(du -h "${SYSTEM_IMAGE_BUILD_DIR}/${IMAGE}-image-build/${IMAGE}-alpine.raw" | cut -f1)"
|
||||
ok "raw: $(du -h "/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" | cut -f1)"
|
||||
done
|
||||
|
||||
- name: Import RDS AMIs on VM
|
||||
@@ -377,13 +343,12 @@ jobs:
|
||||
for tag in ${SYSTEM_TAG}; do TAG_ARGS="${TAG_ARGS} --tag ${tag}"; done
|
||||
banner "import ${IMAGE} AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
|
||||
"/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
|
||||
"$VM":"/tmp/${IMAGE}-alpine.raw"
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/${IMAGE}-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}; \
|
||||
rc=\$?; rm -f /tmp/${IMAGE}-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}" >/dev/null 2>&1
|
||||
ok "${IMAGE} AMI imported"
|
||||
done
|
||||
|
||||
@@ -561,7 +526,7 @@ jobs:
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: ${{ env.E2E_CELL }}
|
||||
cell: single
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
@@ -571,17 +536,10 @@ jobs:
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis"
|
||||
|
||||
# The WAN capture is most of the artifact and only matters when a run
|
||||
# failed. Anything short of a clean pass (suite rc, a failed setup or
|
||||
# collect step, a missing rc) keeps it.
|
||||
- name: Drop the packet capture from a passing single-node run
|
||||
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
|
||||
run: rm -f "${ARTIFACT_DIR}"/net-diag/wan.pcap
|
||||
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ matrix.leg == 'base' && format('e2e-artifacts-{0}', github.run_id) || format('e2e-artifacts-{0}-{1}', github.run_id, matrix.leg) }}
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -624,7 +582,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: ${{ matrix.leg == 'base' && format('tofu-log-single-{0}', github.run_id) || format('tofu-log-single-{0}-{1}', github.run_id, matrix.leg) }}
|
||||
name: tofu-log-single-${{ github.run_id }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -640,13 +598,15 @@ jobs:
|
||||
|
||||
e2e_multi:
|
||||
name: Multi-Node E2E
|
||||
# Runs when the plan job selected any multi-node suite, and only then.
|
||||
if: needs.plan.outputs.multi_suites != ''
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists (multinode|lb|cert) so lb — which only runs here — still
|
||||
# provisions the 3-node env when requested on its own, and the env stays
|
||||
# unprovisioned when none of them are asked for.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
|
||||
needs: plan
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.multi_timeout) }}
|
||||
env:
|
||||
SUITES: ${{ needs.plan.outputs.multi_suites }}
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
|
||||
# See the matching note on e2e_single: ci.cell is what separates this
|
||||
@@ -1030,13 +990,6 @@ jobs:
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "Multi-Node E2E failure analysis"
|
||||
|
||||
# The WAN captures are 90% of the artifact and only matter when a run
|
||||
# failed. Anything short of a clean pass (suite rc, a failed setup or
|
||||
# collect step, a missing rc) keeps them.
|
||||
- name: Drop the packet captures from a passing multi-node run
|
||||
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
|
||||
run: rm -f "${ARTIFACT_DIR}"/net-diag-node*/wan.pcap
|
||||
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
@@ -1103,10 +1056,7 @@ jobs:
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always()
|
||||
# plan is a dependency because a failed plan skips both e2e jobs, and a
|
||||
# skipped job counts as passed below: without it a broken selection would
|
||||
# report green having run nothing.
|
||||
needs: [plan, e2e_single, e2e_multi]
|
||||
needs: [e2e_single, e2e_multi]
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -1115,16 +1065,15 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPORT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
plan_ok=true; single_ok=true; multi_ok=true
|
||||
[ "${{ needs.plan.result }}" = success ] || plan_ok=false
|
||||
single_ok=true; multi_ok=true
|
||||
case "${{ needs.e2e_single.result }}" in success|skipped) ;; *) single_ok=false ;; esac
|
||||
case "${{ needs.e2e_multi.result }}" in success|skipped) ;; *) multi_ok=false ;; esac
|
||||
if $plan_ok && $single_ok && $multi_ok; then
|
||||
if $single_ok && $multi_ok; then
|
||||
STATE="success"
|
||||
DESC="E2E suites passed"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="E2E failed: plan=${{ needs.plan.result }} single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
fi
|
||||
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
|
||||
-f state="$STATE" \
|
||||
@@ -1141,7 +1090,7 @@ jobs:
|
||||
# status-report check.
|
||||
e2e_es_summary:
|
||||
name: ES CI Run Summary
|
||||
needs: [plan, e2e_single, e2e_multi]
|
||||
needs: [e2e_single, e2e_multi]
|
||||
if: always()
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 5
|
||||
@@ -1153,11 +1102,6 @@ jobs:
|
||||
# this host, so no credentials are needed for the POST.
|
||||
MULGA_ES_SINK: ${{ vars.MULGA_ES_SINK || '192.168.1.13:9200' }}
|
||||
steps:
|
||||
# The workspace outlives the run on a self-hosted runner, and the status
|
||||
# files are collected recursively, so an earlier run's would count here.
|
||||
- name: Clear previous summary downloads
|
||||
run: rm -rf "${GITHUB_WORKSPACE}/es-summary-single" "${GITHUB_WORKSPACE}/es-summary-multi"
|
||||
|
||||
# Either leg may not have run (e.g. a workflow_dispatch that only
|
||||
# requested single-node suites), so a missing artifact is tolerated —
|
||||
# the aggregation script below treats an absent suite-status.txt as
|
||||
@@ -1166,7 +1110,7 @@ jobs:
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: e2e-artifacts-${{ github.run_id }}*
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
path: ${{ github.workspace }}/es-summary-single
|
||||
|
||||
- name: Download multi-node artifacts
|
||||
@@ -1186,7 +1130,6 @@ jobs:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
PLAN_RESULT: ${{ needs.plan.result }}
|
||||
SINGLE_RESULT: ${{ needs.e2e_single.result }}
|
||||
MULTI_RESULT: ${{ needs.e2e_multi.result }}
|
||||
SINGLE_DIR: ${{ github.workspace }}/es-summary-single
|
||||
@@ -1194,8 +1137,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# One subdirectory per single-node leg's artifact.
|
||||
SINGLE_STATUS=$(find "${SINGLE_DIR}" -name suite-status.txt 2>/dev/null | sort || true)
|
||||
SINGLE_STATUS="${SINGLE_DIR}/suite-status.txt"
|
||||
MULTI_STATUS="${MULTI_DIR}/suite-status.txt"
|
||||
|
||||
# Collect "<suite>: FAILED" lines from both legs' suite-status.txt
|
||||
@@ -1204,9 +1146,7 @@ jobs:
|
||||
# rather than a bash array so this stays correct under `set -u`
|
||||
# even when nothing failed.
|
||||
FAILED_SUITES=""
|
||||
# SINGLE_STATUS is a newline-separated list, so it is split here.
|
||||
# shellcheck disable=SC2086
|
||||
for f in ${SINGLE_STATUS} "$MULTI_STATUS"; do
|
||||
for f in "$SINGLE_STATUS" "$MULTI_STATUS"; do
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
@@ -1226,7 +1166,7 @@ jobs:
|
||||
case "$MULTI_RESULT" in success | skipped) ;; *) multi_ok=false ;; esac
|
||||
|
||||
STATE=success
|
||||
if [ "$PLAN_RESULT" != success ] || [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
if [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
STATE=failure
|
||||
fi
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ env.GIT_BRANCH }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7.0.1
|
||||
@@ -57,8 +57,8 @@ jobs:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
|
||||
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
env:
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7.0.1
|
||||
@@ -335,7 +335,7 @@ jobs:
|
||||
|
||||
- name: Resolve sibling release tags from go.mod
|
||||
id: siblings
|
||||
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
|
||||
@@ -20,12 +20,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same pull request supersedes its run. Pushes and dispatches
|
||||
# get a group of their own, so every commit on dev and main keeps its result.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Build and Test
|
||||
@@ -42,7 +36,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
@@ -105,13 +99,6 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
@@ -132,6 +119,9 @@ jobs:
|
||||
- name: Build Spinifex
|
||||
run: make -C spinifex build
|
||||
|
||||
- name: Manifest check + drift lint
|
||||
run: make -C spinifex manifest-check manifest-lint
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
env:
|
||||
SPINIFEX_REQUIRE_CONFORMANCE_TOOLS: "1"
|
||||
@@ -144,12 +134,19 @@ jobs:
|
||||
- name: Check Diff Coverage
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
# Compiled output stays valid whatever the tests said, and skipping the
|
||||
# save on a red run would leave the next one rebuilding it, so this is
|
||||
# gated on the branch rather than on success.
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
@@ -160,7 +157,6 @@ jobs:
|
||||
|
||||
race_detection:
|
||||
name: Race Detection
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
@@ -173,7 +169,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
@@ -236,25 +232,22 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
- name: Run Tests with Race Detector
|
||||
run: make -C spinifex test-race GOTESTFLAGS=-count=1
|
||||
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
@@ -278,7 +271,7 @@ jobs:
|
||||
id: siblings
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: bash .github/scripts/resolve-sibling-refs.sh
|
||||
run: bash .github/resolve-sibling-refs.sh
|
||||
working-directory: spinifex
|
||||
|
||||
- name: Checkout Viperblock
|
||||
@@ -341,13 +334,6 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
@@ -364,16 +350,16 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
- name: Run Gated Suites
|
||||
if: ${{ !cancelled() }}
|
||||
run: make -C spinifex test-gated GOTESTFLAGS=-count=1
|
||||
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -7,11 +7,6 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/runtime/roles/spinifexui/frontend/**
|
||||
- spinifex/runtime/roles/spinifexui/*.go
|
||||
- spinifex/operator/admin/instance_metrics*.go
|
||||
- spinifex/operator/admin/live_streams*.go
|
||||
- spinifex/runtime/roles/qmpcollector/**
|
||||
- contracts/telemetry/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -19,23 +14,12 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/runtime/roles/spinifexui/frontend/**
|
||||
- spinifex/runtime/roles/spinifexui/*.go
|
||||
- spinifex/operator/admin/instance_metrics*.go
|
||||
- spinifex/operator/admin/live_streams*.go
|
||||
- spinifex/runtime/roles/qmpcollector/**
|
||||
- contracts/telemetry/**
|
||||
- .github/workflows/ui.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same pull request supersedes its run. Pushes and dispatches
|
||||
# get a group of their own, so every commit on dev and main keeps its result.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui_build_and_test:
|
||||
name: UI Build and Test
|
||||
@@ -85,85 +69,3 @@ jobs:
|
||||
echo "$drift"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The pinned image lives in one file that scripts/ui-playwright.sh also reads,
|
||||
# so make ui-baselines and the job below draw screenshots in the same image.
|
||||
ui_e2e_image:
|
||||
name: UI End-to-End Image
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
image: ${{ steps.read.outputs.image }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend/playwright.image
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Read Playwright Image
|
||||
id: read
|
||||
run: echo "image=$(tr -d '[:space:]' < spinifex/runtime/roles/spinifexui/frontend/playwright.image)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The browser suite: the console against a faked API on Vite dev, plus the
|
||||
# built dist under the production CSP. It runs in Playwright's own image, which
|
||||
# carries the browsers, so screenshots rasterise as they did for the baselines.
|
||||
ui_e2e:
|
||||
name: UI End-to-End
|
||||
needs: ui_e2e_image
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
container:
|
||||
image: ${{ needs.ui_e2e_image.outputs.image }}
|
||||
options: --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
working-directory: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
with:
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: spinifex/runtime/roles/spinifexui/frontend/pnpm-lock.yaml
|
||||
node-version-file: spinifex/runtime/roles/spinifexui/frontend/.nvmrc
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The image's browsers belong to one Playwright release, so a lockfile
|
||||
# bump without an image bump would otherwise fail on a missing executable.
|
||||
- name: Check Playwright Matches the Image
|
||||
env:
|
||||
IMAGE: ${{ needs.ui_e2e_image.outputs.image }}
|
||||
run: |
|
||||
version=$(pnpm exec playwright --version | awk '{print $2}')
|
||||
case "$IMAGE" in
|
||||
*":v$version-"*) ;;
|
||||
*) echo "::error::Lockfile has Playwright $version but the image is $IMAGE. Update playwright.image."; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Build Frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Run End-to-End Suite
|
||||
run: pnpm e2e
|
||||
|
||||
- name: Upload Screenshot Diffs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: ui-e2e-test-results
|
||||
path: spinifex/runtime/roles/spinifexui/frontend/test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -21,13 +21,6 @@ build/microvm/vmlinuz
|
||||
build/microvm/initramfs.cpio.gz
|
||||
build/microvm/qboot.rom
|
||||
build/microvm/qboot.LICENSE
|
||||
build/microvm/sqs-worker/vmlinuz
|
||||
build/microvm/sqs-worker/initramfs.cpio.gz
|
||||
build/microvm/sqs-worker/.stage.*/
|
||||
build/microvm/lambda-runtime/vmlinuz
|
||||
build/microvm/lambda-runtime/initramfs.cpio.gz
|
||||
build/microvm/lambda-runtime/.stage.*/
|
||||
build/microvm/lambda-testlink-runtime/
|
||||
build/qboot/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
|
||||
@@ -42,15 +42,11 @@ ifdef QUIET
|
||||
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
|
||||
_RACEQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' || true; }; exit $${PIPESTATUS[0]}
|
||||
_SECQ = >
|
||||
_HDR = :
|
||||
_RUNSH = sh "$$t" >"$$log" 2>&1 || { echo "-- $$t"; cat "$$log"; exit 1; }
|
||||
else
|
||||
_Q =
|
||||
_COVQ =
|
||||
_RACEQ =
|
||||
_SECQ = 2>&1 | tee
|
||||
_HDR = echo -e
|
||||
_RUNSH = echo "-- $$t"; sh "$$t" || exit 1
|
||||
endif
|
||||
|
||||
build: go_build build-installer build-lb-agent generate-aws-model-coverage
|
||||
@@ -60,19 +56,6 @@ build-ui:
|
||||
@echo -e "\n....Building spinifex-ui frontend...."
|
||||
cd spinifex/runtime/roles/spinifexui/frontend && pnpm build
|
||||
|
||||
# Playwright suite for the console, against Vite dev and the built dist under
|
||||
# the console's CSP. Not part of preflight; needs `pnpm exec playwright install chromium`.
|
||||
test-ui-e2e: build-ui
|
||||
cd spinifex/runtime/roles/spinifexui/frontend && pnpm e2e
|
||||
|
||||
# The same suite inside the pinned Playwright image CI uses (podman or docker),
|
||||
# so screenshots compare as they will on the runner. ui-baselines redraws them.
|
||||
ui-e2e-container:
|
||||
scripts/ui-playwright.sh compare
|
||||
|
||||
ui-baselines:
|
||||
scripts/ui-playwright.sh baselines
|
||||
|
||||
# GO commands
|
||||
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||
@@ -82,36 +65,6 @@ 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
|
||||
|
||||
# Controlled-test only: builds the tagged daemon and its hard-kill initializer
|
||||
# together, pinning the initializer's digest into that daemon build. The daemon
|
||||
# keeps its symbols so the pin can be read back from the binary.
|
||||
# LAMBDA_IMAGE_DIGEST is the test-link runtime image digest that
|
||||
# build-lambda-testlink-image prints; it is read raw and validated before use.
|
||||
LAMBDA_TESTLINK_DIR := ./bin/lambda-testlink
|
||||
LAMBDA_TESTLINK_PKG := github.com/mulgadc/spinifex/spinifex/runtime/daemon
|
||||
LAMBDA_TESTLINK_PIN := $(LAMBDA_TESTLINK_PKG).lambdaHardKillSHA256
|
||||
LAMBDA_TESTLINK_GW := github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw
|
||||
LAMBDA_DIGEST_RE := ^[0-9a-f]{64}$$
|
||||
build-lambda-testlink: override export LAMBDA_IMAGE_DIGEST := $(value LAMBDA_IMAGE_DIGEST)
|
||||
build-lambda-testlink:
|
||||
@[[ "$$LAMBDA_IMAGE_DIGEST" =~ $(LAMBDA_DIGEST_RE) ]] || { echo "LAMBDA_IMAGE_DIGEST must be 64 lowercase hex digits"; exit 1; }
|
||||
@host=$$(uname -m); goos=$$(go env GOOS); goarch=$$(go env GOARCH); \
|
||||
test "$$host" = x86_64 && test "$$goos/$$goarch" = linux/amd64 || { echo "the test link supports only an x86_64 host building linux/amd64, not $$host building $$goos/$$goarch"; exit 1; }
|
||||
@rm -f $(LAMBDA_TESTLINK_DIR)/hardkill.so $(LAMBDA_TESTLINK_DIR)/$(GO_PROJECT_NAME)
|
||||
@mkdir -p $(LAMBDA_TESTLINK_DIR).tmp
|
||||
@rm -f $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME)
|
||||
cc -std=c17 -O2 -Wall -Wextra -Werror -shared -fPIC -o $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so spinifex/runtime/compute/lambda/testlink/hardkill/hardkill.c -lrt
|
||||
scripts/check-glibc-ceiling.sh 2.34 $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so
|
||||
@digest=$$(sha256sum $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so | cut -d' ' -f1); \
|
||||
test $${#digest} -eq 64 || { echo "initializer digest missing"; exit 1; }; \
|
||||
echo "initializer sha256 $$digest"; \
|
||||
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST -X $(LAMBDA_TESTLINK_GW).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
|
||||
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ ./spinifex/runtime/roles/awsgw/ && \
|
||||
go build $(GO_BUILD_MOD) -tags lambdatestlink -ldflags "$(subst -s -w ,,$(LDFLAGS)) $$pin" -o $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) cmd/spinifex/main.go
|
||||
@mkdir -p $(LAMBDA_TESTLINK_DIR)
|
||||
mv $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) $(LAMBDA_TESTLINK_DIR)/
|
||||
@rmdir $(LAMBDA_TESTLINK_DIR).tmp
|
||||
|
||||
build-installer:
|
||||
@echo -e "\n....Building spinifex-installer"
|
||||
go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
|
||||
@@ -187,65 +140,6 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
|
||||
bin/lb-agent:
|
||||
$(MAKE) build-lb-agent
|
||||
|
||||
# The dedicated SQS server, built from the go.mod-pinned module so the image
|
||||
# ships exactly the reviewed version. Static for the musl guest.
|
||||
bin/nats-server: go.mod go.sum
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ github.com/nats-io/nats-server/v2
|
||||
|
||||
# go_local_srcs lists the main module package dirs and files guest package $(1)
|
||||
# links, scanned with the guest build settings; a dir's mtime catches a deleted
|
||||
# or renamed file. A failed scan names a missing prerequisite, so make stops.
|
||||
go_local_srcs = $(shell GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go list -deps -f '{{if and .Module .Module.Main}}{{.Dir}} {{range .GoFiles}}{{$$.Dir}}/{{.}} {{end}}{{range .EmbedFiles}}{{$$.Dir}}/{{.}} {{end}}{{end}}' $(1) || echo go-list-failed-for-$(1))
|
||||
.SECONDEXPANSION:
|
||||
|
||||
# The SQS microVM image: one guest running its own SQS nats-server and the SQS
|
||||
# worker as separate processes. A separate artifact from the LB image above.
|
||||
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
|
||||
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
|
||||
|
||||
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
|
||||
./scripts/build-sqs-store-microvm-image.sh
|
||||
|
||||
bin/sqs-worker: go.mod go.sum $$(call go_local_srcs,./cmd/sqs-worker)
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/sqs-worker
|
||||
|
||||
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
|
||||
|
||||
# The Lambda runtime microVM image. Built on demand; not shipped or installed.
|
||||
LAMBDA_RUNTIME_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/lambda-runtime
|
||||
LAMBDA_RUNTIME_MICROVM_ARTIFACTS := $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/vmlinuz $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
LAMBDA_RUNTIME_MICROVM_INPUTS := scripts/build-lambda-runtime-microvm-image.sh scripts/lib/microvm-image.sh $(addprefix $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/,init.sh inittab run-agent) bin/lambda-agent
|
||||
|
||||
$(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) &: $(LAMBDA_RUNTIME_MICROVM_INPUTS)
|
||||
./scripts/build-lambda-runtime-microvm-image.sh
|
||||
|
||||
bin/lambda-agent: go.mod go.sum $$(call go_local_srcs,./cmd/lambda-agent)
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/lambda-agent
|
||||
|
||||
build-lambda-runtime-microvm-image: $(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) ## Build the Lambda runtime microVM kernel and initramfs (not shipped)
|
||||
GOWORK=off go run ./cmd/lambda-image-agent-tag $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz absent
|
||||
|
||||
# Controlled-test only: a separately built guest whose agent has the
|
||||
# lambdatestlink tag, in its own outputs, never touching bin/lambda-agent or the
|
||||
# default image. It prints the digest to pass as LAMBDA_IMAGE_DIGEST.
|
||||
LAMBDA_TESTLINK_IMAGE_DIR := $(MICROVM_OUT_DIR)/lambda-testlink-runtime
|
||||
build-lambda-testlink-image: ## Build the test-link Lambda guest image and print its digest (not shipped)
|
||||
@host=$$(uname -m); goos=$$(go env GOOS); goarch=$$(go env GOARCH); \
|
||||
test "$$host" = x86_64 && test "$$goos/$$goarch" = linux/amd64 || { echo "the test link supports only an x86_64 host building linux/amd64, not $$host building $$goos/$$goarch"; exit 1; }
|
||||
$(MAKE) --no-print-directory $(MICROVM_OUT_DIR)/qboot.rom
|
||||
@mkdir -p bin $(LAMBDA_TESTLINK_IMAGE_DIR)
|
||||
GOWORK=off GOFLAGS= CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -tags lambdatestlink -ldflags '-s -w' -o bin/lambda-agent-testlink ./cmd/lambda-agent
|
||||
./scripts/build-lambda-runtime-microvm-image.sh bin/lambda-agent-testlink $(LAMBDA_TESTLINK_IMAGE_DIR)
|
||||
GOWORK=off go run ./cmd/lambda-image-agent-tag $(LAMBDA_TESTLINK_IMAGE_DIR)/initramfs.cpio.gz present
|
||||
install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom $(LAMBDA_TESTLINK_IMAGE_DIR)/qboot.rom
|
||||
@digest=$$(GOWORK=off go run -tags lambdatestlink ./cmd/lambda-image-digest $(LAMBDA_TESTLINK_IMAGE_DIR)) && \
|
||||
[[ "$$digest" =~ $(LAMBDA_DIGEST_RE) ]] || { echo "image digest not computed"; exit 1; }; \
|
||||
echo "LAMBDA_IMAGE_DIGEST=$$digest"
|
||||
|
||||
# qboot is the microvm firmware. The source tarball ships with each release
|
||||
# because qboot is GPL-2.0.
|
||||
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
|
||||
@@ -257,12 +151,6 @@ $(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
|
||||
|
||||
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
|
||||
|
||||
install-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS microVM image to /usr/share/spinifex/microvm/sqs-worker/
|
||||
sudo install -d /usr/share/spinifex/microvm/sqs-worker
|
||||
sudo rm -f /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
|
||||
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-worker/vmlinuz
|
||||
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
|
||||
|
||||
install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
|
||||
sudo install -d /usr/share/spinifex/microvm
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
|
||||
@@ -270,14 +158,15 @@ install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM arti
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom /usr/share/spinifex/microvm/qboot.rom
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.LICENSE /usr/share/spinifex/microvm/qboot.LICENSE
|
||||
|
||||
# the pre-commit gate: lint, vuln, and the unit and e2e-harness tiers.
|
||||
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
|
||||
# integration and race tests skipped to keep quick, they run in CI.
|
||||
#
|
||||
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
|
||||
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
|
||||
# -Otarget keeps each gate's output in one block instead of interleaving them.
|
||||
# manifest-check manifest-lint test-package-check removed, can add back later
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
|
||||
@@ -289,33 +178,22 @@ preflight:
|
||||
# reason: between them they install the software, wire OVN, erase the state and
|
||||
# remove the install, so a shellcheck warning in one is an outage or a wrong
|
||||
# deletion, not a style note.
|
||||
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.sh
|
||||
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh
|
||||
|
||||
test-build-scripts:
|
||||
@$(_HDR) "\n....Running build/scripts/**/*_test.sh...."
|
||||
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find build/scripts -name '*_test.sh' | sort); do \
|
||||
$(_RUNSH); \
|
||||
@echo -e "\n....Running build/scripts/**/*_test.sh...."
|
||||
@for t in $$(find build/scripts -name '*_test.sh' | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@$(_HDR) "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
|
||||
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
|
||||
$(_RUNSH); \
|
||||
@echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...."
|
||||
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@$(_HDR) "\n....Running scripts/terraform/latitude-spx/*_test.sh...."
|
||||
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find scripts/terraform/latitude-spx -maxdepth 1 -name '*_test.sh' | sort); do \
|
||||
$(_RUNSH); \
|
||||
done
|
||||
@$(_HDR) "\n....Running .github/scripts/*_test.sh...."
|
||||
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find .github/scripts -maxdepth 1 -name '*_test.sh' | sort); do \
|
||||
$(_RUNSH); \
|
||||
done
|
||||
$(_Q)shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh \
|
||||
.github/scripts/wait-for-memory.sh \
|
||||
.github/scripts/go-build-cache.sh \
|
||||
.github/scripts/e2e-select.sh
|
||||
@$(_HDR) "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
|
||||
$(_Q)shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
|
||||
$(_Q)shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
|
||||
@$(_HDR) " test-build-scripts ok"
|
||||
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
|
||||
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
|
||||
@echo " test-build-scripts ok"
|
||||
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
# default `go test ./spinifex/...`. Runs with mocked AWS clients — no
|
||||
@@ -346,16 +224,6 @@ test-integration:
|
||||
@$(MAKE) --no-print-directory generate-aws-model-coverage
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
|
||||
|
||||
# Suites the unit tiers skip: the three-node SQS R3 scenarios behind
|
||||
# SPINIFEX_SQS_R3 and the lambdatestlink-tagged tests. CI runs these on every PR.
|
||||
test-gated:
|
||||
@echo -e "\n....Running env- and tag-gated suites...."
|
||||
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -timeout 180s -run '^TestR3$$' ./spinifex/domains/sqs/message/jetstream/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ ./spinifex/runtime/compute/lambda/guestconn/ ./spinifex/runtime/compute/lambda/testlink/ ./spinifex/domains/network/host/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink(Executor|Reply)_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink,lambdatestlinkseam -timeout 120s -run '^TestLambdaTestLinkSeam_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink' ./spinifex/runtime/daemon/ $(_RACEQ)
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR) -json $(AWS_MODEL_OPERATION_COVERAGE_DIR)/coverage.json
|
||||
|
||||
@@ -371,6 +239,23 @@ test-segscan-oracle:
|
||||
@echo -e "\n....Running segscan storage oracle test...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=integration,segscanoracle -timeout 120s ./tests/integration/... -run TestSegscanOracle $(_RACEQ)
|
||||
|
||||
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
|
||||
# of services/suites/fixtures + on-disk path existence.
|
||||
manifest-check:
|
||||
@echo -e "\n....Checking service-interfaces.yaml...."
|
||||
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
|
||||
|
||||
# Drift guards: direct-create fixture lint + NATS subject lint,
|
||||
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
|
||||
# drift beyond the baseline.
|
||||
manifest-lint:
|
||||
@echo -e "\n....Linting manifest drift (fixtures + subjects)...."
|
||||
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root .
|
||||
|
||||
# Accept current drift into the baseline. Run after an intentional change.
|
||||
manifest-lint-update:
|
||||
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root . -update
|
||||
|
||||
# Run unit tests
|
||||
test:
|
||||
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
|
||||
@@ -583,7 +468,6 @@ distro-amd64:
|
||||
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
|
||||
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
|
||||
fi
|
||||
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_WORKER_MICROVM_OUT_DIR) dist/amd64 sqs-worker
|
||||
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
|
||||
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
|
||||
|
||||
@@ -602,7 +486,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-lambda-testlink build-lambda-testlink-image build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration test-gated generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -54,7 +54,7 @@ For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/
|
||||
|
||||
## Services
|
||||
|
||||
The platform serves **405 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
|
||||
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
|
||||
|
||||
| Service | What it is | Built on | Status |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -68,10 +68,8 @@ The platform serves **405 AWS API operations** across the services below. See ex
|
||||
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
|
||||
| **ECS** | Container service | Spinifex instances | Available |
|
||||
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
|
||||
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
|
||||
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
|
||||
| **Lambda** | Serverless functions | QEMU `microvm` execution environments | Q1 2027 |
|
||||
| **SQS** | Message queues | A dedicated NATS JetStream store in a per-Region QEMU `microvm` guest | Q1 2027 |
|
||||
| **Lambda** | Serverless functions | | Q1 2027 |
|
||||
|
||||
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
|
||||
|
||||
@@ -113,7 +111,7 @@ aws ec2 describe-instance-types
|
||||
## Web Console
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.webp" alt="Spinifex web console dashboard" width="900">
|
||||
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console dashboard" width="900">
|
||||
</p>
|
||||
|
||||
Spinifex ships with a built-in web console for your instances, volumes, buckets, networks, and IAM. See [Launching the Web UI](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui).
|
||||
|
||||
@@ -15,6 +15,9 @@ var allowlist = []allowance{
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/igw", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/image", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/key", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/launchtemplate", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/placementgroup", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
|
||||
@@ -26,17 +29,8 @@ var allowlist = []allowance{
|
||||
|
||||
{From: "agents/rds/agent", To: "domains/rds/instance", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
|
||||
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/elbv2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/rds/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ec2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/eks/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/iam/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/sqs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/sts/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
|
||||
|
||||
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
|
||||
|
||||
@@ -58,6 +52,9 @@ var allowlist = []allowance{
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
|
||||
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
@@ -94,14 +91,42 @@ var allowlist = []allowance{
|
||||
{From: "domains/ochre", To: "domains/ec2/systeminstance", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
|
||||
{From: "domains/ochre", To: "domains/network/systemvpc", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "ingress/aws/bodyscope", Debt: "domains/ochre (action adapters)", Reason: "Bedrock action adapters still live in the Ochre package; they move to domains/ochre/awsapi"},
|
||||
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
{From: "domains/ochre/vector", To: "domains/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
|
||||
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
|
||||
{From: "domains/rds/awsapi", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "domains/sts/awsapi", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
|
||||
|
||||
{From: "gateway", To: "domains/eks/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/iam/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/rds/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/sts/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ecs/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/elbv2/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "domains/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "domains/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "domains/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
@@ -134,4 +159,6 @@ var allowlist = []allowance{
|
||||
{From: "domains/rds/instance", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "domains/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
|
||||
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
}
|
||||
|
||||
@@ -192,47 +192,24 @@ func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains: the request pipeline
|
||||
// lives under ingress/aws, so it must not import the daemon, any runtime or
|
||||
// operator package, or a domain implementation.
|
||||
func TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains(t *testing.T) {
|
||||
r := ruleByID("R2_IngressImportsNoImplementation")
|
||||
from := "spinifex/ingress/aws/pipeline"
|
||||
f, ok := classify(from)
|
||||
if !ok || !r.subject(f, from) {
|
||||
t.Fatalf("%s is not governed by R2", from)
|
||||
// TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon: the daemon left its
|
||||
// legacy root, so gateway importing runtime/daemon must still be rejected, and
|
||||
// the former handlers packages are held to it by R5 at their domain homes.
|
||||
func TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon(t *testing.T) {
|
||||
daemon, _ := classify("spinifex/runtime/daemon")
|
||||
checks := []struct{ rule, from string }{
|
||||
{"Interim_DaemonGatewayHandlersDirection", "spinifex/gateway"},
|
||||
{"R5_DomainImportsNoRuntimeOrOperator", "spinifex/domains/ecs"},
|
||||
}
|
||||
for _, to := range []string{
|
||||
"spinifex/runtime/daemon", "spinifex/runtime/host/process", "spinifex/runtime/roles/awsgw",
|
||||
"spinifex/operator/admin", "spinifex/domains/ecs", "spinifex/domains/iam/awsapi",
|
||||
} {
|
||||
target, _ := classify(to)
|
||||
if !r.forbids(f, target, from) {
|
||||
t.Errorf("%s -> %s is not rejected by R2", from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestADR0002_S7_RequestRecordRejectsOutsiders: the request record is readable
|
||||
// by packages under ingress/aws only.
|
||||
func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
|
||||
record, ok := classify("spinifex/ingress/aws/reqctx")
|
||||
if !ok || !record.requestRecord {
|
||||
t.Fatalf("ingress/aws/reqctx is not classified as the request record: %+v", record)
|
||||
}
|
||||
r := ruleByID("S7_RequestRecordPrivateToIngress")
|
||||
for name, wantRejected := range map[string]bool{
|
||||
"domains/ecs/awsapi": true,
|
||||
"runtime/roles/awsgw": true,
|
||||
"operator/admin": true,
|
||||
"ingress/aws/dispatch": false,
|
||||
} {
|
||||
from, ok := classify(fullPath(name))
|
||||
if !ok {
|
||||
t.Fatalf("%s is unclassified", name)
|
||||
}
|
||||
if got := r.forbids(from, record, name); got != wantRejected {
|
||||
t.Errorf("%s -> ingress/aws/reqctx rejected=%v, want %v", name, got, wantRejected)
|
||||
for _, c := range checks {
|
||||
r := ruleByID(c.rule)
|
||||
f, _ := classify(c.from)
|
||||
if !r.subject(f, c.from) || !r.forbids(f, daemon, c.from) {
|
||||
t.Errorf("%s -> runtime/daemon is not rejected by %s", c.from, c.rule)
|
||||
}
|
||||
}
|
||||
other, _ := classify("spinifex/runtime/host/process")
|
||||
if gw, _ := classify("spinifex/gateway"); ruleByID("Interim_DaemonGatewayHandlersDirection").forbids(gw, other, "spinifex/gateway") {
|
||||
t.Error("gateway -> runtime/host/process is rejected, but it is not the daemon")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ const (
|
||||
type domainKind int
|
||||
|
||||
const (
|
||||
notDomain domainKind = iota
|
||||
domainImpl // domains/<d>/... resource implementation
|
||||
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
|
||||
domainProj // domains/<d>/projection/... authorised view
|
||||
legacyImpl // handlers/<d>/... legacy domain implementation
|
||||
notDomain domainKind = iota
|
||||
domainImpl // domains/<d>/... resource implementation
|
||||
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
|
||||
domainProj // domains/<d>/projection/... authorised view
|
||||
legacyImpl // handlers/<d>/... legacy domain implementation
|
||||
legacyAdapter // gateway/<d>/... legacy AWS action adapters
|
||||
)
|
||||
|
||||
type class struct {
|
||||
@@ -42,8 +43,6 @@ type class struct {
|
||||
domain string // owning domain for domain-shaped and agent packages
|
||||
kind domainKind
|
||||
daemon bool // runtime/daemon, the interim daemon -> gateway -> handlers root
|
||||
// requestRecord marks ingress/aws/reqctx, the request record ADR-0002:S7 keeps private to ingress.
|
||||
requestRecord bool
|
||||
}
|
||||
|
||||
func (c class) domainShaped() bool { return c.kind != notDomain }
|
||||
@@ -59,8 +58,17 @@ func (c class) isLegacy(roots ...string) bool {
|
||||
return slices.Contains(roots, c.legacy)
|
||||
}
|
||||
|
||||
// legacyRoots are pre-ADR-0001 application roots still standing. None remain.
|
||||
var legacyRoots = map[string]bool{}
|
||||
// legacyRoots are pre-ADR-0001 application roots still standing.
|
||||
var legacyRoots = map[string]bool{
|
||||
"gateway": true,
|
||||
}
|
||||
|
||||
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
|
||||
// Bedrock is the AWS surface of the Ochre domain.
|
||||
var gatewayDomains = map[string]string{
|
||||
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
|
||||
"rds": "rds", "sts": "sts",
|
||||
}
|
||||
|
||||
// classify maps a module-relative package directory to its layer. ok is false
|
||||
// for a package that has no deliberate classification.
|
||||
@@ -95,7 +103,7 @@ func classifyApp(app []string) (class, bool) {
|
||||
case "foundation":
|
||||
return class{layer: layerFoundation}, true
|
||||
case "ingress":
|
||||
return class{layer: layerIngress, requestRecord: len(app) >= 3 && app[1] == "aws" && app[2] == "reqctx"}, true
|
||||
return class{layer: layerIngress}, true
|
||||
case "runtime":
|
||||
return class{layer: layerRuntime, daemon: len(app) >= 2 && app[1] == "daemon"}, true
|
||||
case "operator":
|
||||
@@ -132,6 +140,8 @@ func classifyApp(app []string) (class, bool) {
|
||||
switch {
|
||||
case app[0] == "handlers" && len(app) >= 2:
|
||||
c.domain, c.kind = app[1], legacyImpl
|
||||
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
|
||||
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
// frozenLegacyPackages is every production package still under a legacy root.
|
||||
// It only shrinks: a new entry is a new legacy package, a missing one is a
|
||||
// completed move whose line must be deleted.
|
||||
var frozenLegacyPackages = []string{}
|
||||
var frozenLegacyPackages = []string{
|
||||
"gateway",
|
||||
"gateway/spx",
|
||||
}
|
||||
|
||||
// retiredLegacyRoots were legacy roots whose every package has moved to its
|
||||
// ADR-0001 target; recreating one would reopen a closed migration row.
|
||||
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "gateway", "handlers", "lbagent", "services", "utils", "vpcd"}
|
||||
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "handlers", "lbagent", "services", "utils", "vpcd"}
|
||||
|
||||
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
|
||||
// root a destination, so none may gain a package and no retired root may return.
|
||||
@@ -33,6 +36,9 @@ func TestADR0001_LegacyRootsFrozen(t *testing.T) {
|
||||
present[name] = true
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
t.Fatal("found no legacy packages; the freeze would pass vacuously")
|
||||
}
|
||||
frozen := map[string]bool{}
|
||||
for _, p := range frozenLegacyPackages {
|
||||
frozen[p] = true
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package layering
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
import "slices"
|
||||
|
||||
// rule is one ADR-0001 dependency rule over a classified import edge.
|
||||
type rule struct {
|
||||
@@ -126,8 +123,10 @@ var rules = []rule{
|
||||
case t.layer == layerAgents:
|
||||
return true
|
||||
case t.layer == layerIngress:
|
||||
return f.kind != domainAWSAPI
|
||||
case t.isLegacy("handlers"):
|
||||
return f.kind != domainAWSAPI && f.kind != legacyAdapter
|
||||
case t.isLegacy("gateway") && !t.domainShaped():
|
||||
return true
|
||||
case t.isLegacy("handlers", "gateway"):
|
||||
return f.layer == layerDomain
|
||||
}
|
||||
return false
|
||||
@@ -153,15 +152,18 @@ var rules = []rule{
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "S7_RequestRecordPrivateToIngress",
|
||||
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only packages under ingress/aws may import ingress/aws/reqctx",
|
||||
hint: "receive what you need through dispatch.Invocation instead of the request record",
|
||||
subject: func(_ class, _ string) bool { return true },
|
||||
forbids: func(_, t class, fromName string) bool {
|
||||
if !t.requestRecord {
|
||||
return false
|
||||
id: "Interim_DaemonGatewayHandlersDirection",
|
||||
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
|
||||
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
|
||||
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
switch {
|
||||
case f.isLegacy("handlers"):
|
||||
return t.isLegacy("gateway") || t.daemon
|
||||
case f.isLegacy("gateway"):
|
||||
return t.daemon
|
||||
}
|
||||
return !strings.HasPrefix(fromName, "ingress/aws/")
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
|
||||
FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/bin/sh
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sys /sys
|
||||
mount -t devtmpfs dev /dev
|
||||
|
||||
# modprobe needs a usable modules.dep; insmod by explicit path does not, so
|
||||
# fall back to it. Handles .ko and .ko.gz.
|
||||
load_mod() {
|
||||
modprobe "$1" 2>/dev/null && return 0
|
||||
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
|
||||
insmod "$ko" 2>/dev/null && return 0
|
||||
case "$ko" in
|
||||
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
|
||||
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
|
||||
# anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# insmod fallback resolves no dependencies, so load the chain bottom-up.
|
||||
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# The guest's addresses come from the same netcfg entry as other system VMs.
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -f "$NETCFG" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$NETCFG"
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
[ -z "$mac" ] && continue
|
||||
iface=""
|
||||
for _ in 1 2 3 4 5; do
|
||||
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
|
||||
[ -n "$iface" ] && break
|
||||
sleep 0.02
|
||||
done
|
||||
if [ -z "$iface" ]; then
|
||||
echo "[init] no interface for MAC $mac (NIC$n)" >/dev/console
|
||||
continue
|
||||
fi
|
||||
cidr="" gw="" default="" rdst="" rvia=""
|
||||
eval cidr=\$NIC${n}_CIDR
|
||||
eval gw=\$NIC${n}_GW
|
||||
eval default=\$NIC${n}_DEFAULT
|
||||
eval rdst=\$NIC${n}_ROUTE_DST
|
||||
eval rvia=\$NIC${n}_ROUTE_VIA
|
||||
ip link set "$iface" up
|
||||
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
|
||||
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
|
||||
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
|
||||
done
|
||||
else
|
||||
# lambda-agent then fails to reach the host and the guest powers off.
|
||||
echo "[init] fw_cfg netcfg not available" >/dev/console
|
||||
fi
|
||||
|
||||
exec /sbin/init
|
||||
@@ -1,6 +0,0 @@
|
||||
# The power button loads here, not in /init: busybox init has its SIGUSR2
|
||||
# (poweroff) handler installed before sysinit runs, so a press can never reach
|
||||
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
|
||||
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
|
||||
::once:/usr/local/lib/lambda-runtime/run-agent
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Runs the Lambda guest agent, then powers the guest off whatever its status.
|
||||
/usr/local/bin/lambda-agent >/dev/console 2>&1
|
||||
status=$?
|
||||
echo "[run-agent] lambda-agent exited with status $status" >/dev/console
|
||||
poweroff -f
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/bin/sh
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sys /sys
|
||||
mount -t devtmpfs dev /dev
|
||||
|
||||
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
|
||||
# already enumerated by the time PID 1 runs — no modprobe needed.
|
||||
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
|
||||
#
|
||||
# modprobe needs a usable modules.dep; when the image is built on a host whose
|
||||
# depmod can't produce one, busybox modprobe silently no-ops and the module
|
||||
# never loads — fw_cfg then never appears and init panics. insmod by explicit
|
||||
# path needs no modules.dep, so fall back to it. Handles .ko and .ko.gz.
|
||||
load_mod() {
|
||||
modprobe "$1" 2>/dev/null && return 0
|
||||
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
|
||||
insmod "$ko" 2>/dev/null && return 0
|
||||
case "$ko" in
|
||||
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
|
||||
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
|
||||
# virtio_net depends on net_failover, which depends on failover. The load_mod
|
||||
# insmod fallback loads a single .ko with no dependency resolution, and
|
||||
# modules.dep is absent when the image is built on a kmod-less host (depmod
|
||||
# skipped), so modprobe can't resolve the chain either. Load it explicitly
|
||||
# bottom-up so the NIC comes up regardless of how the image was built —
|
||||
# without this the agent has no network and the LB hangs in provisioning.
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || true
|
||||
|
||||
# The guest's SQS store listens on 127.0.0.1 and the worker dials it there;
|
||||
# the kernel leaves lo down, so bring it up before init starts either.
|
||||
ip link set lo up
|
||||
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ]; do
|
||||
[ -f "$NETCFG" ] && break
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [ ! -f "$NETCFG" ]; then
|
||||
# Dump host/guest fw_cfg state to the serial console before init exits so a
|
||||
# "netcfg not available" panic is diagnosable from the captured console log
|
||||
# (module-not-loaded vs qemu-not-exposing-by_name vs wrong machine type).
|
||||
echo "[init] FATAL: fw_cfg netcfg not available" >&2
|
||||
echo "[init] dmi: vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null) product=$(cat /sys/class/dmi/id/product_name 2>/dev/null) bios=$(cat /sys/class/dmi/id/bios_version 2>/dev/null)" >&2
|
||||
echo "[init] qemu_fw_cfg dir present: $([ -d /sys/firmware/qemu_fw_cfg ] && echo yes || echo NO)" >&2
|
||||
echo "[init] modules: $(lsmod 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ',')" >&2
|
||||
echo "[init] by_name listing:" >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/opt/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
echo "[init] dmesg fw_cfg:" >&2
|
||||
dmesg 2>/dev/null | grep -i fw_cfg | sed 's/^/[init] /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. "$NETCFG"
|
||||
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
[ -z "$mac" ] && continue
|
||||
|
||||
iface=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
|
||||
[ -n "$iface" ] && break
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
if [ -z "$iface" ]; then
|
||||
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
eval cidr=\$NIC${n}_CIDR
|
||||
eval gw=\$NIC${n}_GW
|
||||
eval default=\$NIC${n}_DEFAULT
|
||||
eval rdst=\$NIC${n}_ROUTE_DST
|
||||
eval rvia=\$NIC${n}_ROUTE_VIA
|
||||
|
||||
ip link set "$iface" up
|
||||
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
|
||||
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
|
||||
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
|
||||
done
|
||||
|
||||
# The image takes no lb-agent environment. The worker reads the cluster CA
|
||||
# from fw_cfg itself; this copy only serves other tools in the guest.
|
||||
CA_CERT=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw
|
||||
mkdir -p /etc/ssl/certs
|
||||
[ -f "$CA_CERT" ] && cp "$CA_CERT" /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
exec /sbin/init
|
||||
@@ -1,9 +0,0 @@
|
||||
# The power button loads here, not in /init: busybox init has its SIGUSR2
|
||||
# (poweroff) handler installed before sysinit runs, so a press can never reach
|
||||
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
|
||||
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
|
||||
::sysinit:/usr/local/lib/sqs-worker/sqs-worker-prepare
|
||||
::respawn:/usr/local/lib/sqs-worker/sqs-worker-store
|
||||
::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
::shutdown:/usr/local/lib/sqs-worker/sqs-worker-stop
|
||||
@@ -1,203 +0,0 @@
|
||||
# sqs-worker-lib.sh — shared by the SQS guest's store, readiness and stop
|
||||
# steps. Sourced, never executed. Every path and command is overridable so the
|
||||
# host test can drive the same code against test-owned processes and stub tools.
|
||||
#
|
||||
# The guest runs its own SQS nats-server on loopback and the worker beside it.
|
||||
# The server starts only after boot settled the receipt key; store-ready exists
|
||||
# only while the running watcher's last check of that server passed.
|
||||
|
||||
SQSW_RUN=${SQSW_RUN:-/run/sqs-worker}
|
||||
SQSW_DATA=${SQSW_DATA:-/data}
|
||||
SQSW_CONSOLE=${SQSW_CONSOLE:-/dev/console}
|
||||
SQSW_MOUNTS=${SQSW_MOUNTS:-/proc/mounts}
|
||||
SQSW_BIN=${SQSW_BIN:-/usr/local/bin/sqs-worker}
|
||||
SQSW_LIB=${SQSW_LIB:-/usr/local/lib/sqs-worker/sqs-worker-lib.sh}
|
||||
SQSW_NATS_BIN=${SQSW_NATS_BIN:-/usr/local/bin/nats-server}
|
||||
SQSW_STORE_PORT=${SQSW_STORE_PORT:-4222}
|
||||
SQSW_STOP_BUDGET_TENTHS=${SQSW_STOP_BUDGET_TENTHS:-100}
|
||||
SQSW_RESPAWN_GAP=${SQSW_RESPAWN_GAP:-5}
|
||||
SQSW_PARK=${SQSW_PARK:-sleep 2147483647}
|
||||
SQSW_BOOT_RETRY=${SQSW_BOOT_RETRY:-30}
|
||||
UMOUNT=${UMOUNT:-umount}
|
||||
|
||||
sqsw_log() { echo "[sqs-worker] $*" >> "$SQSW_CONSOLE"; }
|
||||
|
||||
# sqsw_alive <pid>: running or stopped, not exited (a zombie counts as exited).
|
||||
sqsw_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
|
||||
|
||||
# sqsw_starttime <pid>: kernel start time (stat field 22). It survives exec, and
|
||||
# pairing it with the pid reduces PID-reuse risk; it is not an absolute guarantee.
|
||||
sqsw_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
|
||||
|
||||
# sqsw_same <pid> <starttime>: the recorded process is still alive.
|
||||
sqsw_same() { [ -n "$2" ] && sqsw_alive "$1" && [ "$(sqsw_starttime "$1")" = "$2" ]; }
|
||||
|
||||
# sqsw_write_config <file>: the guest's own server. It listens only on
|
||||
# loopback, with no monitor, route or cluster listener. Its max_payload admits
|
||||
# a 1 MiB SQS message with its headers (ADR-0011:S4); the 1MB default does not.
|
||||
sqsw_write_config() {
|
||||
cat > "$1" <<CONF
|
||||
listen: "127.0.0.1:$SQSW_STORE_PORT"
|
||||
server_name: "sqs-store"
|
||||
max_payload: 2MiB
|
||||
jetstream {
|
||||
store_dir: "$SQSW_DATA/jetstream"
|
||||
sync_interval: always
|
||||
}
|
||||
CONF
|
||||
}
|
||||
|
||||
# sqsw_prepare_store: write and test the server config once boot has settled
|
||||
# the receipt key. Returns non-zero, leaving the store unstarted, otherwise.
|
||||
sqsw_prepare_store() {
|
||||
rm -f "$SQSW_RUN/store.conf"
|
||||
[ -s "$SQSW_RUN/receipt-ready" ] || { sqsw_log "store: receipt key not settled, store not prepared"; return 1; }
|
||||
umask 077
|
||||
sqsw_write_config "$SQSW_RUN/store.conf.new" || return 1
|
||||
if ! "$SQSW_NATS_BIN" -t -c "$SQSW_RUN/store.conf.new" > "$SQSW_RUN/config-test.out" 2>&1; then
|
||||
sqsw_log "store: FATAL: server config test failed: $(tr '\n' ' ' < "$SQSW_RUN/config-test.out")"
|
||||
return 1
|
||||
fi
|
||||
mv "$SQSW_RUN/store.conf.new" "$SQSW_RUN/store.conf"
|
||||
sqsw_log "store: config written for 127.0.0.1:$SQSW_STORE_PORT; server not started yet"
|
||||
}
|
||||
|
||||
# sqsw_park <why>: init respawns a step that exits, so a step that must not run
|
||||
# logs once and waits for shutdown instead of looping.
|
||||
sqsw_park() {
|
||||
sqsw_log "$1"
|
||||
exec $SQSW_PARK
|
||||
}
|
||||
|
||||
# sqsw_record <name>: record this process's identity before the stopping
|
||||
# check, so a stop that misses the record has already set stopping.
|
||||
sqsw_record() { echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/$1.pid"; }
|
||||
|
||||
# sqsw_boot: settle the disk and receipt key once per guest boot. A transient
|
||||
# failure (exit 75) waits and exits so init runs it again; boot never remounts
|
||||
# a disk already mounted. Any other failure is final for this guest boot.
|
||||
sqsw_boot() {
|
||||
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "store: boot refused, not starting"
|
||||
[ -s "$SQSW_RUN/receipt-ready" ] && return 0
|
||||
# The boot child records itself before its stopping check, as the other
|
||||
# steps do, so shutdown either sees its record or it never starts.
|
||||
SQSW_RUN="$SQSW_RUN" SQSW_BIN="$SQSW_BIN" sh -c '. "$1"; sqsw_record boot; [ ! -e "$SQSW_RUN/stopping" ] || exit 75; exec "$SQSW_BIN" boot' \
|
||||
sh "$SQSW_LIB" >> "$SQSW_CONSOLE" 2>&1 &
|
||||
child=$!
|
||||
rc=0
|
||||
wait "$child" || rc=$?
|
||||
rm -f "$SQSW_RUN/boot.pid"
|
||||
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown during boot, not starting"
|
||||
case "$rc" in
|
||||
0) return 0 ;;
|
||||
75) sqsw_log "store: boot not settled, retrying in ${SQSW_BOOT_RETRY}s"
|
||||
sleep "$SQSW_BOOT_RETRY"
|
||||
exit 1 ;;
|
||||
*) : > "$SQSW_RUN/boot-refused"
|
||||
sqsw_park "store: boot refused (rc=$rc), not starting" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# sqsw_store: the respawned server body. It starts only on a prepared config.
|
||||
sqsw_store() {
|
||||
mkdir -p "$SQSW_RUN"
|
||||
sqsw_record store
|
||||
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown in progress, not starting"
|
||||
sqsw_boot
|
||||
if [ ! -s "$SQSW_RUN/store.conf" ] && ! sqsw_prepare_store; then
|
||||
: > "$SQSW_RUN/boot-refused"
|
||||
sqsw_park "store: not prepared, not starting"
|
||||
fi
|
||||
sqsw_log "store: starting pid=$$"
|
||||
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
|
||||
GOMEMLIMIT=512MiB exec "$SQSW_NATS_BIN" -c "$SQSW_RUN/store.conf"
|
||||
}
|
||||
|
||||
# sqsw_run: the respawned readiness body. A predecessor that was killed or
|
||||
# crashed could not remove its marker, so every start removes it first.
|
||||
sqsw_run() {
|
||||
mkdir -p "$SQSW_RUN"
|
||||
sqsw_record ready
|
||||
if [ -e "$SQSW_RUN/store-ready" ]; then
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
sqsw_log "ready: removed a store-ready marker left by an earlier watcher"
|
||||
fi
|
||||
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "ready: shutdown in progress, not starting"
|
||||
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "ready: boot refused, readiness not started"
|
||||
# Boot may still be retrying; check again after a gap rather than parking.
|
||||
if [ ! -s "$SQSW_RUN/store.conf" ]; then
|
||||
sleep "$SQSW_RESPAWN_GAP"
|
||||
exit 0
|
||||
fi
|
||||
# Init respawns at once; a watcher that keeps exiting restarts at most
|
||||
# once per gap instead of in a tight loop.
|
||||
now=$(date +%s)
|
||||
last=$(cat "$SQSW_RUN/ready.last" 2>/dev/null || echo 0)
|
||||
wait=$((last + SQSW_RESPAWN_GAP - now))
|
||||
if [ "$wait" -gt 0 ] && [ "$wait" -le "$SQSW_RESPAWN_GAP" ]; then sleep "$wait"; fi
|
||||
date +%s > "$SQSW_RUN/ready.last"
|
||||
sqsw_log "ready: starting watcher pid=$$"
|
||||
exec "$SQSW_BIN" ready >> "$SQSW_CONSOLE" 2>&1
|
||||
}
|
||||
|
||||
# sqsw_end <name>: SIGTERM the recorded process, SIGKILL only after the
|
||||
# budget. Returns non-zero while it is still alive.
|
||||
sqsw_end() {
|
||||
rec=$(cat "$SQSW_RUN/$1.pid" 2>/dev/null || true)
|
||||
pid=${rec%% *} st=${rec#* }
|
||||
[ "$st" != "$rec" ] || st=""
|
||||
if [ -z "$pid" ] || ! sqsw_same "$pid" "$st"; then
|
||||
sqsw_log "stop: no running $1"
|
||||
return 0
|
||||
fi
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sqsw_log "stop: SIGTERM sent to $1 pid=$pid"
|
||||
i=0
|
||||
while sqsw_same "$pid" "$st" && [ "$i" -lt "$SQSW_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
|
||||
if sqsw_same "$pid" "$st"; then
|
||||
sqsw_log "stop: $1 pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
j=0
|
||||
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
|
||||
fi
|
||||
if sqsw_same "$pid" "$st"; then
|
||||
sqsw_log "stop: $1 pid=$pid STILL ALIVE"
|
||||
return 1
|
||||
fi
|
||||
sqsw_log "stop: $1 pid=$pid exited"
|
||||
rm -f "$SQSW_RUN/$1.pid"
|
||||
}
|
||||
|
||||
# sqsw_stop: block any respawn, drop the marker, stop the watcher, the server
|
||||
# and any boot in progress, and unmount the disk only once all are confirmed
|
||||
# gone. A survivor keeps the disk mounted.
|
||||
sqsw_stop() {
|
||||
mkdir -p "$SQSW_RUN"
|
||||
: > "$SQSW_RUN/stopping"
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
if ! sqsw_end ready || ! sqsw_end store || ! sqsw_end boot; then
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
sqsw_log "stop: leaving $SQSW_DATA mounted"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
sync
|
||||
if grep -q " $SQSW_DATA " "$SQSW_MOUNTS" 2>/dev/null; then
|
||||
rc=0
|
||||
"$UMOUNT" "$SQSW_DATA" || rc=$?
|
||||
sqsw_log "stop: umount $SQSW_DATA rc=$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# sqsw_shutdown: the only shutdown action. The generic read-only remount runs
|
||||
# only after both processes stopped; a survivor keeps every mount as it is.
|
||||
sqsw_shutdown() {
|
||||
if ! sqsw_stop; then
|
||||
sync
|
||||
sqsw_log "shutdown: a guest process survived, skipping umount -a -r"
|
||||
return 1
|
||||
fi
|
||||
rc=0
|
||||
"$UMOUNT" -a -r || rc=$?
|
||||
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQSW_CONSOLE"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sysinit: load the disk drivers and clear this boot's markers. The respawned
|
||||
# store step mounts the disk, settles the receipt key and starts the store.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
modprobe virtio_blk 2>/dev/null || true
|
||||
modprobe ext4 2>/dev/null || true
|
||||
mkdir -p "$SQSW_RUN"
|
||||
rm -f "$SQSW_RUN/receipt-ready" "$SQSW_RUN/store.conf" "$SQSW_RUN/boot-refused"
|
||||
exit 0
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Respawned by init: the worker, reporting ready only while the local store
|
||||
# answers. A watcher that must not run parks instead of exiting.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
sqsw_run
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The only shutdown action: stop the worker, then the store, and unmount the
|
||||
# data disk, then run the generic read-only remount only if both are gone.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
sqsw_shutdown
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Respawned by init: the guest's own SQS nats-server, started only on a
|
||||
# prepared config. A store that must not run parks instead of exiting.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
sqsw_store
|
||||
@@ -1,4 +1,4 @@
|
||||
# spinifex-unit-version: 6
|
||||
# spinifex-unit-version: 5
|
||||
[Unit]
|
||||
Description=Spinifex Daemon (VM Orchestration)
|
||||
PartOf=spinifex.target
|
||||
@@ -15,7 +15,6 @@ Slice=spinifex-system.slice
|
||||
User=spinifex-daemon
|
||||
Group=spinifex
|
||||
ExecStartPre=/usr/local/bin/spx admin hostnet check
|
||||
ExecStartPre=-+/usr/local/bin/spx admin node-facts collect
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# spinifex-unit-version: 2
|
||||
# spinifex-unit-version: 1
|
||||
[Unit]
|
||||
Description=Spinifex host firewall (nftables input policy)
|
||||
Documentation=file:/etc/spinifex/firewall/spinifex.nft
|
||||
Wants=network-pre.target
|
||||
Before=network-pre.target shutdown.target
|
||||
DefaultDependencies=no
|
||||
ConditionPathExists=/etc/spinifex/firewall/peers.nft
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
@@ -12,11 +13,8 @@ RemainAfterExit=yes
|
||||
StandardInput=null
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
# Unconditional: connection tracking loads on every node, so a flow that opens
|
||||
# while the policy is not armed survives the arm. The policy itself still loads
|
||||
# only where the daemon has written a peer file.
|
||||
ExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply boot
|
||||
ExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply boot
|
||||
ExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply
|
||||
ExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply
|
||||
TimeoutStopSec=10s
|
||||
|
||||
# No ExecStop: flushing on stop would take vpcd's MASQUERADE and per-EIP FORWARD
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# spinifex-unit-version: 1
|
||||
[Unit]
|
||||
Description=Spinifex SQS Message Store (dedicated JetStream)
|
||||
PartOf=spinifex.target
|
||||
ConditionPathExists=/etc/spinifex/sqs-store/sqs-store.conf
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-sqsstore
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service sqs-store start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# JetStream store shutdown and raft stepdown have no internal deadline.
|
||||
TimeoutStopSec=20
|
||||
OOMScoreAdjust=-500
|
||||
# Soft Go heap limit for the store; MemoryHigh/Max bound a runaway on top.
|
||||
Environment=GOMEMLIMIT=512MiB
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/sqs-store/sqs-store.conf
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/sqs-store /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/sqs-store /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,9 +1,9 @@
|
||||
# spinifex-unit-version: 4
|
||||
# spinifex-unit-version: 3
|
||||
[Unit]
|
||||
Description=Spinifex Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
|
||||
Wants=spinifex-nats.service spinifex-sqs-store.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -4,21 +4,12 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
|
||||
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
|
||||
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
|
||||
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
|
||||
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
|
||||
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
|
||||
sqsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sqs/awsapi"
|
||||
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
@@ -57,26 +48,10 @@ func fail(err error) {
|
||||
func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := dispatch.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
|
||||
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
|
||||
rdsawsapi.ServiceName: rdsawsapi.OperationInventory(),
|
||||
ec2awsapi.ServiceName: ec2awsapi.OperationInventory(),
|
||||
ecsawsapi.ServiceName: ecsawsapi.OperationInventory(),
|
||||
eksawsapi.ServiceName: eksawsapi.OperationInventory(),
|
||||
iamawsapi.ServiceName: iamawsapi.OperationInventory(),
|
||||
sqsawsapi.ServiceName: sqsawsapi.OperationInventory(),
|
||||
stsawsapi.ServiceName: stsawsapi.OperationInventory(),
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
})
|
||||
// The loop below walks the embedded models, so a served service with no
|
||||
// model would otherwise drop out of the report without a word.
|
||||
for name := range inventories {
|
||||
if !slices.Contains(awsmodel.Services(), awsmodel.Service(name)) {
|
||||
return nil, fmt.Errorf("no embedded AWS model for served service %s", name)
|
||||
}
|
||||
}
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := inventories[string(service)]
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// eks-etcd-member removes a dead peer from the embedded etcd cluster of an EKS
|
||||
// K3s control-plane VM. A member that is already gone exits 0; any other
|
||||
// failure exits non-zero.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/etcdmember"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) < 1 || args[0] != "remove" {
|
||||
fmt.Fprintln(stderr, "usage: eks-etcd-member remove --peer-ip 10.0.0.12 [--self-ip 10.0.0.10]")
|
||||
return 2
|
||||
}
|
||||
fs := flag.NewFlagSet("remove", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
var cfg etcdmember.Config
|
||||
endpoint := fs.String("endpoint", etcdmember.DefaultEndpoint, "etcd client endpoint")
|
||||
certDir := fs.String("cert-dir", etcdmember.DefaultCertDir, "directory holding the K3s etcd client.crt, client.key and server-ca.crt")
|
||||
timeout := fs.Duration("timeout", 30*time.Second, "overall deadline")
|
||||
fs.StringVar(&cfg.PeerIP, "peer-ip", "", "peer URL host of the member to remove")
|
||||
fs.StringVar(&cfg.SelfIP, "self-ip", "", "this host's own address; removal of it is refused")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
cli, err := etcdmember.Dial(*endpoint, *certDir, *timeout)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "etcd member remove failed:", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = cli.Close() }()
|
||||
return etcdmember.Run(ctx, cli, cfg, stdout)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit/fakeetcd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var fakeMembers = []fakeetcd.Member{
|
||||
{ID: 0xa1, Name: "cp-a", PeerURLs: []string{"https://10.0.0.10:2380"}},
|
||||
{ID: 0xb2, Name: "cp-b", PeerURLs: []string{"https://10.0.0.12:2380"}},
|
||||
}
|
||||
|
||||
func TestRunRequiresTheRemoveSubcommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, args := range [][]string{nil, {"list"}} {
|
||||
var out, errOut bytes.Buffer
|
||||
assert.Equal(t, 2, run(args, &out, &errOut))
|
||||
assert.Contains(t, errOut.String(), "usage: eks-etcd-member remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUnknownFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out, errOut bytes.Buffer
|
||||
assert.Equal(t, 2, run([]string{"remove", "--bogus"}, &out, &errOut))
|
||||
}
|
||||
|
||||
func TestRunFailsWhenEtcdCertificatesAreMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out, errOut bytes.Buffer
|
||||
code := run([]string{"remove", "--peer-ip", "10.0.0.12", "--cert-dir", t.TempDir()}, &out, &errOut)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, errOut.String(), "etcd member remove failed")
|
||||
}
|
||||
|
||||
func TestRunRemovesTheNamedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := fakeetcd.Start(t, 0xa1, fakeMembers)
|
||||
var out, errOut bytes.Buffer
|
||||
|
||||
code := run([]string{"remove", "--peer-ip", "10.0.0.12", "--self-ip", "10.0.0.10",
|
||||
"--endpoint", srv.Endpoint, "--cert-dir", srv.CertDir}, &out, &errOut)
|
||||
|
||||
assert.Zero(t, code, errOut.String()+out.String())
|
||||
assert.Contains(t, out.String(), "removed etcd member 10.0.0.12")
|
||||
assert.Equal(t, []uint64{0xb2}, srv.Removed())
|
||||
}
|
||||
|
||||
func TestRunTreatsAnAbsentPeerAsSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := fakeetcd.Start(t, 0xa1, fakeMembers)
|
||||
var out, errOut bytes.Buffer
|
||||
|
||||
code := run([]string{"remove", "--peer-ip", "10.0.0.99",
|
||||
"--endpoint", srv.Endpoint, "--cert-dir", srv.CertDir}, &out, &errOut)
|
||||
|
||||
assert.Zero(t, code)
|
||||
assert.Contains(t, out.String(), "member already absent")
|
||||
assert.Empty(t, srv.Removed())
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
|
||||
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
|
||||
// eks-gateway-fetch -resource workers # GET /clusters/{cluster}/internal-workers/{accountId}
|
||||
//
|
||||
// Flags default to environment variables seeded by cloud-init:
|
||||
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
|
||||
@@ -34,7 +33,7 @@ func main() {
|
||||
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
|
||||
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery | workers")
|
||||
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
|
||||
flag.Parse()
|
||||
|
||||
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Command eks-pod-identity-webhook is the IRSA mutating admission webhook run
|
||||
// on every EKS control-plane server; the implementation is in
|
||||
// agents/eks/podidentity. It listens on loopback only and is called by the
|
||||
// local kube-apiserver.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/eks/podidentity"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
cfg, err := podidentity.ParseServiceConfig(os.Args[1:], os.Getenv, os.Stderr)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "error", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := podidentity.Run(ctx, cfg, log); err != nil {
|
||||
log.Error("pod identity webhook stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ func loadConfig() (tokenwebhook.Config, error) {
|
||||
CertPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
|
||||
KeyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
|
||||
Kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
|
||||
ReadyAddr: os.Getenv("EKS_WEBHOOK_READY_ADDR"),
|
||||
}
|
||||
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
|
||||
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
)
|
||||
|
||||
// Exit statuses: the guest refused to start, the control connection failed,
|
||||
// or the host closed it.
|
||||
const (
|
||||
exitRefused = 2
|
||||
exitLink = 1
|
||||
exitClosed = 0
|
||||
)
|
||||
|
||||
type startup struct {
|
||||
fwCfgDir string
|
||||
fwCfgRoot string
|
||||
unload func() error
|
||||
}
|
||||
|
||||
func defaultStartup() startup {
|
||||
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
}
|
||||
|
||||
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
|
||||
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
|
||||
func (s startup) connect(ctx context.Context) (*tls.Conn, lambdav1.Scope, int, error) {
|
||||
id, loadErr := hostlink.LoadIdentity(s.fwCfgDir)
|
||||
if err := hostlink.SealFwCfg(s.fwCfgRoot, s.unload); err != nil {
|
||||
return nil, "", exitRefused, errors.Join(loadErr, err)
|
||||
}
|
||||
if loadErr != nil {
|
||||
return nil, "", exitRefused, loadErr
|
||||
}
|
||||
conn, err := hostlink.Dial(ctx, id)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
|
||||
}
|
||||
return nil, "", exitLink, err
|
||||
}
|
||||
return conn, id.Scope, 0, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
)
|
||||
|
||||
const entryGrant = "test-grant"
|
||||
|
||||
// testGrant is the grant the host launched this VM under.
|
||||
type testGrant struct {
|
||||
grantID, attemptID, environmentID string
|
||||
generation uint64
|
||||
}
|
||||
|
||||
// readGrant reads the test-grant fw_cfg entry: grant ID, attempt ID,
|
||||
// environment ID and generation, one per line.
|
||||
func readGrant(fwCfgDir string) (testGrant, error) {
|
||||
b, err := os.ReadFile(filepath.Join(fwCfgDir, entryGrant, "raw"))
|
||||
if err != nil {
|
||||
return testGrant{}, fmt.Errorf("read %s: %w", entryGrant, err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
return testGrant{}, errors.New(entryGrant + " entry malformed")
|
||||
}
|
||||
g := testGrant{grantID: lines[0], attemptID: lines[1], environmentID: lines[2]}
|
||||
g.generation, err = strconv.ParseUint(lines[3], 10, 64)
|
||||
if err != nil || g.generation == 0 || g.grantID == "" || g.attemptID == "" || g.environmentID == "" {
|
||||
return testGrant{}, errors.New(entryGrant + " entry malformed")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// checkBinding refuses a run unless the grant names the authenticated environment
|
||||
// generation, the request carries that grant and its deadline is still ahead. The
|
||||
// clock check is a local guard only: it cannot detect skew or a superseded grant.
|
||||
func checkBinding(scope lambdav1.Scope, g testGrant, req hostlink.Request, now time.Time) error {
|
||||
parts := strings.Split(string(scope), "/")
|
||||
if len(parts) != 5 {
|
||||
return errors.New("authenticated scope malformed")
|
||||
}
|
||||
if parts[3] != g.environmentID || parts[4] != strconv.FormatUint(g.generation, 10) {
|
||||
return errors.New("grant is not for the authenticated environment generation")
|
||||
}
|
||||
if req.GrantID != g.grantID || req.AttemptID != g.attemptID {
|
||||
return errors.New("request does not carry this VM's grant")
|
||||
}
|
||||
if req.DeadlineMs <= 0 {
|
||||
return errors.New("request has no deadline")
|
||||
}
|
||||
if !now.Before(time.UnixMilli(req.DeadlineMs)) {
|
||||
return errors.New("request deadline already passed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/1"
|
||||
|
||||
// guest is a fake fw_cfg tree; unload removes it as unloading the module would.
|
||||
type guest struct {
|
||||
root string
|
||||
unloads int
|
||||
sealedAt chan struct{}
|
||||
}
|
||||
|
||||
func newGuest(t *testing.T, entries map[string][]byte) *guest {
|
||||
t.Helper()
|
||||
g := &guest{root: filepath.Join(t.TempDir(), "qemu_fw_cfg"), sealedAt: make(chan struct{}, 1)}
|
||||
for name, b := range entries {
|
||||
dir := filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix, name)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), b, 0o600))
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *guest) startup() startup {
|
||||
return startup{
|
||||
fwCfgDir: filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix),
|
||||
fwCfgRoot: g.root,
|
||||
unload: func() error {
|
||||
g.unloads++
|
||||
g.sealedAt <- struct{}{}
|
||||
return os.RemoveAll(g.root)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type host struct {
|
||||
ln *net.TCPListener
|
||||
gen *guestconn.Generation
|
||||
conn chan accepted
|
||||
}
|
||||
|
||||
func newHost(t *testing.T) *host {
|
||||
t.Helper()
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
gen, err := guestconn.NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
return &host{ln: ln, gen: gen, conn: make(chan accepted, 1)}
|
||||
}
|
||||
|
||||
func (h *host) entries() map[string][]byte { return h.gen.GuestEntries(h.ln.Addr().String()) }
|
||||
|
||||
type accepted struct {
|
||||
conn *tls.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
// accept runs the host end until ctx ends and reports what it accepted.
|
||||
func (h *host) accept(ctx context.Context) {
|
||||
go func() {
|
||||
c, err := h.gen.AcceptGuest(ctx, h.ln, 2*time.Second)
|
||||
h.conn <- accepted{c, err}
|
||||
}()
|
||||
}
|
||||
|
||||
// guest waits for the host to authenticate the guest.
|
||||
func (h *host) guest(t *testing.T) *tls.Conn {
|
||||
t.Helper()
|
||||
a := wait(t, h.conn, "host to authenticate the guest")
|
||||
require.NoError(t, a.err)
|
||||
return a.conn
|
||||
}
|
||||
|
||||
// wait bounds every test wait so a stalled host or startup fails promptly.
|
||||
func wait[T any](t *testing.T, ch <-chan T, what string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case v := <-ch:
|
||||
return v
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// lambda-agent runs inside a Lambda guest.
|
||||
//
|
||||
// A normal build reads its per-generation identity from fw_cfg, unloads
|
||||
// fw_cfg so nothing started later can read the guest key, and opens the
|
||||
// authenticated control connection to the host. It accepts no invocations and
|
||||
// never starts the function's bootstrap: the message protocol and grant
|
||||
// fencing do not exist yet. The agent never listens for the host.
|
||||
//
|
||||
// A lambdatestlink build opens the same connection, then relays one invocation
|
||||
// over a test carrier for local proofs; the carrier is not the guest protocol.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { os.Exit(run()) }
|
||||
|
||||
func run() int {
|
||||
flag.Parse()
|
||||
return start(context.Background())
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Dev-only code delivery for the test path: the host attaches the ZIP as a
|
||||
// read-only disk and passes its exact size and SHA-256 through fw_cfg. This
|
||||
// is not the production delivery, and these bounds are not AWS limits.
|
||||
const (
|
||||
entryPackageSize = "test-package-size"
|
||||
entryPackageSHA256 = "test-package-sha256"
|
||||
entryHandler = "test-handler"
|
||||
|
||||
devMaxZipBytes = 16 << 20
|
||||
devMaxUnzipBytes = 64 << 20
|
||||
devMaxEntries = 1024
|
||||
packageDevice = "/dev/vda"
|
||||
taskTmpfsSizeOpts = "mode=0755,size=72m"
|
||||
)
|
||||
|
||||
type packageSpec struct {
|
||||
size int64
|
||||
sha256 [sha256.Size]byte
|
||||
handler string
|
||||
}
|
||||
|
||||
type unpackLimits struct {
|
||||
entries int
|
||||
bytes int64
|
||||
}
|
||||
|
||||
var devLimits = unpackLimits{entries: devMaxEntries, bytes: devMaxUnzipBytes}
|
||||
|
||||
// readPackageSpec reads the expected size and SHA-256 from fw_cfg; it must
|
||||
// run before fw_cfg is sealed.
|
||||
func readPackageSpec(fwCfgDir string) (packageSpec, error) {
|
||||
var spec packageSpec
|
||||
read := func(name string) (string, error) {
|
||||
b, err := os.ReadFile(filepath.Join(fwCfgDir, name, "raw"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
s, err := read(entryPackageSize)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
spec.size, err = strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || spec.size <= 0 || spec.size > devMaxZipBytes {
|
||||
return spec, fmt.Errorf("package size %q outside 1..%d bytes", s, devMaxZipBytes)
|
||||
}
|
||||
h, err := read(entryPackageSHA256)
|
||||
if err != nil {
|
||||
return spec, err
|
||||
}
|
||||
b, err := hex.DecodeString(h)
|
||||
if err != nil || len(b) != sha256.Size {
|
||||
return spec, errors.New("package sha256 is not 64 hex digits")
|
||||
}
|
||||
copy(spec.sha256[:], b)
|
||||
if spec.handler, err = read(entryHandler); err != nil {
|
||||
return spec, err
|
||||
}
|
||||
if spec.handler == "" || len(spec.handler) > 128 || strings.IndexFunc(spec.handler, unicode.IsSpace) >= 0 ||
|
||||
strings.ContainsRune(spec.handler, 0) {
|
||||
return spec, errors.New("handler must be 1..128 characters with no whitespace")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// taskPackage puts the package under dir: a fresh nosuid,nodev tmpfs, made
|
||||
// read-only once the files are in place.
|
||||
type taskPackage struct {
|
||||
device string
|
||||
dir string
|
||||
loadDriver func() error
|
||||
mountDir func(dir string) error
|
||||
sealDir func(dir string) error
|
||||
}
|
||||
|
||||
func devTaskPackage() taskPackage {
|
||||
return taskPackage{device: packageDevice, dir: taskRoot, loadDriver: loadBlockDriver, mountDir: mountTaskDir, sealDir: sealTaskDir}
|
||||
}
|
||||
|
||||
func (p taskPackage) install(spec packageSpec) error {
|
||||
if err := p.loadDriver(); err != nil {
|
||||
return fmt.Errorf("load block driver: %w", err)
|
||||
}
|
||||
dev, err := os.Open(p.device)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open package disk: %w", err)
|
||||
}
|
||||
defer dev.Close()
|
||||
if err := p.mountDir(p.dir); err != nil {
|
||||
return fmt.Errorf("mount %s: %w", p.dir, err)
|
||||
}
|
||||
if err := unpack(dev, spec, p.dir, devLimits); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.sealDir(p.dir); err != nil {
|
||||
return fmt.Errorf("make %s read-only: %w", p.dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpack reads exactly spec.size bytes once, checks their SHA-256, then
|
||||
// validates every entry before writing any of them under dir.
|
||||
func unpack(r io.ReaderAt, spec packageSpec, dir string, lim unpackLimits) error {
|
||||
buf := make([]byte, spec.size)
|
||||
if _, err := io.ReadFull(io.NewSectionReader(r, 0, spec.size), buf); err != nil {
|
||||
return fmt.Errorf("read package: %w", err)
|
||||
}
|
||||
if sha256.Sum256(buf) != spec.sha256 {
|
||||
return errors.New("package sha256 mismatch")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf), spec.size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open package: %w", err)
|
||||
}
|
||||
if len(zr.File) > lim.entries {
|
||||
return fmt.Errorf("package has %d entries, limit %d", len(zr.File), lim.entries)
|
||||
}
|
||||
names := make([]string, len(zr.File))
|
||||
var declared int64
|
||||
for i, f := range zr.File {
|
||||
name, err := entryName(f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names[i] = name
|
||||
switch mode := f.Mode(); {
|
||||
case mode.IsDir():
|
||||
case mode.IsRegular():
|
||||
// Compared against what remains, so a huge size cannot wrap the sum.
|
||||
if f.UncompressedSize64 > uint64(lim.bytes-declared) {
|
||||
return fmt.Errorf("%q: unpacked size over the %d-byte limit", f.Name, lim.bytes)
|
||||
}
|
||||
declared += int64(f.UncompressedSize64)
|
||||
default:
|
||||
return fmt.Errorf("%q: %s entries are refused", f.Name, mode.Type())
|
||||
}
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
var written int64
|
||||
for i, f := range zr.File {
|
||||
n, err := extract(root, names[i], f, lim.bytes-written)
|
||||
written += n
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extract writes one entry, refusing it if its actual bytes exceed budget
|
||||
// whatever its header claims.
|
||||
func extract(root *os.Root, name string, f *zip.File, budget int64) (int64, error) {
|
||||
if f.Mode().IsDir() {
|
||||
return 0, mkdirs(root, name)
|
||||
}
|
||||
if d := path.Dir(name); d != "." {
|
||||
if err := mkdirs(root, d); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
perm := os.FileMode(0o644)
|
||||
if f.Mode()&0o111 != 0 {
|
||||
perm = 0o755
|
||||
}
|
||||
in, err := f.Open()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The umask applies at create; fchmod does not, so the mode is exact.
|
||||
if err := out.Chmod(perm); err != nil {
|
||||
out.Close()
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(out, io.LimitReader(in, budget+1))
|
||||
if cerr := out.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err == nil && n > budget {
|
||||
err = errors.New("unpacked bytes over the limit")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// mkdirs creates each component of dir as mode 0755 whatever the umask. An
|
||||
// existing component must be a real directory, never a symlink.
|
||||
func mkdirs(root *os.Root, dir string) error {
|
||||
cur := ""
|
||||
for _, part := range strings.Split(dir, "/") {
|
||||
cur = path.Join(cur, part)
|
||||
if err := root.Mkdir(cur, 0o755); err != nil {
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return err
|
||||
}
|
||||
fi, err := root.Lstat(cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("%q exists and is not a directory", cur)
|
||||
}
|
||||
}
|
||||
if err := root.Chmod(cur, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// entryName returns a clean relative path, refusing anything that could name
|
||||
// a location outside the task root.
|
||||
func entryName(name string) (string, error) {
|
||||
if name == "" || strings.ContainsAny(name, "\\\x00") || path.IsAbs(name) {
|
||||
return "", fmt.Errorf("%q: entry name refused", name)
|
||||
}
|
||||
c := path.Clean(name)
|
||||
if c == "." || c == ".." || strings.HasPrefix(c, "../") {
|
||||
return "", fmt.Errorf("%q: entry name refused", name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// loadBlockDriver loads virtio_blk, which the image ships but init does not
|
||||
// load, then waits briefly for the package disk to appear.
|
||||
func loadBlockDriver() error {
|
||||
if err := exec.Command("/sbin/modprobe", "virtio_blk").Run(); err != nil {
|
||||
var uts unix.Utsname
|
||||
if uerr := unix.Uname(&uts); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
ko := "/lib/modules/" + unix.ByteSliceToString(uts.Release[:]) + "/kernel/drivers/block/virtio_blk.ko"
|
||||
if err := exec.Command("/sbin/insmod", ko).Run(); err != nil {
|
||||
return fmt.Errorf("modprobe and insmod virtio_blk failed: %w", err)
|
||||
}
|
||||
}
|
||||
for range 50 {
|
||||
if _, err := os.Stat(packageDevice); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("%s did not appear", packageDevice)
|
||||
}
|
||||
|
||||
func mountTaskDir(dir string) error {
|
||||
return unix.Mount("tmpfs", dir, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, taskTmpfsSizeOpts)
|
||||
}
|
||||
|
||||
func sealTaskDir(dir string) error {
|
||||
return unix.Mount("", dir, "", unix.MS_REMOUNT|unix.MS_RDONLY|unix.MS_NOSUID|unix.MS_NODEV, "")
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"hash/crc32"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testZip builds a ZIP; an entry named bootstrap is marked executable.
|
||||
func testZip(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
h := &zip.FileHeader{Name: n, Method: zip.Deflate}
|
||||
h.SetMode(0o644)
|
||||
if n == "bootstrap" {
|
||||
h.SetMode(0o755)
|
||||
}
|
||||
w, err := zw.CreateHeader(h)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte(files[n]))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// rawZip writes entries with headers set by the caller, so a test can claim
|
||||
// sizes or modes the data does not have.
|
||||
func rawZip(t *testing.T, entries ...*zip.FileHeader) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, h := range entries {
|
||||
w, err := zw.CreateRaw(h)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("x"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func specFor(b []byte) packageSpec {
|
||||
return packageSpec{size: int64(len(b)), sha256: sha256.Sum256(b)}
|
||||
}
|
||||
|
||||
// Not parallel: the umask is process-wide, so the test sets a restrictive one
|
||||
// and restores it.
|
||||
func TestUnpackInstallsFilesWithFixedModes(t *testing.T) {
|
||||
old := syscall.Umask(0o077)
|
||||
t.Cleanup(func() { syscall.Umask(old) })
|
||||
b := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, unpack(bytes.NewReader(b), specFor(b), dir, devLimits))
|
||||
|
||||
pfi, err := os.Stat(filepath.Join(dir, "lib"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o755), pfi.Mode().Perm(), "implicit parent dir")
|
||||
|
||||
fi, err := os.Stat(filepath.Join(dir, "bootstrap"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o755), fi.Mode().Perm(), "executable")
|
||||
fi, err = os.Stat(filepath.Join(dir, "lib", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o644), fi.Mode().Perm())
|
||||
got, err := os.ReadFile(filepath.Join(dir, "lib", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "data", string(got))
|
||||
}
|
||||
|
||||
func TestUnpackRefusesBeforeWritingAnything(t *testing.T) {
|
||||
symlink := &zip.FileHeader{Name: "link", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
|
||||
symlink.SetMode(fs.ModeSymlink | 0o777)
|
||||
fifo := &zip.FileHeader{Name: "pipe", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
|
||||
fifo.SetMode(fs.ModeNamedPipe | 0o644)
|
||||
// The second size would wrap a plain running sum back under the limit.
|
||||
big := func(name string, n uint64) *zip.FileHeader {
|
||||
return &zip.FileHeader{Name: name, Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: n}
|
||||
}
|
||||
cases := map[string]struct {
|
||||
zip []byte
|
||||
want string
|
||||
}{
|
||||
"parent traversal": {testZip(t, map[string]string{"a": "ok", "../escape": "x"}), "entry name refused"},
|
||||
"nested traversal": {testZip(t, map[string]string{"a": "ok", "lib/../../escape": "x"}), "entry name refused"},
|
||||
"absolute path": {testZip(t, map[string]string{"a": "ok", "/etc/escape": "x"}), "entry name refused"},
|
||||
"backslash": {testZip(t, map[string]string{"a": "ok", `..\escape`: "x"}), "entry name refused"},
|
||||
"symlink": {rawZip(t, big("a", 1), symlink), "entries are refused"},
|
||||
"named pipe": {rawZip(t, big("a", 1), fifo), "entries are refused"},
|
||||
"declared size": {rawZip(t, big("a", devMaxUnzipBytes+1)), "over the"},
|
||||
"wrapping sizes": {rawZip(t, big("a", devMaxUnzipBytes), big("b", math.MaxUint64)), "over the"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := unpack(bytes.NewReader(tc.zip), specFor(tc.zip), dir, devLimits)
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
left, rerr := os.ReadDir(dir)
|
||||
require.NoError(t, rerr)
|
||||
assert.Empty(t, left, "files written before the refusal")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackRefusesTooManyEntries(t *testing.T) {
|
||||
b := testZip(t, map[string]string{"a": "1", "b": "2", "c": "3"})
|
||||
err := unpack(bytes.NewReader(b), specFor(b), t.TempDir(), unpackLimits{entries: 2, bytes: devMaxUnzipBytes})
|
||||
assert.ErrorContains(t, err, "limit 2")
|
||||
}
|
||||
|
||||
// The header claims a size within the limit, but the data is longer: the
|
||||
// actual bytes are refused, whatever the header says.
|
||||
func TestUnpackRefusesBytesBeyondTheLimit(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("a"), 64)
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
h := &zip.FileHeader{Name: "a", Method: zip.Store, CRC32: crc32.ChecksumIEEE(data), CompressedSize64: uint64(len(data)), UncompressedSize64: 8}
|
||||
w, err := zw.CreateRaw(h)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
b := buf.Bytes()
|
||||
|
||||
dir := t.TempDir()
|
||||
err = unpack(bytes.NewReader(b), specFor(b), dir, unpackLimits{entries: 4, bytes: 16})
|
||||
require.Error(t, err)
|
||||
fi, serr := os.Stat(filepath.Join(dir, "a"))
|
||||
if serr == nil {
|
||||
assert.LessOrEqual(t, fi.Size(), int64(17))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpackChecksSizeAndHash(t *testing.T) {
|
||||
b := testZip(t, map[string]string{"bootstrap": "x"})
|
||||
wrongHash := specFor(b)
|
||||
wrongHash.sha256[0] ^= 1
|
||||
assert.ErrorContains(t, unpack(bytes.NewReader(b), wrongHash, t.TempDir(), devLimits), "sha256 mismatch")
|
||||
|
||||
longer := specFor(b)
|
||||
longer.size++
|
||||
assert.ErrorContains(t, unpack(bytes.NewReader(b), longer, t.TempDir(), devLimits), "read package")
|
||||
}
|
||||
|
||||
func TestReadPackageSpecBounds(t *testing.T) {
|
||||
sum := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
cases := map[string]struct {
|
||||
size, hash, handler string
|
||||
want string
|
||||
}{
|
||||
"valid": {"10", sum, "bootstrap.h", ""},
|
||||
"zero": {"0", sum, "bootstrap.h", "outside"},
|
||||
"too large": {"16777217", sum, "bootstrap.h", "outside"},
|
||||
"not hex": {"10", "zz", "bootstrap.h", "64 hex digits"},
|
||||
"short": {"10", sum[:62], "bootstrap.h", "64 hex digits"},
|
||||
"empty handler": {"10", sum, "", "handler"},
|
||||
"handler space": {"10", sum, "a b", "handler"},
|
||||
"128-char handler": {"10", sum, strings.Repeat("h", 128), ""},
|
||||
"129-char handler": {"10", sum, strings.Repeat("h", 129), "handler"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for n, v := range map[string]string{entryPackageSize: tc.size, entryPackageSHA256: tc.hash, entryHandler: tc.handler} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, n), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, n, "raw"), []byte(v), 0o600))
|
||||
}
|
||||
spec, err := readPackageSpec(dir)
|
||||
if tc.want == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10), spec.size)
|
||||
assert.Equal(t, tc.handler, spec.handler)
|
||||
return
|
||||
}
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-existing symlink where a directory belongs is refused, not followed
|
||||
// or chmodded.
|
||||
func TestMkdirsRefusesSymlinkComponent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
require.NoError(t, os.Chmod(outside, 0o700))
|
||||
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "lib")))
|
||||
root, err := os.OpenRoot(dir)
|
||||
require.NoError(t, err)
|
||||
defer root.Close()
|
||||
assert.ErrorContains(t, mkdirs(root, "lib/sub"), "not a directory")
|
||||
fi, err := os.Stat(outside)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o700), fi.Mode().Perm(), "symlink target was chmodded")
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// errNoProtocol is returned when the host sends anything: this build defines no
|
||||
// message protocol, so it accepts no work.
|
||||
var errNoProtocol = errors.New("host sent data, but this build accepts no invocations")
|
||||
|
||||
func start(ctx context.Context) int {
|
||||
code, err := defaultStartup().run(ctx)
|
||||
if err != nil {
|
||||
slog.Error("lambda-agent stopping", "exit", code, "err", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// run authenticates to the host and holds the connection until it closes.
|
||||
func (s startup) run(ctx context.Context) (int, error) {
|
||||
conn, _, code, err := s.connect(ctx)
|
||||
if err != nil {
|
||||
return code, err
|
||||
}
|
||||
defer conn.Close()
|
||||
slog.Info("control connection open; invocations are not enabled in this build", "host", conn.RemoteAddr().String())
|
||||
if err := awaitHostClose(ctx, conn); err != nil {
|
||||
return exitLink, err
|
||||
}
|
||||
return exitClosed, nil
|
||||
}
|
||||
|
||||
// awaitHostClose reads until the host closes the connection. Under TLS 1.3 a
|
||||
// host that rejects the guest certificate is only seen here, on first read.
|
||||
func awaitHostClose(ctx context.Context, conn *tls.Conn) error {
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
n, err := conn.Read(make([]byte, 1))
|
||||
switch {
|
||||
case n > 0:
|
||||
return errNoProtocol
|
||||
case errors.Is(err, io.EOF):
|
||||
return nil
|
||||
case ctx.Err() != nil:
|
||||
return fmt.Errorf("control connection: %w", context.Cause(ctx))
|
||||
default:
|
||||
return fmt.Errorf("control connection: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type result struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
|
||||
func runAsync(ctx context.Context, s startup) <-chan result {
|
||||
out := make(chan result, 1)
|
||||
go func() {
|
||||
code, err := s.run(ctx)
|
||||
out <- result{code, err}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStartupAuthenticatesAfterSealingFwCfg(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
g := newGuest(t, h.entries())
|
||||
done := runAsync(ctx, g.startup())
|
||||
|
||||
conn := h.guest(t)
|
||||
select {
|
||||
case <-g.sealedAt:
|
||||
default:
|
||||
t.Fatal("guest dialled before fw_cfg was sealed")
|
||||
}
|
||||
_, err := os.Stat(g.root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg still readable while connected")
|
||||
|
||||
require.NoError(t, conn.Close())
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitClosed, res.code)
|
||||
assert.NoError(t, res.err)
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
}
|
||||
|
||||
func TestStartupRefusesHostData(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
done := runAsync(ctx, newGuest(t, h.entries()).startup())
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
_, err := conn.Write([]byte{0})
|
||||
require.NoError(t, err)
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
assert.ErrorIs(t, res.err, errNoProtocol)
|
||||
}
|
||||
|
||||
// The host pins another generation's guest; the guest learns of the refusal
|
||||
// on its first read and fails with a link error, never a clean close.
|
||||
func TestStartupFailsWhenHostRefusesGuest(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
other, err := guestconn.NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
entries := h.entries()
|
||||
wrong := other.GuestEntries(h.ln.Addr().String())
|
||||
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
|
||||
g := newGuest(t, entries)
|
||||
|
||||
res := wait(t, runAsync(ctx, g.startup()), "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
require.Error(t, res.err)
|
||||
assert.Contains(t, res.err.Error(), "bad certificate")
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
select {
|
||||
case a := <-h.conn:
|
||||
require.Error(t, a.err, "host accepted a guest it does not pin")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupRefusesWithoutDialling(t *testing.T) {
|
||||
h := newHost(t)
|
||||
cases := map[string]struct {
|
||||
entries map[string][]byte
|
||||
unload func(g *guest) func() error
|
||||
want string
|
||||
}{
|
||||
"missing entry": {
|
||||
entries: func() map[string][]byte { e := h.entries(); delete(e, lambdav1.EntryHostCert); return e }(),
|
||||
want: "read host-cert",
|
||||
},
|
||||
"fw_cfg stays readable": {
|
||||
entries: h.entries(),
|
||||
unload: func(*guest) func() error { return func() error { return errors.New("module is built in") } },
|
||||
want: "fw_cfg still readable",
|
||||
},
|
||||
"unload claims success but tree remains": {
|
||||
entries: h.entries(),
|
||||
unload: func(*guest) func() error { return func() error { return nil } },
|
||||
want: "unload reported success",
|
||||
},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g := newGuest(t, tc.entries)
|
||||
s := g.startup()
|
||||
if tc.unload != nil {
|
||||
s.unload = tc.unload(g)
|
||||
}
|
||||
code, err := s.run(context.Background())
|
||||
assert.Equal(t, exitRefused, code)
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing dialled the host in any case above.
|
||||
require.NoError(t, h.ln.SetDeadline(time.Now().Add(50*time.Millisecond)))
|
||||
_, err := h.ln.Accept()
|
||||
var ne net.Error
|
||||
require.ErrorAs(t, err, &ne)
|
||||
assert.True(t, ne.Timeout())
|
||||
}
|
||||
|
||||
// A missing entry still seals fw_cfg: the key may be present even when
|
||||
// another entry is not.
|
||||
func TestStartupSealsEvenWhenIdentityIsIncomplete(t *testing.T) {
|
||||
h := newHost(t)
|
||||
e := h.entries()
|
||||
delete(e, lambdav1.EntryHostAddr)
|
||||
g := newGuest(t, e)
|
||||
code, err := g.startup().run(context.Background())
|
||||
assert.Equal(t, exitRefused, code)
|
||||
assert.ErrorContains(t, err, "read host-addr")
|
||||
assert.Equal(t, 1, g.unloads)
|
||||
_, statErr := os.Stat(g.root)
|
||||
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestStartupStopsWithContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
defer cancel(nil)
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
done := runAsync(ctx, newGuest(t, h.entries()).startup())
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
|
||||
reason := errors.New("guest shutting down")
|
||||
cancel(reason)
|
||||
res := wait(t, done, "startup to return")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
assert.ErrorIs(t, res.err, reason)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeAddr = "127.0.0.1:9001"
|
||||
taskRoot = "/var/task"
|
||||
)
|
||||
|
||||
// testlink relays one invocation over the test carrier on the authenticated
|
||||
// connection; the carrier is not the guest protocol.
|
||||
type testlink struct {
|
||||
startup
|
||||
pkg taskPackage
|
||||
runtimeAddr string
|
||||
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
|
||||
}
|
||||
|
||||
func start(ctx context.Context) int {
|
||||
t := testlink{
|
||||
startup: defaultStartup(),
|
||||
pkg: devTaskPackage(),
|
||||
runtimeAddr: runtimeAddr,
|
||||
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
|
||||
},
|
||||
}
|
||||
code, err := t.run(ctx)
|
||||
if err != nil {
|
||||
slog.Error("lambda-agent stopping", "exit", code, "err", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (t testlink) run(parent context.Context) (int, error) {
|
||||
// The package values are fw_cfg entries, so read them before connect seals it.
|
||||
spec, specErr := readPackageSpec(t.fwCfgDir)
|
||||
grant, grantErr := readGrant(t.fwCfgDir)
|
||||
conn, scope, code, err := t.connect(parent)
|
||||
if err != nil {
|
||||
return code, errors.Join(err, specErr, grantErr)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := errors.Join(specErr, grantErr); err != nil {
|
||||
return exitRefused, err
|
||||
}
|
||||
link := hostlink.NewTestStream(conn)
|
||||
|
||||
// The handshake can return before the host refuses the guest, so nothing
|
||||
// tenant-facing starts until the host's first valid frame arrives.
|
||||
first, err := link.Next(parent)
|
||||
if errors.Is(err, hostlink.ErrShutdown) {
|
||||
return exitClosed, nil
|
||||
}
|
||||
if err != nil {
|
||||
return exitLink, fmt.Errorf("first frame from host: %w", err)
|
||||
}
|
||||
if parent.Err() != nil {
|
||||
return exitLink, context.Cause(parent)
|
||||
}
|
||||
// Nothing of the tenant's is installed or run for an unbound request.
|
||||
if err := checkBinding(scope, grant, first, time.Now()); err != nil {
|
||||
return exitRefused, err
|
||||
}
|
||||
// A host that sent no credential is refused before anything is installed.
|
||||
if first.Credential == nil || !first.Credential.Complete() {
|
||||
return exitRefused, errors.New("first frame carries no execution credential")
|
||||
}
|
||||
cred := *first.Credential
|
||||
first.Credential = nil
|
||||
if err := t.pkg.install(spec); err != nil {
|
||||
return exitRefused, fmt.Errorf("install package: %w", err)
|
||||
}
|
||||
|
||||
srv := runtimeapi.New()
|
||||
ln, err := net.Listen("tcp", t.runtimeAddr)
|
||||
if err != nil {
|
||||
return exitLink, fmt.Errorf("listen runtime api: %w", err)
|
||||
}
|
||||
hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { _ = hs.Serve(ln) }()
|
||||
defer hs.Close()
|
||||
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
defer cancel(nil)
|
||||
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred))
|
||||
if err != nil {
|
||||
return exitLink, fmt.Errorf("start bootstrap: %w", err)
|
||||
}
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
err := cmd.Wait()
|
||||
// Once a response is being received the result stands, so only an
|
||||
// exit before that cancels the invocation.
|
||||
if srv.BootstrapExited() {
|
||||
cancel(fmt.Errorf("bootstrap exited: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
err = agent.Run(ctx, &oneInvocation{Link: link, first: &first}, srv)
|
||||
cancel(errors.New("agent stopping"))
|
||||
<-exited
|
||||
if err != nil {
|
||||
return exitLink, err
|
||||
}
|
||||
return exitClosed, nil
|
||||
}
|
||||
|
||||
// bootstrapEnv is the bootstrap's whole environment: the runtime variables and
|
||||
// the execution role's three credential variables.
|
||||
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential) []string {
|
||||
return []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + runtimeAPI,
|
||||
"_HANDLER=" + handler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID=" + cred.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + cred.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + cred.SessionToken,
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownGrace is how long past the invocation deadline the agent waits for
|
||||
// the host's shutdown frame once its result is written.
|
||||
const shutdownGrace = 2 * time.Second
|
||||
|
||||
// oneInvocation serves the host's first request and then stops the agent:
|
||||
// each test VM runs a single invocation. After the result is written it waits
|
||||
// for the host's shutdown frame or EOF, so the result is not cut off by the
|
||||
// poweroff that follows the agent's exit.
|
||||
type oneInvocation struct {
|
||||
hostlink.Link
|
||||
first *hostlink.Request
|
||||
deadlineMs int64
|
||||
}
|
||||
|
||||
var _ hostlink.Link = (*oneInvocation)(nil)
|
||||
|
||||
func (o *oneInvocation) Next(ctx context.Context) (hostlink.Request, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return hostlink.Request{}, err
|
||||
}
|
||||
if o.first != nil {
|
||||
req := *o.first
|
||||
o.first = nil
|
||||
o.deadlineMs = req.DeadlineMs
|
||||
return req, nil
|
||||
}
|
||||
return hostlink.Request{}, o.awaitShutdown(ctx)
|
||||
}
|
||||
|
||||
// awaitShutdown reads the host's next frame and always ends the agent: with
|
||||
// ErrShutdown for a shutdown frame, EOF, a stray frame or an expired wait.
|
||||
func (o *oneInvocation) awaitShutdown(ctx context.Context) error {
|
||||
end := time.UnixMilli(o.deadlineMs)
|
||||
if now := time.Now(); end.Before(now) {
|
||||
end = now
|
||||
}
|
||||
wctx, cancel := context.WithDeadline(ctx, end.Add(shutdownGrace))
|
||||
defer cancel()
|
||||
_, err := o.Link.Next(wctx)
|
||||
switch {
|
||||
case errors.Is(err, hostlink.ErrShutdown), errors.Is(err, io.EOF):
|
||||
return hostlink.ErrShutdown
|
||||
case err == nil:
|
||||
slog.Warn("lambda-agent: frame after the invocation was not dispatched")
|
||||
return hostlink.ErrShutdown
|
||||
case ctx.Err() != nil:
|
||||
return err
|
||||
case wctx.Err() != nil:
|
||||
slog.Warn("lambda-agent: no shutdown from the host; exiting")
|
||||
return hostlink.ErrShutdown
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const helperEnv = "LAMBDA_AGENT_TEST_BOOTSTRAP"
|
||||
|
||||
// helperHandlerOut names the file the helper bootstrap writes its _HANDLER to.
|
||||
const helperHandlerOut = "LAMBDA_AGENT_TEST_HANDLER_OUT"
|
||||
|
||||
// TestHelperBootstrap is the fake function bootstrap, run as a subprocess: it
|
||||
// echoes one invocation through the Runtime API, then waits for the next.
|
||||
func TestHelperBootstrap(t *testing.T) {
|
||||
if os.Getenv(helperEnv) != "1" {
|
||||
return
|
||||
}
|
||||
if out := os.Getenv(helperHandlerOut); out != "" {
|
||||
require.NoError(t, os.WriteFile(out, []byte(os.Getenv("_HANDLER")), 0o600))
|
||||
}
|
||||
base := "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API") + "/2018-06-01/runtime/invocation/"
|
||||
resp, err := http.Get(base + "next")
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
id := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
|
||||
post, err := http.Post(base+id+"/response", "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
post.Body.Close()
|
||||
if os.Getenv(helperExitAfterPost) == "1" {
|
||||
return
|
||||
}
|
||||
_, _ = http.Get(base + "next")
|
||||
}
|
||||
|
||||
// helperExitAfterPost makes the helper bootstrap exit once its response is posted.
|
||||
const helperExitAfterPost = "LAMBDA_AGENT_TEST_EXIT_AFTER_POST"
|
||||
|
||||
// testGrantEntry grants g-1 for attempt a-1 on the fixture scope's env-1/1.
|
||||
const testGrantEntry = "g-1\na-1\nenv-1\n1\n"
|
||||
|
||||
// testHandler is the handler the fixture's host configures.
|
||||
const testHandler = "bootstrap.fixture"
|
||||
|
||||
type bootstrapStarts struct {
|
||||
n int
|
||||
started chan struct{}
|
||||
task string
|
||||
sealed bool
|
||||
// handlerOut receives the _HANDLER the bootstrap process itself saw.
|
||||
handlerOut string
|
||||
// env is the environment the agent gave the bootstrap.
|
||||
env []string
|
||||
}
|
||||
|
||||
// testlink builds a guest whose fw_cfg also carries a controlled package,
|
||||
// installed into a temp dir without mounting anything.
|
||||
func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) testlink {
|
||||
t.Helper()
|
||||
zipBytes := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
|
||||
disk := filepath.Join(t.TempDir(), "disk")
|
||||
require.NoError(t, os.WriteFile(disk, zipBytes, 0o600))
|
||||
sum := sha256.Sum256(zipBytes)
|
||||
entries[entryPackageSize] = []byte(strconv.Itoa(len(zipBytes)))
|
||||
entries[entryPackageSHA256] = []byte(hex.EncodeToString(sum[:]))
|
||||
if _, ok := entries[entryHandler]; !ok {
|
||||
entries[entryHandler] = []byte(testHandler)
|
||||
}
|
||||
if _, ok := entries[entryGrant]; !ok {
|
||||
entries[entryGrant] = []byte(testGrantEntry)
|
||||
}
|
||||
g := newGuest(t, entries)
|
||||
b.started = make(chan struct{}, 1)
|
||||
b.task = t.TempDir()
|
||||
b.handlerOut = filepath.Join(t.TempDir(), "handler")
|
||||
return testlink{
|
||||
startup: g.startup(),
|
||||
pkg: taskPackage{
|
||||
device: disk, dir: b.task,
|
||||
loadDriver: func() error { return nil },
|
||||
mountDir: func(string) error { return nil },
|
||||
sealDir: func(string) error { b.sealed = true; return nil },
|
||||
},
|
||||
runtimeAddr: "127.0.0.1:0",
|
||||
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
b.n++
|
||||
b.env = env
|
||||
b.started <- struct{}{}
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestHelperBootstrap$")
|
||||
cmd.Env = append(env, helperEnv+"=1", helperHandlerOut+"="+b.handlerOut)
|
||||
return cmd, cmd.Start()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runTestlinkAsync(ctx context.Context, tl testlink) <-chan result {
|
||||
out := make(chan result, 1)
|
||||
go func() {
|
||||
code, err := tl.run(ctx)
|
||||
out <- result{code, err}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
type result struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
|
||||
func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
select {
|
||||
case <-b.started:
|
||||
t.Fatal("bootstrap started before the host sent a frame")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{"a":40,"b":2}`)}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
got, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hostlink.Response{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", Payload: req.Payload}, got)
|
||||
|
||||
// A second invocation on the same VM is never served. The agent may
|
||||
// already have closed the connection, so a failed send is a refusal too.
|
||||
second := req
|
||||
second.RequestID = "r-2"
|
||||
sendErr := hs.HostSend(second)
|
||||
res := wait(t, done, "agent to stop after one invocation")
|
||||
assert.Equal(t, exitClosed, res.code)
|
||||
assert.NoError(t, res.err)
|
||||
if sendErr == nil {
|
||||
_, err = hs.HostResult(ctx)
|
||||
assert.Error(t, err, "agent answered a second invocation")
|
||||
}
|
||||
assert.Equal(t, 1, b.n)
|
||||
seen, err := os.ReadFile(b.handlerOut)
|
||||
require.NoError(t, err, "bootstrap process did not report its handler")
|
||||
assert.Equal(t, testHandler, string(seen), "bootstrap process not given the configured handler")
|
||||
assert.True(t, b.sealed, "task dir not made read-only")
|
||||
assert.FileExists(t, filepath.Join(b.task, "lib", "data.txt"))
|
||||
assert.ElementsMatch(t, []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + envValue(b.env, "AWS_LAMBDA_RUNTIME_API"),
|
||||
"_HANDLER=" + testHandler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID=" + testCredential.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + testCredential.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + testCredential.SessionToken,
|
||||
}, b.env, "the bootstrap gets exactly the runtime and credential variables")
|
||||
assert.NotEmpty(t, envValue(b.env, "AWS_LAMBDA_RUNTIME_API"))
|
||||
}
|
||||
|
||||
var testCredential = hostlink.Credential{AccessKeyID: "ASIAGUESTKEY", SecretAccessKey: "guest-secret", SessionToken: "guest-token"}
|
||||
|
||||
func envValue(env []string, name string) string {
|
||||
for _, kv := range env {
|
||||
if v, ok := strings.CutPrefix(kv, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A first frame without a complete credential installs and runs nothing.
|
||||
func TestTestlinkFrameWithoutCredentialNeverStartsBootstrap(t *testing.T) {
|
||||
for name, cred := range map[string]*hostlink.Credential{
|
||||
"none": nil,
|
||||
"incomplete": {AccessKeyID: "ASIAGUESTKEY"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{Credential: cred, GrantID: "g-1", AttemptID: "a-1",
|
||||
RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}))
|
||||
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, "no execution credential")
|
||||
assert.Zero(t, b.n)
|
||||
assert.False(t, b.sealed)
|
||||
assert.NoFileExists(t, filepath.Join(b.task, "bootstrap"), "package installed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A bootstrap that posts its response and exits at once does not cost the
|
||||
// host the result; the agent then waits for the host's shutdown.
|
||||
func TestTestlinkBootstrapExitingAfterResponseStillDelivers(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
tl := b.testlink(t, h.entries())
|
||||
start := tl.startBootstrap
|
||||
tl.startBootstrap = func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
return start(ctx, append(env, helperExitAfterPost+"=1"))
|
||||
}
|
||||
done := runTestlinkAsync(ctx, tl)
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
payload := bytes.Repeat([]byte("0123456789"), 300_000)
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: payload}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
got, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, hostlink.Response{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", Payload: payload}, got)
|
||||
|
||||
require.NoError(t, hs.HostShutdown())
|
||||
res := wait(t, done, "agent to stop after the host's shutdown")
|
||||
assert.Equal(t, exitClosed, res.code)
|
||||
assert.NoError(t, res.err)
|
||||
}
|
||||
|
||||
// The agent stays up for the host's shutdown rather than exiting as soon as
|
||||
// its result is written, and never dispatches a stray frame.
|
||||
func TestOneInvocationAfterResult(t *testing.T) {
|
||||
first := hostlink.Request{RequestID: "r-1", DeadlineMs: time.Now().Add(time.Minute).UnixMilli()}
|
||||
cases := map[string]struct {
|
||||
next func(context.Context) (hostlink.Request, error)
|
||||
want error
|
||||
}{
|
||||
"shutdown frame": {func(context.Context) (hostlink.Request, error) { return hostlink.Request{}, hostlink.ErrShutdown }, hostlink.ErrShutdown},
|
||||
"eof": {func(context.Context) (hostlink.Request, error) { return hostlink.Request{}, io.EOF }, hostlink.ErrShutdown},
|
||||
"a second request is not dispatched": {func(context.Context) (hostlink.Request, error) {
|
||||
return hostlink.Request{RequestID: "r-2"}, nil
|
||||
}, hostlink.ErrShutdown},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
o := &oneInvocation{Link: stubLink{tc.next}, first: &first}
|
||||
_, err := o.Next(context.Background())
|
||||
require.NoError(t, err)
|
||||
req, err := o.Next(context.Background())
|
||||
require.ErrorIs(t, err, tc.want)
|
||||
assert.Empty(t, req.RequestID)
|
||||
})
|
||||
}
|
||||
t.Run("host never sends shutdown", func(t *testing.T) {
|
||||
past := first
|
||||
past.DeadlineMs = time.Now().Add(-time.Second).UnixMilli()
|
||||
o := &oneInvocation{Link: stubLink{func(ctx context.Context) (hostlink.Request, error) {
|
||||
<-ctx.Done()
|
||||
return hostlink.Request{}, ctx.Err()
|
||||
}}, first: &past}
|
||||
_, err := o.Next(context.Background())
|
||||
require.NoError(t, err)
|
||||
start := time.Now()
|
||||
_, err = o.Next(context.Background())
|
||||
require.ErrorIs(t, err, hostlink.ErrShutdown)
|
||||
assert.GreaterOrEqual(t, time.Since(start), shutdownGrace-100*time.Millisecond, "waited the grace")
|
||||
})
|
||||
t.Run("parent cancel is not a shutdown", func(t *testing.T) {
|
||||
o := &oneInvocation{Link: stubLink{func(ctx context.Context) (hostlink.Request, error) {
|
||||
<-ctx.Done()
|
||||
return hostlink.Request{}, ctx.Err()
|
||||
}}, first: &first}
|
||||
_, _ = o.Next(context.Background())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := o.Next(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
}
|
||||
|
||||
type stubLink struct {
|
||||
next func(context.Context) (hostlink.Request, error)
|
||||
}
|
||||
|
||||
var _ hostlink.Link = stubLink{}
|
||||
|
||||
func (l stubLink) Next(ctx context.Context) (hostlink.Request, error) { return l.next(ctx) }
|
||||
|
||||
func (stubLink) Complete(context.Context, hostlink.Response) error { return nil }
|
||||
|
||||
func TestTestlinkShutdownBeforeInvocationNeverStartsBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostShutdown())
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitClosed, res.code)
|
||||
assert.NoError(t, res.err)
|
||||
assert.Zero(t, b.n)
|
||||
}
|
||||
|
||||
// The guest's handshake completes, but the host pins another generation's
|
||||
// guest; the refusal arrives on first read and the bootstrap never starts.
|
||||
func TestTestlinkHostRefusalNeverStartsBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
other, err := guestconn.NewGeneration(scope, time.Hour)
|
||||
require.NoError(t, err)
|
||||
entries := h.entries()
|
||||
wrong := other.GuestEntries(h.ln.Addr().String())
|
||||
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
|
||||
var b bootstrapStarts
|
||||
|
||||
res := wait(t, runTestlinkAsync(ctx, b.testlink(t, entries)), "agent to stop")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
require.Error(t, res.err)
|
||||
assert.Contains(t, res.err.Error(), "bad certificate")
|
||||
assert.Zero(t, b.n)
|
||||
}
|
||||
|
||||
func TestTestlinkHostCloseBeforeFrameNeverStartsBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
|
||||
require.NoError(t, h.guest(t).Close())
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitLink, res.code)
|
||||
assert.ErrorContains(t, res.err, "first frame from host")
|
||||
assert.Zero(t, b.n)
|
||||
}
|
||||
|
||||
// A cancelled context is reported before the buffered request is delivered,
|
||||
// and the request stays undelivered.
|
||||
func TestOneInvocationHonoursCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
o := &oneInvocation{first: &hostlink.Request{RequestID: "r-1"}}
|
||||
_, err := o.Next(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
req, err := o.Next(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "r-1", req.RequestID)
|
||||
}
|
||||
|
||||
// A package that fails its check is refused after authentication, and the
|
||||
// bootstrap never starts.
|
||||
func TestTestlinkBadPackageNeverStartsBootstrap(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
tl := b.testlink(t, h.entries())
|
||||
tl.pkg.device = filepath.Join(t.TempDir(), "other")
|
||||
require.NoError(t, os.WriteFile(tl.pkg.device, testZip(t, map[string]string{"bootstrap": "x"}), 0o600))
|
||||
done := runTestlinkAsync(ctx, tl)
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1",
|
||||
DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli()}))
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, "install package")
|
||||
assert.Zero(t, b.n)
|
||||
assert.False(t, b.sealed)
|
||||
}
|
||||
|
||||
// Missing package values still seal fw_cfg, and the agent refuses before
|
||||
// waiting for any frame.
|
||||
func TestTestlinkMissingPackageSpecRefuses(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
tl := b.testlink(t, h.entries())
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(tl.fwCfgDir, entryPackageSHA256)))
|
||||
|
||||
res := wait(t, runTestlinkAsync(ctx, tl), "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, "read "+entryPackageSHA256)
|
||||
assert.Zero(t, b.n)
|
||||
_, err := os.Stat(tl.fwCfgRoot)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg not sealed")
|
||||
}
|
||||
|
||||
// A request that does not carry this VM's grant, or a grant for another
|
||||
// environment generation than the one authenticated, installs and runs
|
||||
// nothing of the tenant's.
|
||||
func TestTestlinkUnboundRequestNeverStartsBootstrap(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
grant string
|
||||
grantID, attempt string
|
||||
deadlineMs int64
|
||||
want string
|
||||
}{
|
||||
"frame with no deadline": {testGrantEntry, "g-1", "a-1", -1, "no deadline"},
|
||||
"frame with expired deadline": {testGrantEntry, "g-1", "a-1", time.Now().Add(-time.Second).UnixMilli(), "deadline already passed"},
|
||||
"frame for another grant": {testGrantEntry, "g-2", "a-1", 0, "does not carry this VM's grant"},
|
||||
"frame for another attempt": {testGrantEntry, "g-1", "a-2", 0, "does not carry this VM's grant"},
|
||||
"frame with no grant": {testGrantEntry, "", "", 0, "does not carry this VM's grant"},
|
||||
"grant for another env": {"g-1\na-1\nenv-2\n1\n", "g-1", "a-1", 0, "not for the authenticated environment"},
|
||||
"grant for another gen": {"g-1\na-1\nenv-1\n2\n", "g-1", "a-1", 0, "not for the authenticated environment"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
entries := h.entries()
|
||||
entries[entryGrant] = []byte(tc.grant)
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, entries))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
deadline := time.Now().Add(5 * time.Second).UnixMilli()
|
||||
switch {
|
||||
case tc.deadlineMs < 0:
|
||||
deadline = 0
|
||||
case tc.deadlineMs > 0:
|
||||
deadline = tc.deadlineMs
|
||||
}
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: tc.grantID, AttemptID: tc.attempt, RequestID: "r-1",
|
||||
DeadlineMs: deadline, Payload: []byte(`{}`)}
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(req))
|
||||
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, tc.want)
|
||||
assert.Zero(t, b.n)
|
||||
assert.False(t, b.sealed)
|
||||
assert.NoFileExists(t, filepath.Join(b.task, "bootstrap"), "package installed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing or malformed grant entry refuses before any frame is awaited.
|
||||
func TestTestlinkBadGrantEntryRefuses(t *testing.T) {
|
||||
cases := map[string]*string{
|
||||
"missing": nil,
|
||||
"too few lines": ptr("g-1\na-1\nenv-1\n"),
|
||||
"generation 0": ptr("g-1\na-1\nenv-1\n0\n"),
|
||||
"empty grant": ptr("\na-1\nenv-1\n1\n"),
|
||||
}
|
||||
for name, grant := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
entries := h.entries()
|
||||
if grant != nil {
|
||||
entries[entryGrant] = []byte(*grant)
|
||||
}
|
||||
tl := b.testlink(t, entries)
|
||||
if grant == nil {
|
||||
require.NoError(t, os.RemoveAll(filepath.Join(tl.fwCfgDir, entryGrant)))
|
||||
}
|
||||
res := wait(t, runTestlinkAsync(ctx, tl), "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, entryGrant)
|
||||
assert.Zero(t, b.n)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
@@ -1,224 +0,0 @@
|
||||
// Command lambda-image-agent-tag checks whether the lambda-agent archived in a
|
||||
// built Lambda guest initramfs carries the lambdatestlink build tag. Build
|
||||
// information is recorded by the build; it is not proof of how the agent behaves.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"debug/buildinfo"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
agentPath = "usr/local/bin/lambda-agent"
|
||||
maxArchive = 1 << 30
|
||||
maxAgent = 256 << 20
|
||||
maxName = 4096
|
||||
newcHeader = 110
|
||||
trailerName = "TRAILER!!!"
|
||||
modeTypeMask = 0o170000
|
||||
modeDir = 0o040000
|
||||
modeRegular = 0o100000
|
||||
)
|
||||
|
||||
var agentParents = []string{"usr", "usr/local", "usr/local/bin"}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 || (os.Args[2] != "present" && os.Args[2] != "absent") {
|
||||
fmt.Fprintln(os.Stderr, "usage: lambda-image-agent-tag <initramfs.cpio.gz> present|absent")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Args[1], os.Args[2] == "present"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[1], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(path string, want bool) error {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return errors.New("not a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
agent, err := archivedAgent(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkTag(agent, want)
|
||||
}
|
||||
|
||||
// archivedAgent returns the single agent entry after checking listed names,
|
||||
// entry types, the agent's link count and zero-only padding after the trailer.
|
||||
// It is not a full kernel-equivalent parser; 070702 CRCs, for one, are unchecked.
|
||||
func archivedAgent(r io.Reader) ([]byte, error) {
|
||||
zr, err := gzip.NewReader(bufio.NewReader(r))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a readable gzip archive: %w", err)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(zr, maxArchive+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a readable gzip archive: %w", err)
|
||||
}
|
||||
if len(raw) > maxArchive {
|
||||
return nil, fmt.Errorf("archive larger than %d bytes", maxArchive)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var agent []byte
|
||||
off := 0
|
||||
for {
|
||||
e, next, err := parseEntry(raw, off)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entry at offset %d: %w", off, err)
|
||||
}
|
||||
off = next
|
||||
if e.name == trailerName {
|
||||
break
|
||||
}
|
||||
if !canonical(e.name) {
|
||||
return nil, fmt.Errorf("entry name %q is not canonical", e.name)
|
||||
}
|
||||
if seen[e.name] {
|
||||
return nil, fmt.Errorf("entry %q appears more than once", e.name)
|
||||
}
|
||||
seen[e.name] = true
|
||||
for _, p := range agentParents {
|
||||
if e.name == p && e.mode&modeTypeMask != modeDir {
|
||||
return nil, fmt.Errorf("%s is not a directory", p)
|
||||
}
|
||||
}
|
||||
if e.name == agentPath {
|
||||
for _, p := range agentParents {
|
||||
if !seen[p] {
|
||||
return nil, fmt.Errorf("%s has no %s directory entry before it", agentPath, p)
|
||||
}
|
||||
}
|
||||
if e.mode&modeTypeMask != modeRegular {
|
||||
return nil, fmt.Errorf("%s is not a regular file", agentPath)
|
||||
}
|
||||
if e.nlink != 1 {
|
||||
return nil, fmt.Errorf("%s has %d links, want 1", agentPath, e.nlink)
|
||||
}
|
||||
if len(e.data) > maxAgent {
|
||||
return nil, fmt.Errorf("%s is larger than %d bytes", agentPath, maxAgent)
|
||||
}
|
||||
agent = e.data
|
||||
}
|
||||
}
|
||||
if i := bytes.IndexFunc(raw[off:], func(r rune) bool { return r != 0 }); i >= 0 {
|
||||
return nil, fmt.Errorf("data after the archive trailer at offset %d", off+i)
|
||||
}
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("no %s entry", agentPath)
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
mode uint64
|
||||
nlink uint64
|
||||
data []byte
|
||||
}
|
||||
|
||||
func parseEntry(raw []byte, off int) (entry, int, error) {
|
||||
if len(raw)-off < newcHeader {
|
||||
return entry{}, 0, errors.New("truncated header")
|
||||
}
|
||||
h := raw[off : off+newcHeader]
|
||||
if m := string(h[:6]); m != "070701" && m != "070702" {
|
||||
return entry{}, 0, fmt.Errorf("magic %q is not newc", m)
|
||||
}
|
||||
field := func(i int) (uint64, error) {
|
||||
return strconv.ParseUint(string(h[6+8*i:14+8*i]), 16, 32)
|
||||
}
|
||||
mode, err1 := field(1)
|
||||
nlink, err2 := field(4)
|
||||
size, err3 := field(6)
|
||||
nameSize, err4 := field(11)
|
||||
if err := errors.Join(err1, err2, err3, err4); err != nil {
|
||||
return entry{}, 0, fmt.Errorf("bad header field: %w", err)
|
||||
}
|
||||
if nameSize < 2 || nameSize > maxName {
|
||||
return entry{}, 0, fmt.Errorf("name size %d out of range", nameSize)
|
||||
}
|
||||
nameEnd := off + newcHeader + int(nameSize)
|
||||
dataOff := align4(nameEnd)
|
||||
if dataOff > len(raw) || size > uint64(len(raw)-dataOff) {
|
||||
return entry{}, 0, errors.New("truncated entry")
|
||||
}
|
||||
dataEnd := dataOff + int(size)
|
||||
if dataEnd > len(raw) || align4(dataEnd) > len(raw) {
|
||||
return entry{}, 0, errors.New("truncated entry")
|
||||
}
|
||||
name := raw[off+newcHeader : nameEnd]
|
||||
if name[len(name)-1] != 0 || bytes.IndexByte(name[:len(name)-1], 0) >= 0 {
|
||||
return entry{}, 0, errors.New("name is not one NUL-terminated string")
|
||||
}
|
||||
return entry{name: string(name[:len(name)-1]), mode: mode, nlink: nlink, data: raw[dataOff:dataEnd]}, align4(dataEnd), nil
|
||||
}
|
||||
|
||||
func align4(n int) int { return (n + 3) &^ 3 }
|
||||
|
||||
// canonical accepts "." and relative names with no empty, "." or ".." component.
|
||||
func canonical(name string) bool {
|
||||
if name == "." {
|
||||
return true
|
||||
}
|
||||
if name == "" || strings.HasPrefix(name, "/") {
|
||||
return false
|
||||
}
|
||||
for c := range strings.SplitSeq(name, "/") {
|
||||
if c == "" || c == "." || c == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func checkTag(agent []byte, want bool) error {
|
||||
info, err := buildinfo.Read(bytes.NewReader(agent))
|
||||
if err != nil {
|
||||
return fmt.Errorf("no Go build information in %s: %w", agentPath, err)
|
||||
}
|
||||
return decide(info, want)
|
||||
}
|
||||
|
||||
// decide refuses build information with no recorded settings, since their
|
||||
// absence does not show the tag was absent.
|
||||
func decide(info *buildinfo.BuildInfo, want bool) error {
|
||||
if len(info.Settings) == 0 {
|
||||
return fmt.Errorf("%s records no build settings", agentPath)
|
||||
}
|
||||
if hasTag(info, "lambdatestlink") != want {
|
||||
return fmt.Errorf("%s: lambdatestlink build tag present=%v, want %v", agentPath, !want, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasTag(info *buildinfo.BuildInfo, tag string) bool {
|
||||
for _, s := range info.Settings {
|
||||
if s.Key != "-tags" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(strings.Split(s.Value, ","), tag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"debug/buildinfo"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fileEntry struct {
|
||||
name string
|
||||
mode uint32
|
||||
nlink uint32
|
||||
data string
|
||||
}
|
||||
|
||||
func dir(name string) fileEntry { return fileEntry{name: name, mode: modeDir | 0o755, nlink: 2} }
|
||||
func file(name, data string) fileEntry {
|
||||
return fileEntry{name: name, mode: modeRegular | 0o755, nlink: 1, data: data}
|
||||
}
|
||||
func symlink(name, target string) fileEntry {
|
||||
return fileEntry{name: name, mode: 0o120777, nlink: 1, data: target}
|
||||
}
|
||||
|
||||
// newc writes entries and a trailer in the format the kernel unpacks.
|
||||
func newc(entries ...fileEntry) []byte {
|
||||
var b bytes.Buffer
|
||||
for i, e := range append(entries, fileEntry{name: trailerName, nlink: 1}) {
|
||||
fmt.Fprintf(&b, "070701%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X",
|
||||
i+1, e.mode, 0, 0, e.nlink, 0, len(e.data), 0, 0, 0, 0, len(e.name)+1, 0)
|
||||
b.WriteString(e.name)
|
||||
b.WriteByte(0)
|
||||
pad(&b)
|
||||
b.WriteString(e.data)
|
||||
pad(&b)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func pad(b *bytes.Buffer) {
|
||||
for b.Len()%4 != 0 {
|
||||
b.WriteByte(0)
|
||||
}
|
||||
}
|
||||
|
||||
func gz(parts ...[]byte) []byte {
|
||||
var b bytes.Buffer
|
||||
for _, p := range parts {
|
||||
w := gzip.NewWriter(&b)
|
||||
_, _ = w.Write(p)
|
||||
_ = w.Close()
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func withAgent(agent fileEntry) []byte {
|
||||
return newc(dir("."), dir("usr"), dir("usr/local"), dir("usr/local/bin"), agent, file("init", "#!/bin/sh"))
|
||||
}
|
||||
|
||||
func TestArchivedAgent_ReturnsTheOneArchivedAgent(t *testing.T) {
|
||||
got, err := archivedAgent(bytes.NewReader(gz(withAgent(file(agentPath, "agent-bytes")))))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "agent-bytes", string(got))
|
||||
|
||||
padded := append(withAgent(file(agentPath, "agent-bytes")), make([]byte, 1024)...)
|
||||
got, err = archivedAgent(bytes.NewReader(gz(padded)))
|
||||
require.NoError(t, err, "zero padding after the trailer")
|
||||
require.Equal(t, "agent-bytes", string(got))
|
||||
}
|
||||
|
||||
func TestArchivedAgent_Refuses(t *testing.T) {
|
||||
parents := []fileEntry{dir("."), dir("usr"), dir("usr/local"), dir("usr/local/bin")}
|
||||
agent := file(agentPath, "untagged")
|
||||
small := newc(append(parents, agent)...)
|
||||
second := newc(append(parents[1:], file(agentPath, "tagged"))...)
|
||||
// The second archive starts inside what would be the first one's final block.
|
||||
sameBlock := append(append([]byte{}, small...), second...)
|
||||
for len(sameBlock)%512 != 0 {
|
||||
sameBlock = append(sameBlock, 0)
|
||||
}
|
||||
|
||||
for name, archive := range map[string][]byte{
|
||||
"aliased duplicate": gz(newc(append(parents, agent, file("././"+agentPath, "tagged"))...)),
|
||||
"leading slash alias": gz(newc(append(parents, agent, file("/"+agentPath, "tagged"))...)),
|
||||
"dot-dot component": gz(newc(append(parents, agent, file("usr/local/../local/bin/lambda-agent", "x"))...)),
|
||||
"empty component": gz(newc(append(parents, agent, file("usr//local/bin/lambda-agent", "x"))...)),
|
||||
"exact duplicate": gz(newc(append(parents, agent, file(agentPath, "tagged"))...)),
|
||||
"duplicate directory": gz(newc(dir("usr"), dir("usr"), dir("usr/local"), dir("usr/local/bin"), agent)),
|
||||
"missing directory entry": gz(newc(dir("usr"), dir("usr/local/bin"), agent)),
|
||||
"directory after the agent": gz(newc(dir("usr"), dir("usr/local"), agent, dir("usr/local/bin"))),
|
||||
"parent is a symlink": gz(newc(dir("usr"), dir("usr/local"), symlink("usr/local/bin", "elsewhere"), agent)),
|
||||
"agent is a symlink": gz(newc(append(parents, symlink(agentPath, "/bin/sh"))...)),
|
||||
"hard-linked agent": gz(newc(append(parents, fileEntry{name: agentPath, mode: modeRegular | 0o755, nlink: 2, data: "x"})...)),
|
||||
"no agent": gz(newc(parents...)),
|
||||
"second archive appended": gz(small, second),
|
||||
"second archive in same block": gz(sameBlock),
|
||||
"non-zero byte after trailer": gz(append(append([]byte{}, small...), 0, 0, 1)),
|
||||
"truncated archive": gz(small[:len(small)-20]),
|
||||
"not newc": gz([]byte("not cpio at all, but long enough to fill one newc header: " + string(make([]byte, 120)))),
|
||||
"not gzip": small,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := archivedAgent(bytes.NewReader(archive))
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RefusesASymlinkedImagePath(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
img := filepath.Join(d, "initramfs.cpio.gz")
|
||||
require.NoError(t, os.WriteFile(img, gz(withAgent(file(agentPath, "x"))), 0o644))
|
||||
require.NoError(t, os.Symlink(img, filepath.Join(d, "link")))
|
||||
require.ErrorContains(t, run(filepath.Join(d, "link"), false), "not a regular file")
|
||||
}
|
||||
|
||||
func TestHasTag_MatchesWholeTagsOnly(t *testing.T) {
|
||||
info := func(tags string) *buildinfo.BuildInfo {
|
||||
return &buildinfo.BuildInfo{Settings: []debug.BuildSetting{{Key: "-buildmode", Value: "exe"}, {Key: "-tags", Value: tags}}}
|
||||
}
|
||||
require.True(t, hasTag(info("lambdatestlink,fips140v1.0"), "lambdatestlink"))
|
||||
require.False(t, hasTag(info("fips140v1.0"), "lambdatestlink"))
|
||||
require.False(t, hasTag(info("lambdatestlinkx,xlambdatestlink"), "lambdatestlink"))
|
||||
require.False(t, hasTag(&buildinfo.BuildInfo{}, "lambdatestlink"))
|
||||
}
|
||||
|
||||
// The test binary carries real build information, so checkTag reads it and
|
||||
// refuses the opposite answer; bytes with no build information are refused.
|
||||
func TestCheckTag_ReadsRealBuildInformation(t *testing.T) {
|
||||
exe, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
raw, err := os.ReadFile(exe)
|
||||
require.NoError(t, err)
|
||||
info, err := buildinfo.Read(bytes.NewReader(raw))
|
||||
require.NoError(t, err)
|
||||
tagged := hasTag(info, "lambdatestlink")
|
||||
require.NoError(t, checkTag(raw, tagged))
|
||||
require.Error(t, checkTag(raw, !tagged))
|
||||
require.Error(t, checkTag([]byte("not a Go binary"), false))
|
||||
}
|
||||
|
||||
func TestDecide_RefusesMissingBuildSettings(t *testing.T) {
|
||||
require.ErrorContains(t, decide(&buildinfo.BuildInfo{}, false), "no build settings")
|
||||
require.ErrorContains(t, decide(&buildinfo.BuildInfo{}, true), "no build settings")
|
||||
untagged := &buildinfo.BuildInfo{Settings: []debug.BuildSetting{{Key: "-buildmode", Value: "exe"}}}
|
||||
require.NoError(t, decide(untagged, false))
|
||||
require.Error(t, decide(untagged, true))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
// Command lambda-image-digest prints the test-link runtime image digest of one
|
||||
// directory, selecting the boot files the same way the daemon does. The digest
|
||||
// identifies the bytes; it does not authenticate where they came from.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/testlink"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: lambda-image-digest <image-dir>")
|
||||
os.Exit(2)
|
||||
}
|
||||
digest, err := testlink.DirDigest(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(digest)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// sqs-worker runs inside the SQS microVM, next to its own SQS NATS server.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// sqs-worker boot
|
||||
// sqs-worker ready
|
||||
//
|
||||
// boot selects and mounts the guest's data disk named by fw_cfg, asks the
|
||||
// gateway for its receipt key decision with its instance-role credentials,
|
||||
// then loads the recorded key, or creates one on a new volume and records its
|
||||
// fingerprint, before the guest's SQS store first starts on that disk. ready
|
||||
// then keeps checking the guest's own loopback JetStream, keeping a readiness
|
||||
// marker only while it answers, and serves SQS owner calls against it, pulling
|
||||
// them from the gateway with the same credentials.
|
||||
//
|
||||
// Serving is refused unless the gateway reports a single-node host cluster.
|
||||
// A disk whose receipt key is missing or does not match the recorded one
|
||||
// refuses to serve; recovering it needs a procedure not yet designed, and
|
||||
// nothing deletes or replaces the volume.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/sqsworkergw"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerboot"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerserve"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
|
||||
)
|
||||
|
||||
// The cluster CA the daemon supplies; the only root the client trusts.
|
||||
const caPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw"
|
||||
|
||||
// newClaimer signs with the IMDS instance-role credentials only; there is
|
||||
// no static-key fallback.
|
||||
func newClaimer() workerboot.NewClaimer {
|
||||
return func(region, gatewayURL string) (workerkey.Claimer, error) {
|
||||
c, err := newClient(region, gatewayURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workerboot.GatewayClaimer{Client: c}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(region, gatewayURL string) (*sqsworkergw.Client, error) {
|
||||
ca, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read cluster CA: %w", err)
|
||||
}
|
||||
return sqsworkergw.New(gatewayURL, region, ca, gwsign.NewInstanceRole())
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 || (os.Args[1] != "boot" && os.Args[1] != "ready") {
|
||||
fmt.Fprintln(os.Stderr, "usage: sqs-worker boot|ready")
|
||||
os.Exit(2)
|
||||
}
|
||||
logf := func(msg string) { fmt.Fprintf(os.Stderr, "[sqs-worker] %s\n", msg) }
|
||||
run := boot
|
||||
if os.Args[1] == "ready" {
|
||||
run = ready
|
||||
}
|
||||
if err := run(logf); err != nil {
|
||||
if os.Args[1] == "boot" && workerboot.Transient(err) {
|
||||
logf("boot not settled, will retry: " + err.Error())
|
||||
os.Exit(exitTransient)
|
||||
}
|
||||
logf("FATAL: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// exitTransient tells the guest's supervisor that boot may be run again.
|
||||
const exitTransient = 75
|
||||
|
||||
func config(logf func(string)) workerboot.Config {
|
||||
return workerboot.Config{
|
||||
FwCfgDir: "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-worker",
|
||||
SysBlock: "/sys/block",
|
||||
DevDir: "/dev",
|
||||
MountInfo: "/proc/self/mountinfo",
|
||||
DataDir: "/data",
|
||||
RunDir: "/run/sqs-worker",
|
||||
Disk: workerboot.SystemDisk{E2fsck: "/sbin/e2fsck"},
|
||||
IsBlockDevice: workerboot.IsBlockDevice,
|
||||
NewClaimer: newClaimer(),
|
||||
Attempts: 60,
|
||||
Wait: 5 * time.Second,
|
||||
Log: logf,
|
||||
}
|
||||
}
|
||||
|
||||
func boot(logf func(string)) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
_, err := workerboot.Boot(ctx, config(logf))
|
||||
return err
|
||||
}
|
||||
|
||||
// ready runs until init stops it; the marker is removed on the way out.
|
||||
func ready(logf func(string)) error {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cancel()
|
||||
// The store is one guest, so its streams and buckets are R1.
|
||||
if err := clustersize.Declare(1); err != nil {
|
||||
return err
|
||||
}
|
||||
err := workerboot.Watch(ctx, workerboot.ReadyConfig{
|
||||
Config: config(logf),
|
||||
Checker: workerboot.NATSChecker{},
|
||||
CallTimeout: 30 * time.Second,
|
||||
Refresh: 30 * time.Second,
|
||||
Refused: 5 * time.Minute,
|
||||
Serve: &workerboot.ServeConfig{
|
||||
Connector: workerboot.NATSConnector{},
|
||||
Run: func(ctx context.Context, region, gatewayURL string, js jetstream.JetStream, receiptKey []byte) error {
|
||||
c, err := newClient(region, gatewayURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return workerserve.Run(ctx, c, js, receiptKey, logf)
|
||||
},
|
||||
},
|
||||
})
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -10,7 +10,7 @@ quota state.
|
||||
a request/reply route on which a gateway, in the `awsgw-quota-seal` queue
|
||||
group, irreversibly seals one account's quota override record.
|
||||
`domains/admission/quota` owns it: `awsgw` serves it.
|
||||
`operator/accountlifecycle` is the consumer: it calls this subject to fence every
|
||||
`accountteardown` is the consumer: it calls this subject to fence every
|
||||
override writer at the quota owner before the account record is removed.
|
||||
|
||||
The request names the account in its body (`account_id`) and may carry an
|
||||
@@ -31,20 +31,10 @@ Any current gateway may answer; the leader-accepted revision is the evidence
|
||||
whichever one does, and the override bucket is opened at seal time even on a
|
||||
gateway whose quotas were disabled at startup.
|
||||
|
||||
## Seal-quota-override capability
|
||||
|
||||
`AccountQuotaOverrideSealCapabilitySubject(node)` (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) is a per-node, read-only request/reply route on which `operator/accountlifecycle` asks one configured gateway whether it supports the quota override seal, immediately before every seal attempt.
|
||||
The quota owner (`domains/admission/quota`) owns it: `awsgw` serves it with no queue group, so a request addressed to one node reaches only subscribers configured as that node, and two gateways sharing a node ID both answer.
|
||||
The consumer's quota seal reaper probes every configured node that runs `awsgw` and sends no seal request unless every one answers compatible.
|
||||
|
||||
`AccountQuotaOverrideSealCapabilityVersion` is a claim about the whole binary's writers, not about the responder: version 1 means every quota override writer in the binary is revision-guarded and refuses a sealed record, and the binary serves the seal subject.
|
||||
A gateway binary without this responder has no subscriber, so the probe for that node reads as incomplete (`no-reply`), the same as a stopped or partitioned node.
|
||||
The reply proves nothing beyond the moment it was sent: a gateway downgraded or replaced after a successful probe is outside the guarantee.
|
||||
|
||||
### Compatibility boundary
|
||||
|
||||
The subject name carries the contract major; `account_quota_seal_test.go` and `account_quota_seal_capability_test.go`
|
||||
preserve the version-1 JSON shapes and the subject builder.
|
||||
The subject name carries the contract major; `account_quota_seal_test.go`
|
||||
preserves the version-1 JSON shape.
|
||||
Producer and consumer are in the coordinated Spinifex release, so the window
|
||||
is N and N-1.
|
||||
A gateway that predates the subject has no subscription, so the request gets
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package admissionv1
|
||||
|
||||
import "strings"
|
||||
|
||||
// AccountQuotaOverrideSealCapabilityVersion is the version this binary reports.
|
||||
// Version 1: every quota override writer in the binary is revision-guarded and
|
||||
// refuses a sealed record, and the seal subject is served.
|
||||
const AccountQuotaOverrideSealCapabilityVersion = 1
|
||||
|
||||
// AccountQuotaOverrideSealCapabilitySubject is one gateway's read-only
|
||||
// seal-capability route, addressed per node so a probe reaches exactly the
|
||||
// node it asks about.
|
||||
func AccountQuotaOverrideSealCapabilitySubject(node string) string {
|
||||
return "admission.v1.AccountQuotaOverrideSealCapability." + node
|
||||
}
|
||||
|
||||
// ValidSealCapabilityNode reports whether node is exactly one NATS subject
|
||||
// token. Responder and probe both check it before building a subject, so
|
||||
// neither can address a wildcard or another node's route.
|
||||
func ValidSealCapabilityNode(node string) bool {
|
||||
return node != "" && !strings.ContainsAny(node, ".*>\t\n\r ")
|
||||
}
|
||||
|
||||
// AccountQuotaOverrideSealCapabilityRequest asks node whether it supports the
|
||||
// quota override seal. CorrelationRef is an opaque caller reference echoed for
|
||||
// logs; it grants nothing and is never a fence or authority to write.
|
||||
type AccountQuotaOverrideSealCapabilityRequest struct {
|
||||
Node string `json:"node"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
}
|
||||
|
||||
// AccountQuotaOverrideSealCapabilityReply reports the responding node's
|
||||
// capability version. It carries no account data and proves nothing beyond
|
||||
// the moment it was sent.
|
||||
type AccountQuotaOverrideSealCapabilityReply struct {
|
||||
Node string `json:"node"`
|
||||
CapabilityVersion int `json:"capability_version"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package admissionv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountQuotaOverrideSealCapabilitySubject(t *testing.T) {
|
||||
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-1", AccountQuotaOverrideSealCapabilitySubject("node-1"))
|
||||
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-2", AccountQuotaOverrideSealCapabilitySubject("node-2"))
|
||||
}
|
||||
|
||||
func TestAccountQuotaOverrideSealCapabilityRequestJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1", CorrelationRef: "req-1"})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"node":"node-1","correlation_ref":"req-1"}`, string(data))
|
||||
|
||||
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1"})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"node":"node-1"}`, string(data))
|
||||
}
|
||||
|
||||
func TestAccountQuotaOverrideSealCapabilityReplyJSONContract(t *testing.T) {
|
||||
reply := AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1, CorrelationRef: "req-1"}
|
||||
data, err := json.Marshal(reply)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"node":"node-1","capability_version":1,"correlation_ref":"req-1"}`, string(data))
|
||||
|
||||
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"node":"node-1","capability_version":1}`, string(data))
|
||||
}
|
||||
|
||||
func TestValidSealCapabilityNode(t *testing.T) {
|
||||
for _, node := range []string{"node-1", "node_2", "n3"} {
|
||||
require.True(t, ValidSealCapabilityNode(node), node)
|
||||
}
|
||||
for _, node := range []string{"", "a.b", "*", ">", "node-*", "node >", "node\t1", "node\n1"} {
|
||||
require.False(t, ValidSealCapabilityNode(node), "%q", node)
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package clusterv1
|
||||
|
||||
import "regexp"
|
||||
|
||||
const (
|
||||
// NodeFactsSubject fans out a request for each daemon's hardware and
|
||||
// cabling description. Its reply payload is NodeFacts.
|
||||
NodeFactsSubject = "spinifex.node.facts"
|
||||
|
||||
nodeNetTestPrefix = "spinifex.node.nettest."
|
||||
)
|
||||
|
||||
var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// ValidNodeID reports whether a node name is safe to place in a subject: no
|
||||
// NATS token separator or wildcard can get through.
|
||||
func ValidNodeID(node string) bool {
|
||||
return nodeIDPattern.MatchString(node)
|
||||
}
|
||||
|
||||
// NodeFactsOfSubject asks one node alone for its NodeFacts.
|
||||
func NodeFactsOfSubject(node string) string {
|
||||
return NodeFactsSubject + "." + node
|
||||
}
|
||||
|
||||
// NetTestServeSubject asks one node to start a one-shot throughput server.
|
||||
func NetTestServeSubject(node string) string {
|
||||
return nodeNetTestPrefix + node + ".serve"
|
||||
}
|
||||
|
||||
// NetTestRunSubject asks one node to run the client side of a throughput test.
|
||||
func NetTestRunSubject(node string) string {
|
||||
return nodeNetTestPrefix + node + ".run"
|
||||
}
|
||||
|
||||
// Planes a node's NICs can carry, named as the installer names them.
|
||||
const (
|
||||
PlaneWAN = "wan"
|
||||
PlaneLAN = "lan"
|
||||
PlaneVPC = "vpc"
|
||||
)
|
||||
|
||||
// NetTestPort is the TCP port reserved for node-to-node throughput tests.
|
||||
const NetTestPort = 5001
|
||||
|
||||
// NodeFacts describes one node's hardware, OS and cabling. Fields a host
|
||||
// cannot report are left empty, and Sources says why.
|
||||
type NodeFacts struct {
|
||||
Node string `json:"node"`
|
||||
CollectedAt string `json:"collected_at,omitempty"`
|
||||
FormFactor string `json:"form_factor"`
|
||||
Virt string `json:"virt,omitempty"`
|
||||
System SystemFacts `json:"system"`
|
||||
OS OSFacts `json:"os"`
|
||||
CPU CPUFacts `json:"cpu"`
|
||||
Memory MemoryFacts `json:"memory"`
|
||||
NICs []NICFacts `json:"nics"`
|
||||
Planes []PlaneFacts `json:"planes"`
|
||||
Disks []DiskFacts `json:"disks,omitempty"`
|
||||
Filesystems []FilesystemFacts `json:"filesystems,omitempty"`
|
||||
Sources map[string]string `json:"sources"`
|
||||
}
|
||||
|
||||
// SystemFacts is the machine's identity from DMI.
|
||||
type SystemFacts struct {
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
ChassisType string `json:"chassis_type,omitempty"`
|
||||
ChassisAsset string `json:"chassis_asset,omitempty"`
|
||||
BoardVendor string `json:"board_vendor,omitempty"`
|
||||
BoardProduct string `json:"board_product,omitempty"`
|
||||
BoardSerial string `json:"board_serial,omitempty"`
|
||||
BIOSVendor string `json:"bios_vendor,omitempty"`
|
||||
BIOSVersion string `json:"bios_version,omitempty"`
|
||||
BIOSDate string `json:"bios_date,omitempty"`
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
}
|
||||
|
||||
// OSFacts names the running OS and Spinifex build.
|
||||
type OSFacts struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Version string `json:"spx_version,omitempty"`
|
||||
Commit string `json:"spx_commit,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
// CPUFacts summarises the sockets. Threads and Cores are totals. Features are
|
||||
// the flags worth naming; Flags is every flag the CPU reports.
|
||||
type CPUFacts struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Sockets int `json:"sockets"`
|
||||
CoresPerSocket int `json:"cores_per_socket,omitempty"`
|
||||
Cores int `json:"cores"`
|
||||
Threads int `json:"threads"`
|
||||
MaxMHz int `json:"max_mhz,omitempty"`
|
||||
CurrentMHz int `json:"current_mhz,omitempty"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryFacts groups identical DIMMs so a 32-slot board reads as one line.
|
||||
type MemoryFacts struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
SlotsUsed int `json:"slots_used"`
|
||||
SlotsTotal int `json:"slots_total"`
|
||||
MaxCapacity string `json:"max_capacity,omitempty"`
|
||||
ECC string `json:"ecc,omitempty"`
|
||||
Groups []MemoryGroup `json:"groups"`
|
||||
OSTotalBytes uint64 `json:"os_total_bytes,omitempty"`
|
||||
OSAvailBytes uint64 `json:"os_available_bytes,omitempty"`
|
||||
HugePageBytes uint64 `json:"huge_page_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryGroup is a set of DIMMs alike in every field shown. ConfiguredMTs
|
||||
// below SpeedMTs means the board runs them slower than rated.
|
||||
type MemoryGroup struct {
|
||||
Count int `json:"count"`
|
||||
SizeBytes uint64 `json:"size_bytes"`
|
||||
Type string `json:"type,omitempty"`
|
||||
TypeDetail string `json:"type_detail,omitempty"`
|
||||
FormFactor string `json:"form_factor,omitempty"`
|
||||
SpeedMTs int `json:"speed_mts,omitempty"`
|
||||
ConfiguredMTs int `json:"configured_mts,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Rank string `json:"rank,omitempty"`
|
||||
Serials []string `json:"serials,omitempty"`
|
||||
}
|
||||
|
||||
// DiskFacts is one physical drive as sysfs reports it. Transport is nvme, sata,
|
||||
// usb or virtio, and empty when the device path names none, as a RAID
|
||||
// controller's virtual disk does.
|
||||
type DiskFacts struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes uint64 `json:"size_bytes"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Rotational bool `json:"rotational,omitempty"`
|
||||
Removable bool `json:"removable,omitempty"`
|
||||
}
|
||||
|
||||
// FilesystemFacts is one mounted block-device or ZFS filesystem. BindOf names
|
||||
// the directory a bind mount shows; Disk names the drives under it. Error says
|
||||
// why Usage is missing or stale.
|
||||
type FilesystemFacts struct {
|
||||
Mount string `json:"mount"`
|
||||
Source string `json:"source"`
|
||||
Disk string `json:"disk,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BindOf string `json:"bind_of,omitempty"`
|
||||
Usage *FilesystemUsage `json:"usage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// ID identifies the mount on its node, so a filesystem replaced at the
|
||||
// same path is read afresh. Empty for a mount the daemon cannot see.
|
||||
ID string `json:"-"`
|
||||
}
|
||||
|
||||
// FilesystemUsage is a filesystem's last statfs reading, as df counts it.
|
||||
type FilesystemUsage struct {
|
||||
SizeBytes uint64 `json:"size_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
AvailBytes uint64 `json:"avail_bytes"`
|
||||
}
|
||||
|
||||
// NICFacts is one physical network interface and, where LLDP reports it, the
|
||||
// switch port it is cabled to.
|
||||
type NICFacts struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
BusInfo string `json:"bus_info,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||
MaxMbps int `json:"max_mbps,omitempty"`
|
||||
Duplex string `json:"duplex,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
State string `json:"state"`
|
||||
Master string `json:"master,omitempty"`
|
||||
Planes []string `json:"planes,omitempty"`
|
||||
Neighbor *LLDPNeighbor `json:"neighbor,omitempty"`
|
||||
}
|
||||
|
||||
// LLDPNeighbor is what the switch on the other end of a NIC advertises.
|
||||
type LLDPNeighbor struct {
|
||||
ChassisID string `json:"chassis_id"`
|
||||
SystemName string `json:"system_name,omitempty"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
PortID string `json:"port_id,omitempty"`
|
||||
PortDescription string `json:"port_description,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// PlaneFacts is the address a plane uses on this node and the NICs under it.
|
||||
type PlaneFacts struct {
|
||||
Plane string `json:"plane"`
|
||||
Bridge string `json:"bridge,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Prefix int `json:"prefix,omitempty"`
|
||||
NICs []string `json:"nics,omitempty"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
}
|
||||
|
||||
// NetTestServeRequest starts a server bound to the node's address on Plane.
|
||||
type NetTestServeRequest struct {
|
||||
Plane string `json:"plane"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// NetTestServeResponse names the address the server listens on.
|
||||
type NetTestServeResponse struct {
|
||||
Address string `json:"address,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NetTestRunRequest runs the client towards Address from the node's own
|
||||
// address on Plane. Address must be what Target reports for that plane.
|
||||
type NetTestRunRequest struct {
|
||||
Plane string `json:"plane"`
|
||||
Target string `json:"target"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Streams int `json:"streams"`
|
||||
}
|
||||
|
||||
// NetTestRunResponse is one direction of a pair's measurement.
|
||||
type NetTestRunResponse struct {
|
||||
SourceAddress string `json:"source_address,omitempty"`
|
||||
BitsPerSecond float64 `json:"bits_per_second"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
Streams int `json:"streams"`
|
||||
StreamBPS []float64 `json:"stream_bps,omitempty"`
|
||||
RTTMinMillis float64 `json:"rtt_min_ms,omitempty"`
|
||||
RTTAvgMillis float64 `json:"rtt_avg_ms,omitempty"`
|
||||
RTTMaxMillis float64 `json:"rtt_max_ms,omitempty"`
|
||||
PacketLoss float64 `json:"packet_loss_percent,omitempty"`
|
||||
LinkMbps int `json:"link_mbps,omitempty"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package clusterv1
|
||||
|
||||
import "time"
|
||||
|
||||
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
|
||||
// other cluster-wide state.
|
||||
const ClusterStateBucket = "spinifex-cluster-state"
|
||||
|
||||
// HeartbeatKey is the ClusterStateBucket key a node writes its heartbeat under.
|
||||
func HeartbeatKey(node string) string {
|
||||
return "heartbeat." + node
|
||||
}
|
||||
|
||||
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
|
||||
// decodes only the timestamp, so a reader does not depend on the capacity
|
||||
// figures the daemon writes beside it.
|
||||
type HeartbeatTimestamp struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Time returns when the heartbeat was written. A timestamp that is missing or
|
||||
// unparseable vouches for nothing, so ok is false.
|
||||
func (h HeartbeatTimestamp) Time() (written time.Time, ok bool) {
|
||||
parsed, err := time.Parse(time.RFC3339, h.Timestamp)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed.UTC(), true
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package clusterv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The bucket and key are the deployed layout: a rolling upgrade has old daemons
|
||||
// writing and new readers reading, so these strings cannot change.
|
||||
func TestHeartbeatKey_IsTheDeployedLayout(t *testing.T) {
|
||||
assert.Equal(t, "spinifex-cluster-state", clusterv1.ClusterStateBucket)
|
||||
assert.Equal(t, "heartbeat.node-a", clusterv1.HeartbeatKey("node-a"))
|
||||
}
|
||||
|
||||
func TestHeartbeatTimestamp_ReadsAFullDaemonHeartbeat(t *testing.T) {
|
||||
full := `{"node":"node-a","epoch":3,"timestamp":"2026-10-30T01:02:03Z","vm_count":4,"allocated_vcpu":8}`
|
||||
var h clusterv1.HeartbeatTimestamp
|
||||
require.NoError(t, json.Unmarshal([]byte(full), &h))
|
||||
|
||||
written, ok := h.Time()
|
||||
require.True(t, ok)
|
||||
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
|
||||
}
|
||||
|
||||
func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
|
||||
for _, ts := range []string{"", "yesterday"} {
|
||||
_, ok := clusterv1.HeartbeatTimestamp{Timestamp: ts}.Time()
|
||||
assert.False(t, ok, "timestamp %q", ts)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ subject shape are wire compatibility. Callers and receivers must use this
|
||||
package rather than rebuild those details. `commands_test.go` preserves the
|
||||
version-1 JSON shape and subject form.
|
||||
|
||||
This package owns shared command payloads. It
|
||||
This package owns shared command payloads and the drain-volume response. It
|
||||
does not define every action's reply body, authorize the request, store EC2
|
||||
state or implement NATS transport. Those remain responsibilities of the EC2
|
||||
domain and its runtime composition.
|
||||
@@ -37,13 +37,6 @@ contract or an approved compatibility plan; it is not a package refactor.
|
||||
Only the subject is owned here today.
|
||||
The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declared as anonymous structs on each side; moving them into this package is a contract change that needs its own characterization first.
|
||||
|
||||
## Volume held elsewhere
|
||||
|
||||
A start refused because another node holds one of the instance's volumes is an `IncorrectState` error payload on the `ec2.start`, `ec2.start.<node>` and `ec2.cmd.<instance-id>` replies, and the AWS body cannot say why.
|
||||
The owning node adds the `X-Volume-Held-Elsewhere: true` header to that reply, the `ec2.start` forwarder relays it, and `StartInstances` wraps `ErrVolumeHeldElsewhere` around the error it rebuilds, so a caller matches it with `errors.Is` rather than reading the message.
|
||||
The owning node sets the header only from the EBS provider's `volume_held_elsewhere` code, which every provider returns for this refusal; no reader matches the message text.
|
||||
`VolumeHeldElsewhereText` is the wording viperblockd puts in that refusal's message, not a signal.
|
||||
|
||||
## Platform inventory
|
||||
|
||||
`PlatformInventorySubject` (`ec2.v1.PlatformInventory`) is a request/reply route on which a daemon in the `spinifex-workers` queue group reports one account's key pairs, placement groups, launch templates and their `ec2-tags` entries from the EC2 owner stores.
|
||||
|
||||
@@ -29,6 +29,7 @@ type EC2InstanceCommand struct {
|
||||
Attributes EC2CommandAttributes `json:"attributes"`
|
||||
AttachVolumeData *AttachVolumeData `json:"attach_volume_data,omitempty"`
|
||||
DetachVolumeData *DetachVolumeData `json:"detach_volume_data,omitempty"`
|
||||
DrainVolumeData *DrainVolumeData `json:"drain_volume_data,omitempty"`
|
||||
AttachENIData *AttachENIData `json:"attach_eni_data,omitempty"`
|
||||
DetachENIData *DetachENIData `json:"detach_eni_data,omitempty"`
|
||||
IamProfileAssociationData *IamProfileAssociationData `json:"iam_profile_association_data,omitempty"`
|
||||
@@ -44,6 +45,7 @@ type EC2CommandAttributes struct {
|
||||
StartInstance bool `json:"start_instance"`
|
||||
AttachVolume bool `json:"attach_volume"`
|
||||
DetachVolume bool `json:"detach_volume"`
|
||||
DrainVolume bool `json:"drain_volume,omitempty"`
|
||||
RebootInstance bool `json:"reboot_instance"`
|
||||
AttachENI bool `json:"attach_eni"`
|
||||
DetachENI bool `json:"detach_eni"`
|
||||
@@ -67,6 +69,33 @@ type DetachVolumeData struct {
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// DrainVolumeData carries parameters for a drain-volume command, which flushes
|
||||
// the volume's in-flight writes to S3 before a snapshot reads them. The command
|
||||
// is addressed to the instance the volume is attached to because the drain
|
||||
// socket only exists on the node hosting it.
|
||||
type DrainVolumeData struct {
|
||||
VolumeID string `json:"volume_id"`
|
||||
}
|
||||
|
||||
const (
|
||||
// DrainVolumeStatusDrained is the ack a node returns once the volume's
|
||||
// in-flight writes have reached S3.
|
||||
DrainVolumeStatusDrained = "drained"
|
||||
|
||||
// DrainVolumeStatusNotRunning is the ack a node returns when it still holds
|
||||
// the instance but the VM is not running, so no process is writing to the
|
||||
// volume and its sealed checkpoint is already current. An attachment record
|
||||
// outlives the writer — stop deliberately leaves boot volumes attached — so
|
||||
// the caller cannot tell this from the record alone.
|
||||
DrainVolumeStatusNotRunning = "not-running"
|
||||
)
|
||||
|
||||
// DrainVolumeResponse is the reply to a drain-volume command.
|
||||
type DrainVolumeResponse struct {
|
||||
VolumeID string `json:"volume_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AttachENIData carries parameters for an attach-network-interface command.
|
||||
type AttachENIData struct {
|
||||
NetworkInterfaceID string `json:"network_interface_id"`
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
StartInstance: true,
|
||||
AttachVolume: true,
|
||||
DetachVolume: true,
|
||||
DrainVolume: true,
|
||||
RebootInstance: true,
|
||||
AttachENI: true,
|
||||
DetachENI: true,
|
||||
@@ -27,6 +28,7 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
},
|
||||
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
|
||||
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
|
||||
DrainVolumeData: &DrainVolumeData{VolumeID: "vol-3"},
|
||||
AttachENIData: &AttachENIData{NetworkInterfaceID: "eni-1", DeviceIndex: 2},
|
||||
DetachENIData: &DetachENIData{AttachmentID: "eni-attach-1", Force: true},
|
||||
IamProfileAssociationData: &IamProfileAssociationData{InstanceProfileArn: "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
@@ -45,6 +47,7 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
"start_instance": true,
|
||||
"attach_volume": true,
|
||||
"detach_volume": true,
|
||||
"drain_volume": true,
|
||||
"reboot_instance": true,
|
||||
"attach_eni": true,
|
||||
"detach_eni": true,
|
||||
@@ -56,6 +59,7 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
},
|
||||
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
|
||||
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
|
||||
"drain_volume_data": {"volume_id": "vol-3"},
|
||||
"attach_eni_data": {"network_interface_id": "eni-1", "device_index": 2},
|
||||
"detach_eni_data": {"attachment_id": "eni-attach-1", "force": true},
|
||||
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
@@ -65,6 +69,16 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func TestDrainVolumeResponseJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(DrainVolumeResponse{
|
||||
VolumeID: "vol-1",
|
||||
Status: DrainVolumeStatusDrained,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"volume_id":"vol-1","status":"drained"}`, string(data))
|
||||
require.Equal(t, "not-running", DrainVolumeStatusNotRunning)
|
||||
}
|
||||
|
||||
func TestInstanceCommandSubjects(t *testing.T) {
|
||||
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
|
||||
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package ec2v1
|
||||
|
||||
import "errors"
|
||||
|
||||
// VolumeHeldElsewhereText is the message viperblockd gives a lease refusal. It
|
||||
// is wording for the operator; callers classify the refusal by its code.
|
||||
const VolumeHeldElsewhereText = "volume is leased by another owner"
|
||||
|
||||
// ErrVolumeHeldElsewhere marks a start refused because another node still
|
||||
// holds one of the instance's volumes. The owning node sets it from the EBS
|
||||
// provider's held-elsewhere code, and a start reply carries it across NATS as
|
||||
// the X-Volume-Held-Elsewhere header rather than as message text.
|
||||
var ErrVolumeHeldElsewhere = errors.New("a volume is leased by another node")
|
||||
@@ -126,8 +126,8 @@ type Assign struct {
|
||||
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
|
||||
ENISubnetID string `json:"eniSubnetId,omitempty"`
|
||||
// TaskRoleARN is the task's IAM role (from the taskdef); when set, the agent
|
||||
// serves its credentials at 169.254.170.2 under CredID, a scheduler-minted
|
||||
// secret. An assign without a CredID gets no credentials.
|
||||
// serves its credentials at 169.254.170.2 under CredID, defaulting CredID to
|
||||
// the taskID when the scheduler leaves it empty.
|
||||
CredID string `json:"credId,omitempty"`
|
||||
TaskRoleARN string `json:"taskRoleArn,omitempty"`
|
||||
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Lambda v1 contracts
|
||||
|
||||
This package will own the Lambda process contracts declared by ADR-0009:S13.
|
||||
It holds the L-GUEST declaration and, in `guest.go`, the connection vocabulary both ends share: the scope, the URI SAN roles, the ALPN identifier and the fw_cfg entry names. The transport and authentication are decided (ADR-0009:S17); the invocation message types and framing are not chosen, so no message types are defined here.
|
||||
It is not an AWS tenant-facing API.
|
||||
|
||||
## L-GUEST: node executor to guest agent
|
||||
|
||||
**Owner:** the Lambda domain.
|
||||
|
||||
**Producers and consumers:** the node-local executor (`runtime/compute/lambda`) and the in-guest Lambda agent (`agents/lambda`), in both directions.
|
||||
|
||||
**Operations:** bootstrap with scoped material, readiness, invoke, completion, bounded logs, shutdown.
|
||||
|
||||
**Transport:** TCP over the microVM's network (ADR-0009:S17).
|
||||
The agent dials one connection to a listener the executor owns for the environment, and the guest exposes no listener for host control.
|
||||
At most one authenticated control connection is active per environment, and the agent may reconnect; how a prior connection and its in-flight work are handled is open (ADR-0009 M1).
|
||||
The network is shared and untrusted: no source address, port or bridge membership is identity or authority.
|
||||
Tenant code may be able to reach the listener, but it cannot open the agent's connection or key material, so any connection it makes fails authentication.
|
||||
|
||||
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
|
||||
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
|
||||
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
|
||||
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/3`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
|
||||
On that socket a request's raw payload is at most 6 MiB, checked by the gateway before encoding and by the daemon after decoding.
|
||||
Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata headroom and 64 bytes of field slack, and a receiver checks the frame length before allocating; metadata has no separate bound within the frame.
|
||||
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
|
||||
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
|
||||
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
|
||||
|
||||
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
|
||||
|
||||
| Entry | Content |
|
||||
|---|---|
|
||||
| `guest-key` | Guest private key, PEM |
|
||||
| `guest-cert` | Guest certificate, PEM |
|
||||
| `host-cert` | The one pinned host certificate, PEM |
|
||||
| `expect` | `<Region>/<account>/<function ID>/<environment ID>/<generation>` |
|
||||
| `host-addr` | Host listener as `host:port` |
|
||||
|
||||
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
|
||||
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
|
||||
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
|
||||
|
||||
**Authority:** one environment's attempt only.
|
||||
The user runtime never holds these keys, and sharing the guest with the agent gives user code no platform authority.
|
||||
|
||||
**Idempotency and fencing:** an invoke carries the attempt-grant identity and the attempt ID.
|
||||
The agent accepts a run command only under a current grant, and a completion under a superseded grant is discarded and reported.
|
||||
How a grant is enforced and proven ended is open (ADR-0009 M1).
|
||||
|
||||
**Errors and versions:** the agent and executor fail closed on an unknown protocol version or an identity mismatch.
|
||||
|
||||
**Readiness:** environment readiness needs authenticated, current-generation agent evidence; a running hypervisor process does not count.
|
||||
|
||||
**Payloads:** the contract must carry a synchronous payload and response of up to 6 MB and an asynchronous payload of up to 1 MB, plus framing, over a path whose size bound is declared.
|
||||
How payloads move is open (ADR-0009 M3).
|
||||
|
||||
### Compatibility boundary
|
||||
|
||||
The agent ships inside the runtime image pinned by each published version, so it is outside the coordinated release train.
|
||||
The executor supports every L-GUEST major carried by a still-supported runtime image.
|
||||
Retiring an image follows a published runtime-deprecation lifecycle that blocks create and update before it blocks invoke.
|
||||
|
||||
Evidence required before the first agent image ships: a function on an old image invoked by a new executor, and the reverse during rollback.
|
||||
|
||||
## L-RUNTIME
|
||||
|
||||
The guest agent serves the AWS Lambda Runtime API, version `2018-06-01`, to the user runtime at `AWS_LAMBDA_RUNTIME_API`.
|
||||
Spinifex does not version this AWS contract, and conformance is measured against AWS.
|
||||
@@ -1,54 +0,0 @@
|
||||
// Package lambdav1 holds the Lambda process-contract vocabulary shared by the
|
||||
// node executor and the guest agent. See README.md for the declarations.
|
||||
package lambdav1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ALPNv1 names L-GUEST major version 1; a peer offering any other fails the handshake.
|
||||
const ALPNv1 = "spinifex-lambda-guest/1"
|
||||
|
||||
// FwCfgPrefix prefixes every fw_cfg entry the executor passes the guest.
|
||||
const FwCfgPrefix = "opt/spinifex/lambda/"
|
||||
|
||||
// fw_cfg entry names under FwCfgPrefix.
|
||||
const (
|
||||
EntryGuestKey = "guest-key"
|
||||
EntryGuestCert = "guest-cert"
|
||||
EntryHostCert = "host-cert"
|
||||
EntryScope = "expect"
|
||||
EntryHostAddr = "host-addr"
|
||||
)
|
||||
|
||||
// Roles named as the last URI SAN path segment.
|
||||
const (
|
||||
RoleHost = "host"
|
||||
RoleGuest = "guest"
|
||||
)
|
||||
|
||||
const uriScheme = "spinifex-lambda"
|
||||
|
||||
// Scope names one environment generation:
|
||||
// Region/account/function ID/environment ID/generation.
|
||||
type Scope string
|
||||
|
||||
// ParseScope accepts exactly five non-empty, slash-separated segments.
|
||||
func ParseScope(s string) (Scope, error) {
|
||||
parts := strings.Split(s, "/")
|
||||
if len(parts) != 5 {
|
||||
return "", fmt.Errorf("lambdav1: scope %q: want 5 segments, got %d", s, len(parts))
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" || strings.ContainsAny(p, " \t\r\n?#%") {
|
||||
return "", fmt.Errorf("lambdav1: scope %q: empty or invalid segment", s)
|
||||
}
|
||||
}
|
||||
return Scope(s), nil
|
||||
}
|
||||
|
||||
// URI is the single URI SAN a certificate for role in this scope carries.
|
||||
func (s Scope) URI(role string) string {
|
||||
return uriScheme + "://" + string(s) + "/" + role
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package lambdav1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseScope(t *testing.T) {
|
||||
s, err := ParseScope("ap-southeast-2/000000000001/fn-1/env-1/gen-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "spinifex-lambda://ap-southeast-2/000000000001/fn-1/env-1/gen-1/host", s.URI(RoleHost))
|
||||
for _, bad := range []string{
|
||||
"", "a/b/c/d", "a/b/c/d/e/f", "a//c/d/e", "a/b/c/d/e/", "a/b/c/d/e?x", "a/b/c/d/e#x", "a b/b/c/d/e",
|
||||
} {
|
||||
_, err := ParseScope(bad)
|
||||
assert.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,3 @@ administration. It is separate from AWS tenant-facing service contracts.
|
||||
roles and Reed-Solomon configuration. It is an operational status contract,
|
||||
not a generic provider abstraction; do not change its subject or JSON shape
|
||||
incompatibly. Add a new version when that is required.
|
||||
|
||||
`admin.go` holds the `/admin/<Method>` request and response bodies the private admin surface shares with the CLI.
|
||||
`AdminMethodNames` is the list of callable methods, and the gateway router builds its accept set from it.
|
||||
`AccountQuotaRequest`, `AccountQuotaResponse` and `DeleteAccountResponse` stay in the gateway until the `/admin` handlers move, because they embed types the contracts layer may not import.
|
||||
Do not change a field name, JSON tag or omitempty incompatibly; add a new version when that is required.
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package operatorv1
|
||||
|
||||
import "slices"
|
||||
|
||||
// adminMethodNames is the callable /admin/<Method> list. The gateway router
|
||||
// builds its accept set from it, so a name cannot be served without being listed.
|
||||
var adminMethodNames = []string{
|
||||
"CreateAccount",
|
||||
"DeleteAccount",
|
||||
"DescribeAccountDeletion",
|
||||
"GetAccountQuota",
|
||||
"ListAccounts",
|
||||
"PutAccountQuota",
|
||||
}
|
||||
|
||||
// AdminMethodNames returns the callable /admin/<Method> names in sorted order.
|
||||
// Tooling that mints a credential grants these by name, so the list has to be
|
||||
// the one the router serves rather than a copy that can drift out of step.
|
||||
func AdminMethodNames() []string {
|
||||
return slices.Sorted(slices.Values(adminMethodNames))
|
||||
}
|
||||
|
||||
// Deletion job states.
|
||||
const (
|
||||
DeletionStateRunning = "RUNNING"
|
||||
DeletionStateCompleted = "COMPLETED"
|
||||
DeletionStateFailed = "FAILED"
|
||||
|
||||
// DeletionStateExercised ends a non-finalizing run: the account is left
|
||||
// TERMINATING and a later DeleteAccount takes the same operation over.
|
||||
DeletionStateExercised = "EXERCISED"
|
||||
)
|
||||
|
||||
// CreateAccountRequest is the /admin/CreateAccount request body.
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// CreateAccountResponse is the /admin/CreateAccount success body. It is stored
|
||||
// verbatim (with the secret encrypted) so a replay returns an identical answer.
|
||||
type CreateAccountResponse struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
AdminUser string `json:"adminUser"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
DefaultVpcID string `json:"defaultVpcId"`
|
||||
}
|
||||
|
||||
// DeleteAccountRequest is the /admin/DeleteAccount request body.
|
||||
type DeleteAccountRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
|
||||
// NonFinalizing quiesces the account, cleans the quiesced EC2 platform
|
||||
// kinds and stops. No legacy stage runs and the account record stays.
|
||||
NonFinalizing bool `json:"nonFinalizing,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
|
||||
type DescribeAccountDeletionRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
|
||||
// AccountSummary is one row of /admin/ListAccounts. It carries no credential
|
||||
// material: the caller can already create and delete accounts, but that is no
|
||||
// reason for a listing to hand out keys.
|
||||
type AccountSummary struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// ListAccountsResponse is the /admin/ListAccounts success body.
|
||||
type ListAccountsResponse struct {
|
||||
Accounts []AccountSummary `json:"accounts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
|
||||
// fence. Recording only: it admits nothing. QuiescedScope is filled only by a run
|
||||
// that guarantees a final inventory re-read, and holds as of EvaluatedAt.
|
||||
type IAMQuiescence struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Fence uint64 `json:"fence"`
|
||||
Generation uint64 `json:"generation"`
|
||||
QuiescedScope []string `json:"quiescedScope"`
|
||||
Unresolved []UnresolvedScope `json:"unresolved"`
|
||||
Consumers []ConsumerBasis `json:"consumers"`
|
||||
Writers []AccountedWriter `json:"writers"`
|
||||
Inventory []InventoryStatus `json:"inventory"`
|
||||
EvaluatedAt string `json:"evaluatedAt"`
|
||||
}
|
||||
|
||||
// UnresolvedScope names a scope or writer that blocks quiescence, with a fixed
|
||||
// reason. It is never a timeout.
|
||||
type UnresolvedScope struct {
|
||||
Scope string `json:"scope"`
|
||||
Class string `json:"class"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ConsumerBasis cites an enforcement consumer and the accepted contract that
|
||||
// lets it count as having applied the generation.
|
||||
type ConsumerBasis struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Basis string `json:"basis"`
|
||||
}
|
||||
|
||||
// AccountedWriter is a writer that cannot be fenced and is covered by the
|
||||
// owner's inventory instead. Ref names the tracked work that closes the gap.
|
||||
type AccountedWriter struct {
|
||||
Writer string `json:"writer"`
|
||||
Basis string `json:"basis"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
// InventoryStatus is the owner inventory's status for one kind at evaluation.
|
||||
type InventoryStatus struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Classes of an UnresolvedScope.
|
||||
const (
|
||||
UnresolvedClassFence = "fence"
|
||||
UnresolvedClassConsumer = "consumer"
|
||||
UnresolvedClassWriter = "writer"
|
||||
)
|
||||
|
||||
// Fixed reasons of an UnresolvedScope.
|
||||
const (
|
||||
ReasonFenceMismatch = "fence-mismatch"
|
||||
ReasonIAMReadbackFailed = "iam-readback-failed"
|
||||
ReasonNoAcceptedEnforcement = "no-accepted-enforcement-contract"
|
||||
ReasonNoCompleteOwnerInventory = "no-complete-owner-inventory"
|
||||
ReasonParticipantNotDeclared = "participant-not-declared"
|
||||
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
|
||||
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
|
||||
)
|
||||
|
||||
// ExerciseResult is what a non-finalizing run did after IAM quiescence.
|
||||
type ExerciseResult struct {
|
||||
// Skipped is the fixed reason no cleanup ran; Cleanup is then absent.
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
Cleanup *PlatformCleanup `json:"cleanup,omitempty"`
|
||||
Accounting []KindAccounting `json:"accounting,omitempty"`
|
||||
}
|
||||
|
||||
// PlatformCleanup is the EC2 platform cleanup result. It carries resource IDs,
|
||||
// fixed reasons and counts only, never names, tags, key material or data.
|
||||
type PlatformCleanup struct {
|
||||
Kinds []CleanupKind `json:"kinds"`
|
||||
Items []CleanupItem `json:"items,omitempty"`
|
||||
Counts []CleanupCount `json:"counts,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupKind is one kind's scan status after the cleanup's own re-scan.
|
||||
type CleanupKind struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
IncompleteReasons []string `json:"incompleteReasons,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupItem is one resource by its ID with what the cleanup did to it.
|
||||
type CleanupItem struct {
|
||||
ResourceID string `json:"resourceId"`
|
||||
Kind string `json:"kind"`
|
||||
Disposition string `json:"disposition"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MemberCount int `json:"memberCount,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupCount is the number of records of one class that have no public ID.
|
||||
type CleanupCount struct {
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class"`
|
||||
Disposition string `json:"disposition"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// KindAccounting is the final inventory re-read held against the cleanup result
|
||||
// for one kind. Quiescence is not rewritten from it.
|
||||
type KindAccounting struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
|
||||
// Accounting statuses and fixed reasons of a KindAccounting.
|
||||
const (
|
||||
AccountingAccounted = "accounted"
|
||||
AccountingUnaccounted = "unaccounted"
|
||||
|
||||
AccountingReasonRereadIncomplete = "reread-incomplete"
|
||||
AccountingReasonUnnamedRemainder = "unnamed-remainder"
|
||||
)
|
||||
|
||||
// Fixed reasons an exercise ran no cleanup.
|
||||
const (
|
||||
ExerciseSkippedNoQuiescedKind = "no-quiesced-kind"
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
package operatorv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func goldenJSON(t *testing.T, v any, want string) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, want, string(data))
|
||||
}
|
||||
|
||||
func TestAdminWire_CreateAccountRequest(t *testing.T) {
|
||||
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1", Source: "cli"},
|
||||
`{"name":"acme","clientToken":"tok-1","source":"cli"}`)
|
||||
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1"},
|
||||
`{"name":"acme","clientToken":"tok-1"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_CreateAccountResponse(t *testing.T) {
|
||||
goldenJSON(t, CreateAccountResponse{
|
||||
AccountID: "000000000042", AccountName: "acme", AdminUser: "admin",
|
||||
AccessKeyID: "AKIAEXAMPLE", SecretAccessKey: "secret", DefaultVpcID: "vpc-1",
|
||||
}, `{"accountId":"000000000042","accountName":"acme","adminUser":"admin","accessKeyId":"AKIAEXAMPLE","secretAccessKey":"secret","defaultVpcId":"vpc-1"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DeleteAccountRequest(t *testing.T) {
|
||||
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2", Force: true, DryRun: true},
|
||||
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2","force":true,"dryRun":true}`)
|
||||
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2"},
|
||||
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DescribeAccountDeletionRequest(t *testing.T) {
|
||||
goldenJSON(t, DescribeAccountDeletionRequest{AccountID: "000000000042"}, `{"accountId":"000000000042"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DeletionStates(t *testing.T) {
|
||||
require.Equal(t, "RUNNING", DeletionStateRunning)
|
||||
require.Equal(t, "COMPLETED", DeletionStateCompleted)
|
||||
require.Equal(t, "FAILED", DeletionStateFailed)
|
||||
require.Equal(t, "EXERCISED", DeletionStateExercised)
|
||||
}
|
||||
|
||||
func TestAdminWire_NonFinalizingDeleteRequest(t *testing.T) {
|
||||
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-3", NonFinalizing: true},
|
||||
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-3","nonFinalizing":true}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_ExerciseResult(t *testing.T) {
|
||||
goldenJSON(t, ExerciseResult{
|
||||
Cleanup: &PlatformCleanup{
|
||||
Kinds: []CleanupKind{{Kind: "key-pair", Status: "observed-clean-at-scan-time"}},
|
||||
Items: []CleanupItem{{ResourceID: "pg-1", Kind: "placement-group", Disposition: "blocked", Reason: "instance-members", MemberCount: 1}},
|
||||
Counts: []CleanupCount{{Kind: "key-pair", Class: "public-key-object-without-metadata", Disposition: "deleted", Count: 2}},
|
||||
},
|
||||
Accounting: []KindAccounting{{Kind: "placement-group", Status: AccountingAccounted, Remaining: 1}},
|
||||
}, `{"cleanup":{"kinds":[{"kind":"key-pair","status":"observed-clean-at-scan-time"}],
|
||||
"items":[{"resourceId":"pg-1","kind":"placement-group","disposition":"blocked","reason":"instance-members","memberCount":1}],
|
||||
"counts":[{"kind":"key-pair","class":"public-key-object-without-metadata","disposition":"deleted","count":2}]},
|
||||
"accounting":[{"kind":"placement-group","status":"accounted","remaining":1}]}`)
|
||||
goldenJSON(t, ExerciseResult{Skipped: ExerciseSkippedNoQuiescedKind}, `{"skipped":"no-quiesced-kind"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_ListAccountsResponse(t *testing.T) {
|
||||
goldenJSON(t, ListAccountsResponse{
|
||||
Accounts: []AccountSummary{
|
||||
{AccountID: "000000000042", AccountName: "acme", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
|
||||
{AccountID: "000000000043", AccountName: "bolt", Status: "TERMINATING"},
|
||||
},
|
||||
Count: 2,
|
||||
}, `{"accounts":[
|
||||
{"accountId":"000000000042","accountName":"acme","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},
|
||||
{"accountId":"000000000043","accountName":"bolt","status":"TERMINATING"}],"count":2}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_AdminMethodNames(t *testing.T) {
|
||||
require.Equal(t, []string{
|
||||
"CreateAccount", "DeleteAccount", "DescribeAccountDeletion",
|
||||
"GetAccountQuota", "ListAccounts", "PutAccountQuota",
|
||||
}, AdminMethodNames())
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package telemetryv1
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// LiveSamplePeriodMillis is how often a watched instance is sampled for the
|
||||
// console's live view.
|
||||
const LiveSamplePeriodMillis = 500
|
||||
|
||||
// LiveWatchLeaseMillis is how long one watch keeps the live sampler running.
|
||||
// Watchers renew well inside it, so a viewer that goes away costs at most this.
|
||||
const LiveWatchLeaseMillis = 30_000
|
||||
|
||||
// LiveRingSamples bounds the in-memory history a live sampler keeps for a
|
||||
// reconnecting viewer: five minutes at LiveSamplePeriodMillis.
|
||||
const LiveRingSamples = 5 * 60 * 1000 / LiveSamplePeriodMillis
|
||||
|
||||
// Live subjects sit outside metrics.> so nothing that ingests the periodic
|
||||
// CloudWatch batches ever reads a sub-second rate as a period. The account is part
|
||||
// of the subject, so only the poller for that account's instance answers.
|
||||
const (
|
||||
liveWatchSubjectPrefix = "telemetry.watch.ec2."
|
||||
liveSampleSubjectPrefix = "telemetry.live.ec2."
|
||||
)
|
||||
|
||||
var (
|
||||
liveAccountPattern = regexp.MustCompile(`^[0-9]{12}$`)
|
||||
liveInstancePattern = regexp.MustCompile(`^i-[0-9a-f]{8,17}$`)
|
||||
)
|
||||
|
||||
// ValidLiveTarget reports whether accountID and instanceID are safe to place
|
||||
// in a live subject: no NATS token separators or wildcards can get through.
|
||||
func ValidLiveTarget(accountID, instanceID string) bool {
|
||||
return liveAccountPattern.MatchString(accountID) && liveInstancePattern.MatchString(instanceID)
|
||||
}
|
||||
|
||||
// LiveWatchSubject is where a viewer asks the owning node to keep sampling. A
|
||||
// request on it is answered with the ring since LiveWatch.SinceMillis.
|
||||
func LiveWatchSubject(accountID, instanceID string) string {
|
||||
return liveWatchSubjectPrefix + accountID + "." + instanceID
|
||||
}
|
||||
|
||||
// LiveSampleSubject carries one LiveSample per sampling period while watched.
|
||||
func LiveSampleSubject(accountID, instanceID string) string {
|
||||
return liveSampleSubjectPrefix + accountID + "." + instanceID
|
||||
}
|
||||
|
||||
// LiveWatch is the body of a watch. SinceMillis is the newest sample the
|
||||
// viewer already has; zero asks for the whole ring.
|
||||
type LiveWatch struct {
|
||||
SinceMillis int64 `json:"since_ms,omitempty"`
|
||||
}
|
||||
|
||||
// LiveRing answers a watch request with the samples newer than SinceMillis.
|
||||
type LiveRing struct {
|
||||
Samples []LiveSample `json:"samples"`
|
||||
}
|
||||
|
||||
// LiveSample is one live measurement. Every value is a rate over the interval
|
||||
// ending at TimeMillis, measured against the previous sample's clock.
|
||||
type LiveSample struct {
|
||||
TimeMillis int64 `json:"t"`
|
||||
CPUPercent float64 `json:"cpu"`
|
||||
// NetworkIn and NetworkOut are bytes per second from the guest's side.
|
||||
NetworkIn float64 `json:"netIn"`
|
||||
NetworkOut float64 `json:"netOut"`
|
||||
// PacketsIn and PacketsOut are packets per second from the guest's side.
|
||||
PacketsIn float64 `json:"pktIn"`
|
||||
PacketsOut float64 `json:"pktOut"`
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package telemetryv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The console parses these names, so a rename here breaks its live charts.
|
||||
func TestLiveSampleJSONContract(t *testing.T) {
|
||||
data, err := json.Marshal(LiveSample{
|
||||
TimeMillis: 1793450123250, CPUPercent: 12.5,
|
||||
NetworkIn: 48213, NetworkOut: 1290, PacketsIn: 41, PacketsOut: 12,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"t":1793450123250,"cpu":12.5,"netIn":48213,"netOut":1290,"pktIn":41,"pktOut":12}`, string(data))
|
||||
}
|
||||
|
||||
func TestValidLiveTarget(t *testing.T) {
|
||||
assert.True(t, ValidLiveTarget("123456789012", "i-0123456789abcdef0"))
|
||||
assert.True(t, ValidLiveTarget("123456789012", "i-0abc1234"))
|
||||
|
||||
for _, tc := range []struct{ account, instance string }{
|
||||
{"", "i-0123456789abcdef0"},
|
||||
{"12345678901", "i-0123456789abcdef0"},
|
||||
{"123456789012", ""},
|
||||
{"123456789012", ">"},
|
||||
{"123456789012", "*"},
|
||||
{"123456789012", "i-0123.abcdef"},
|
||||
{"123456789012", "i-0123456789ABCDEF0"},
|
||||
{"123456789012", "vol-0123456789abcdef0"},
|
||||
} {
|
||||
assert.False(t, ValidLiveTarget(tc.account, tc.instance), "%q %q", tc.account, tc.instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveSubjectsAreOutsideMetrics(t *testing.T) {
|
||||
assert.Equal(t, "telemetry.watch.ec2.123456789012.i-0abc1234", LiveWatchSubject("123456789012", "i-0abc1234"))
|
||||
assert.Equal(t, "telemetry.live.ec2.123456789012.i-0abc1234", LiveSampleSubject("123456789012", "i-0abc1234"))
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package telemetryv1
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Node live subjects mirror the instance ones, keyed by node name. Only the
|
||||
// super-admin account's gateway handler subscribes to them.
|
||||
const (
|
||||
nodeLiveWatchSubjectPrefix = "telemetry.watch.node."
|
||||
nodeLiveSampleSubjectPrefix = "telemetry.live.node."
|
||||
)
|
||||
|
||||
var liveNodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
// ValidLiveNode reports whether a node name is safe to place in a subject.
|
||||
func ValidLiveNode(node string) bool {
|
||||
return liveNodePattern.MatchString(node)
|
||||
}
|
||||
|
||||
// NodeLiveWatchSubject is where a viewer asks a node to keep sampling itself.
|
||||
// A request on it is answered with a NodeLiveRing.
|
||||
func NodeLiveWatchSubject(node string) string {
|
||||
return nodeLiveWatchSubjectPrefix + node
|
||||
}
|
||||
|
||||
// NodeLiveSampleSubject carries one NodeLiveSample per period while watched.
|
||||
func NodeLiveSampleSubject(node string) string {
|
||||
return nodeLiveSampleSubjectPrefix + node
|
||||
}
|
||||
|
||||
// NodeLiveRing answers a watch with the samples newer than its SinceMillis.
|
||||
type NodeLiveRing struct {
|
||||
Samples []NodeLiveSample `json:"samples"`
|
||||
}
|
||||
|
||||
// NodeLiveSample is one host measurement. Rates are per second over the
|
||||
// interval ending at TimeMillis.
|
||||
type NodeLiveSample struct {
|
||||
TimeMillis int64 `json:"t"`
|
||||
CPUPercent float64 `json:"cpu"`
|
||||
// MemoryUsedPercent is used memory less page cache, as free(1) reports it.
|
||||
MemoryUsedPercent float64 `json:"mem"`
|
||||
Load1 float64 `json:"load1"`
|
||||
// Planes holds the traffic of each plane's physical NICs, keyed wan, lan, vpc.
|
||||
Planes map[string]NodeLivePlane `json:"planes"`
|
||||
}
|
||||
|
||||
// NodeLivePlane is one plane's traffic from the host's side.
|
||||
type NodeLivePlane struct {
|
||||
NetworkIn float64 `json:"netIn"`
|
||||
NetworkOut float64 `json:"netOut"`
|
||||
PacketsIn float64 `json:"pktIn"`
|
||||
PacketsOut float64 `json:"pktOut"`
|
||||
}
|
||||
@@ -51,6 +51,15 @@ request/reply replies.
|
||||
removed its local state and export. These routes and JSON shapes are deployed
|
||||
legacy compatibility behaviour, not a generic EBS-provider interface.
|
||||
|
||||
## Fence route
|
||||
|
||||
`VolumeFencedSubject(node)` is `ebs.<node>.fenced`, or `ebs.fenced` with no
|
||||
node for a single-node daemon. The Viperblock adapter publishes
|
||||
`VolumeFencedEvent` on it after tearing down an export whose volume lease
|
||||
moved to another node; the local daemon subscribes to stop the guest that
|
||||
was using it. Node-addressed, like mount and unmount: the guest is local, and
|
||||
no other node's daemon has anything to stop.
|
||||
|
||||
## Abandon route
|
||||
|
||||
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
|
||||
// with an empty-node fallback for a single-node daemon.
|
||||
// TestVolumeAbandonSubject_BothForms pins the abandon route the same way the
|
||||
// fenced route is pinned: node-addressed, with an empty-node fallback for a
|
||||
// single-node daemon.
|
||||
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
|
||||
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
|
||||
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package viperblocklegacyv1
|
||||
|
||||
// VolumeFencedSubject is where a fenced volume is announced so the daemon on
|
||||
// this node can stop the guest that was using it. Node-addressed: the guest is
|
||||
// local, and every other node's daemon has nothing to do about it.
|
||||
func VolumeFencedSubject(node string) string {
|
||||
if node == "" {
|
||||
return "ebs.fenced"
|
||||
}
|
||||
return "ebs." + node + ".fenced"
|
||||
}
|
||||
|
||||
// VolumeFencedEvent announces that a node tore down a volume's export because
|
||||
// the lease moved. Winner is who holds it now, for the operator reading the
|
||||
// guest's stop reason.
|
||||
type VolumeFencedEvent struct {
|
||||
Volume string `json:"volume"`
|
||||
Node string `json:"node"`
|
||||
Winner string `json:"winner"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package viperblocklegacyv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVolumeFencedSubject_IsNodeAddressed pins the routing. A fenced guest is on
|
||||
// the node that lost the volume, so a subject without the node in it would reach
|
||||
// daemons with nothing to stop and, on a queue group, miss the one that has.
|
||||
func TestVolumeFencedSubject_IsNodeAddressed(t *testing.T) {
|
||||
assert.Equal(t, "ebs.node-a.fenced", viperblocklegacyv1.VolumeFencedSubject("node-a"))
|
||||
assert.Equal(t, "ebs.fenced", viperblocklegacyv1.VolumeFencedSubject(""),
|
||||
"a single-node daemon has no node name, and still has to hear its own fences")
|
||||
}
|
||||
|
||||
// TestVolumeFencedEvent_JSON pins the exact field spelling of the fenced
|
||||
// event, published by viperblockd and consumed by the daemon.
|
||||
func TestVolumeFencedEvent_JSON(t *testing.T) {
|
||||
golden := `{"volume":"vol-1","node":"node-a","winner":"node-b","reason":"volume lease moved to node-b while this node had the volume open"}`
|
||||
event := viperblocklegacyv1.VolumeFencedEvent{
|
||||
Volume: "vol-1",
|
||||
Node: "node-a",
|
||||
Winner: "node-b",
|
||||
Reason: "volume lease moved to node-b while this node had the volume open",
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(event)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, golden, string(marshaled))
|
||||
|
||||
var decoded viperblocklegacyv1.VolumeFencedEvent
|
||||
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
|
||||
assert.Equal(t, event, decoded)
|
||||
}
|
||||
@@ -19,14 +19,14 @@ Every service is `PartOf=spinifex.target` and ordered by its own `After=`/`Wants
|
||||
| `spinifex-predastore.service` | `spx service predastore start` | S3-compatible object storage, serving that host's nodes and the S3 gate among them |
|
||||
| `spinifex-northstar.service` | `spx service northstar start` | Authoritative DNS for `*.spx3.net`, recursive via upstream forwarders, syncing zones from its S3 bucket |
|
||||
| `spinifex-viperblock.service` | `spx service viperblock start` | Block storage with the NBD plugin. `KillMode=process`: nbdkit serving a guest survives a restart of this unit |
|
||||
| `spinifex-daemon.service` | `spx service spinifex start` | VM orchestration, NATS subscriptions, health endpoint. Two `ExecStartPre` steps run first: `spx admin hostnet check`, and `spx admin node-facts collect`, which runs as root and whose failure does not stop the daemon. `KillMode=process`: guest QEMU survives a restart and the new daemon reattaches. `SIGHUP` reloads config |
|
||||
| `spinifex-daemon.service` | `spx service spinifex start` | VM orchestration, NATS subscriptions, health endpoint. `KillMode=process`: guest QEMU survives a restart and the new daemon reattaches. `SIGHUP` reloads config |
|
||||
| `spinifex-awsgw.service` | `spx service awsgw start` | AWS-compatible gateway: SigV4 auth, IAM policy enforcement, TLS. Also serves the Private Admin API |
|
||||
| `spinifex-vpcd.service` | `spx service vpcd start` | VPC daemon: translates `vpc.*` NATS events to OVN logical switches, ports and routers |
|
||||
| `spinifex-ui.service` | `spx service spinifex-ui start` | Web UI serving the React frontend |
|
||||
| `spinifex-qmp-collector.service` | `spx service qmp-collector start` | Reads the `contracts/telemetry/v1` per-VM sidecar, polls telemetry QMP sockets and tap counters, publishes to NATS `metrics.ec2.*` |
|
||||
| `spinifex-shutdown.service` | `/bin/true`; the work is its `ExecStop` | Runs `spx admin node drain --local --unless-restarting`. `After=` every service, so it stops first and drains guests while the stack is still up |
|
||||
| `spinifex-nats-watchdog.service` / `.timer` | `nats-js-watchdog.sh` | JetStream liveness probe every 30s. `try-restart`s NATS when JetStream stops accepting writes while the process stays alive — what `Restart=on-failure` cannot catch |
|
||||
| `spinifex-firewall.service` | `spinifex-firewall-apply boot` | Oneshot nftables input policy, `WantedBy=sysinit.target` rather than the target. Loads the `inet spinifex_conntrack` tracking table on every node, and the policy only once the daemon has written a peer file. No `ExecStop`: remove the policy with `spinifex-firewall-apply disable` |
|
||||
| `spinifex-firewall.service` | `spinifex-firewall-apply` | Oneshot nftables input policy, `WantedBy=sysinit.target` rather than the target. No `ExecStop`: remove it with `nft delete table inet spinifex_filter` |
|
||||
| `regenerate-ssh-host-keys.service` | `ssh-keygen -A` | Oneshot for AMI-built nodes, conditional on the host key being absent |
|
||||
|
||||
#### Operations
|
||||
@@ -95,7 +95,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab; on a host whose IPsec stack is libreswan it defaults to false and `--ipsec=true` is refused), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | 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/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab; on a host whose IPsec stack is libreswan it defaults to false and `--ipsec=true` is refused), `--host` (leader to join; falls back to multicast discovery), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | 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/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--advertise` (defaults to `--bind`, or the auto-detected WAN IP when `--bind` is 0.0.0.0), `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services`, `--skip-host-dns`, `--email`, `--no-telemetry`, `--predastore-compaction-interval`, `--join-timeout` (how long to keep retrying while the formation server is unreachable, default 20m), `--force` (join even though this node is already initialized or holds JetStream streams, **discarding its own CA, master key and JetStream store**; the store is removed only after formation succeeds, and the join is refused while NATS is running even with `--force`) | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations → points the host resolver at this node's local northstar so the Spinifex zones resolve from the node itself (opt out with `--skip-host-dns`) |
|
||||
|
||||
### Version
|
||||
@@ -129,16 +129,14 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|---------|-------|-------------|
|
||||
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. On x86_64 it also checks that the QEMU binary the daemon will run lists the `microvm` machine type and that the microvm firmware `/usr/share/spinifex/microvm/qboot.rom` is installed, since load balancers boot on both. It runs the host network check `spinifex-daemon` and `spinifex-vpcd` run before they start, and fails while firewalld or ufw is active, since the Spinifex nftables policy is the only supported node firewall. It reports a Warning, without failing, when systemd-resolved is active but `/etc/resolv.conf` is not its stub, since lookups then bypass the route that sends Spinifex names to northstar. Exits non-zero if any asset is Missing, Stale, Ungranted, Unsupported or Failed. |
|
||||
| `spx admin node js-probe` | `--timeout` (default: 10s) | Round-trips a canary key through the cluster-state KV bucket and exits non-zero if the write or the read-back fails. JetStream can latch a write error per message block and keep serving reads — nats-server's filestore records the failure on the block and returns it on every later write, and nothing clears it short of a restart. The HTTP health endpoint reports the server-wide JetStream state, which that latch never touches, so only an actual write observes it. |
|
||||
| `spx admin node-facts collect` | — | Runs `dmidecode`, `ethtool`, `udevadm`, `networkctl lldp` and `systemd-detect-virt`, and writes their raw output to `/etc/spinifex/node-facts/`, readable by the spinifex group. `spinifex-daemon.service` runs it as a root `ExecStartPre` because the daemon itself is unprivileged; the daemon parses the files when the operator console asks. A probe that fails writes no new file, so a file from an earlier successful run can remain and the console reads it as available; a source with no file is shown as unavailable. |
|
||||
| `spx admin banner` | `--boot-check` | Writes the node information banner to `/etc/issue` (shown before login on the physical/serial console) and `/etc/motd` (shown after SSH login). With `--boot-check` it also detects a management IP that has changed since last boot and updates `/etc/spinifex/node.conf` accordingly. |
|
||||
|
||||
### Storage Inspection
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. It also lists UEFI variable stores nothing owns: a `uefi/i-*/` prefix with no instance record and no store record, a `uefi/ami-*/` prefix with no AMI document, and a store record that is not `deleting` and has no instance record. These are reported and never deleted, because an absent instance record is not proof of a terminate. |
|
||||
| `spx admin volumes obligations` | — | Lists the open unpublish obligations, oldest first: a volume export that a force detach, or a terminate whose unmount did not complete, left on the node that held the attachment. Each row names the volume, the node, the instance and the age, plus attempts and the last error once the node has tried. The node settles each one once it answers; an obligation stays open while its node is down and closes without the node only once the node has been removed from the cluster. **Reports only.** |
|
||||
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is refused while that node is alive (its daemon heartbeat is under 60 seconds old, or cannot be read) and is otherwise allowed, in which case it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
|
||||
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. |
|
||||
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is allowed, and is preferred over leaving the instance unable to run, but it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
|
||||
| `spx admin storage status` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Sends the signed `GetStorageStatus` action to the AWS gateway with credentials from the standard AWS chain (e.g. `AWS_PROFILE=spinifex`) and prints what the console's Service Metrics page shows: predastore encoding, meta and blob node counts, cluster health, a meta node table (health, raft state, leader, term, commit and applied index) and the blob nodes. Cluster health is `Degraded` when any meta node is unreachable or the nodes reporting raft state do not agree on one leader. A meta node read only through its host's `/readyz` shows healthy with blank raft fields. |
|
||||
|
||||
### Upgrade Management
|
||||
@@ -154,13 +152,12 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `spx admin account create` | `--name` (required) | Connects to NATS → refuses if an account already holds the name (case- and whitespace-insensitive) → reserves the name in `spinifex-account-names` KV → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates default VPC → creates `admin` user, AdministratorAccess policy (Action:*, Resource:*) and one access key → prints credentials and writes a `spinifex-<name>` AWS CLI profile |
|
||||
| `spx admin account create --remote` | `--name` (required), `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA), `--client-token` (default: generated), `--source` (default: `spx-cli`) | Same account, created through `POST /admin/CreateAccount` over SigV4 instead of NATS — the path the public signup form uses, so a shell can exercise it. Credentials come from the standard AWS chain (env vars or `AWS_PROFILE`), never from the cluster master key; the caller must be an IAM user in the super-admin account holding `spinifex:CreateAccount`. Reuse the printed `--client-token` to retry: a fresh token would create a second account. |
|
||||
| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
|
||||
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS credential chain: on a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command fails to find credentials), or equivalent credentials; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
|
||||
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
|
||||
| `spx admin account quota get <account-id>` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Reads the limits in force for one account through `POST /admin/GetAccountQuota` over SigV4, and reports for each dimension whether it came from the `[quota]` block in `awsgw.toml` (`config`) or from an override stored against the account (`override`). Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:GetAccountQuota`. |
|
||||
| `spx admin account quota set <account-id>` | `--vcpus`, `--vpcs`, `--subnets`, `--eips`, `--volumes`, `--volumes-gib`, `--rds-instances`, `--load-balancers`, `--clear`, plus `--endpoint`, `--region`, `--ca-bundle` | Overrides individual limits for one account through `POST /admin/PutAccountQuota`, leaving every dimension not named inheriting the configured baseline. `-1` uncaps a dimension; `0` is a real limit that denies every request on it. `--clear` removes the whole override record and cannot be combined with a limit flag. The account must already exist. Prints the resulting limits, so a caller never has to guess what it applied. The caller must hold `spinifex:PutAccountQuota`. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--non-finalizing` (hidden), `--yes` (requires `--name`), `--name` (account name, must match the stored record), `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Always runs through `POST /admin/DeleteAccount` over SigV4 and follows `DescribeAccountDeletion` to completion; the CLI never tears an account down in its own process, so a CLI delete and an API delete cannot race. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete --force` | as above | Escalates only for resources the ordinary API refuses: it clears a volume attachment in the control plane without the guest's cooperation (`ec2.ForceDetachVolume`), hard-destroys after a graceful attempt times out, and treats an already-missing resource as deleted. It never reorders the stages and never widens scope beyond the account. Use it once the guest is being destroyed — this is the escape from a volume stuck attached to an instance that will not terminate, where neither can otherwise be deleted. |
|
||||
| `spx admin account delete --non-finalizing` | as above | An exercise run, not a finalising delete, but the cleanup is real. Marks the account `TERMINATING`, deletes the resources of each EC2 platform kind the evaluation found quiesced (for example key pairs, empty placement groups and orphaned tags), re-reads the owner inventory against that cleanup, and ends `EXERCISED` with the account still `TERMINATING`. No other stage runs and the account record, name reservation and quota are left in place. It refuses `--force` and `--dry-run`. |
|
||||
| `spx admin account delete --remote` | as above | Accepted for existing scripts and ignored: every delete already goes through the admin API. |
|
||||
| `spx admin account delete --remote` | as above, plus `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Same teardown driven through `POST /admin/DeleteAccount` over SigV4 instead of NATS, then follows `DescribeAccountDeletion` to completion, printing each stage as it lands. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. |
|
||||
| `spx admin principal create <name>` | `--grant` (repeatable or comma-separated; default: every admin method — `CreateAccount`, `DeleteAccount`, `DescribeAccountDeletion`, `GetAccountQuota`, `ListAccounts`, `PutAccountQuota`) | Creates an IAM user in the super-admin account with an inline `spx-admin-methods` policy allowing the requested methods, revokes any access key left from a previous run, and mints one new key. Each method is named rather than granted as `spinifex:*`, so a later addition to the admin surface is not authorised by an existing key. The principal's grants are exactly that one policy: re-running with fewer `--grant` values narrows it, and any other inline policy on the user is removed, so a rename cannot leave a wider grant behind. The secret is printed once; store it in a secret manager or an AWS profile, never in a config file. `admin` is refused as a name — it is the account's own administrator and rotating its key would revoke the bootstrap credential. |
|
||||
| `spx admin principal list` | — | Lists every principal in the super-admin account with its access-key count and what it may call. An attached policy is reported by name (`AdministratorAccess (attached)`) rather than expanded, which is how an unscoped principal is spotted. |
|
||||
| `spx admin principal revoke <name>` | — | Deletes every access key the named principal holds, leaving the user and its policy in place. Immediate and cluster-wide; re-issue with `principal create`. |
|
||||
@@ -195,35 +192,6 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|---------|-------|-------------|
|
||||
| `spx admin eks restore-snapshot` | `--cluster` (required), `--snapshot` (optional, defaults to the latest snapshot in predastore), `--account` (optional, defaults to the bootstrap account) | Single-CP total-loss DR path (fail-safe): validates the snapshot exists in predastore BEFORE any mutation (a typo'd/missing key hard-fails, never resets into an empty datastore) → launches a fresh control-plane VM as a cluster-init seed (replaying the persisted create-time launch template) → sets a required-snapshot `RecoveryDirective` (`cluster-reset`) so the boot-time recovery agent aborts rather than resets-into-empty if it cannot fetch the snapshot → persists the replacement in cluster meta BEFORE re-pointing the NLB (so an NLB failure is convergeable by the reconciler, returned as a provisional status, not a hard error) → re-points the cluster NLB's apiserver and konnectivity target groups from the old CP's ENI to the new one → fences the old CP with retries, failing loudly if it cannot be confirmed terminated (split-brain guard). Any failure before the meta commit unwinds the fresh CP (terminate + clear directive) so a re-run does not stack a second resetting control plane. The returned status is provisional — success means the sequence completed, not that etcd is restored and serving; verify cluster health. HA clusters (a spread with a potentially surviving quorum) are rejected — recover those via quorum reformation instead. |
|
||||
|
||||
### SQS System Instances
|
||||
|
||||
These commands enable SQS on a single-node cluster and start its one system VM.
|
||||
A bound VM is not a ready service: `spx admin sqs status` reports readiness.
|
||||
|
||||
The SQS VM runs two processes on one encrypted data volume: the Region's dedicated SQS NATS server, which listens only on the guest's loopback address and is separate from platform NATS, and the worker, which serves SQS owner calls from that local store. The worker starts serving only after the local store answers and the receipt-signing key on the volume matches the fingerprint the platform recorded.
|
||||
|
||||
SQS supports only a fresh single-node install. A worker record without a supported launch contract, or any record of the separate SQS store VM from an earlier dev build, cannot be upgraded: `enable` refuses it, and any existing volume is kept, not inspected, emptied or replaced. An existing separate store VM is refused on relaunch with its volume kept, and an existing worker VM is not restarted into a supported mode; upgrading such an install is unsupported.
|
||||
|
||||
**Do not put tenant production data in SQS.** SQS-managed per-message encryption is not implemented: `GetQueueAttributes` reports `SqsManagedSseEnabled=false`, and a request to enable SSE-SQS or SSE-KMS is rejected.
|
||||
The SQS data volume must be encrypted, which is a separate layer under every queue and not SSE.
|
||||
There is no `DeleteQueue` or `PurgeQueue`, so a queue and its unexpired messages cannot be removed once created.
|
||||
Retention is checked when a message is received, against the queue's retention at that moment, and nothing deletes an expired message: the store drops a body only at its age backstop of 14 days and 1 hour.
|
||||
So a message past a short retention can be delivered again if `MessageRetentionPeriod` is later raised while its body is still stored.
|
||||
Account termination does not yet remove SQS queues or messages, and there is no disable: the VM's volume, receipt key and data are kept.
|
||||
|
||||
Each sends a signed `spinifex` admin action (`EnableSQS`, `DescribeSQS`, `EnsureSQSWorker`) to the AWS gateway using the standard AWS credential chain, for example `AWS_PROFILE=spinifex`. The caller must be in the super-admin account (`000000000001`) and hold `spinifex:<Action>`. The VM, volume, private VPC and ENI belong to the system account (`000000000000`). `--endpoint`, `--region` and `--ca-bundle` default to this node's gateway, region and `config/ca.pem`; off a node, `--endpoint` and `--region` are required. `ensure-worker` and `enable` wait up to 11 minutes, and `status` up to 1 minute.
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin sqs enable` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Records SQS as enabled for the Region, pinned to `--node`, then launches or reports the SQS VM there. The desired state is recorded first, so rerunning the command resumes an interrupted enable. More than one node in the cluster is refused before anything is recorded. A worker record with a missing or unsupported launch contract, or a record of the separate store VM, is also refused before anything is recorded or launched; any existing volume is kept, and upgrading it is not supported. |
|
||||
| `spx admin sqs status` | `--endpoint`, `--region`, `--ca-bundle` | Shows the requested state, node, generation, VM state, receipt key state, whether a worker is polling this gateway, whether this gateway serves the SQS API, and `Ready`, which is true only when all of them hold. `Unsupported` names a record whose missing or unsupported launch contract blocks enable. |
|
||||
| `spx admin sqs ensure-worker` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The VM is pinned to its first node; another node is refused with `InvalidParameterValue`, and there is no takeover. It gets a private system VPC with one /24 subnet and no internet gateway, NAT or Elastic IP, and an encrypted system-account data volume of `--volume-size-gib` that holds the queue data and the receipt-signing key the guest creates on first boot. The platform records only the key's fingerprint. A different `--instance-type` is accepted only while no volume, network interface or launch step has been attempted; once any has, it is `IncorrectState`, whether or not the step completed. A different `--volume-size-gib` than the recorded one is `IncorrectState`. |
|
||||
|
||||
`ServiceUnavailable` never proves that no SQS resources exist, because an earlier attempt may already have created some. Its message says what it does establish. When no node serves the subject, this request was not delivered. When a volume create was not sent, that particular create did not happen. When the node cannot boot the guest yet or its launcher lacks a capability, this request stopped before allocating or recording anything. When the gateway times out waiting for the node's reply, nothing is known: the launch may still be running, so never assume it created nothing. In every case, call the command again with the same arguments. A retry first checks the durable record of the attempt and continues only when that is safe; it can proceed to the first launch after a create that was proven unsent, and otherwise reports the recorded outcome as bound or `IncorrectState`, or returns `ServiceUnavailable` again. `IncorrectState` from a volume, network interface or launch step means that step was recorded as sent but its outcome was never observed. It is never resent, the message names the volume when its ID is known, and no tool reconciles it yet: recovery is manual.
|
||||
|
||||
`bound` says only that the VM was launched for the current record. The worker is ready only when the guest's local store answers, and it serves only while its receipt key matches the recorded fingerprint; a missing or different key once one is recorded refuses serving and is never replaced.
|
||||
Real VPC routing, IMDS and gateway TLS from the guest have not been tested on a live host.
|
||||
|
||||
## Private Admin API
|
||||
|
||||
Not an AWS API: a Spinifex-internal surface on the AWS gateway, JSON in and JSON out, one method per path segment at `POST /admin/<Method>`. It exists so a self-service signup form, a provisioning system or a load-test harness can create and remove tenant accounts without an operator shell on a node.
|
||||
|
||||
@@ -33,42 +33,76 @@ The gateway (`spinifex/runtime/roles/awsgw/awsgw.go`) is the entry point:
|
||||
// Connect to NATS (retries while the local broker comes up)
|
||||
natsConn, err := natsmsg.ConnectNATSWithRetry(...)
|
||||
|
||||
// Load IAM (master key + JetStream KV), then compose the gateway: composeGateway
|
||||
// sets every production hook and wires the service registry from each domain's
|
||||
// awsapi registration.
|
||||
gw, err := composeGateway(gatewayInputs{
|
||||
NATS: natsConn,
|
||||
IAM: iamService,
|
||||
Credentials: credentials,
|
||||
Admission: admission,
|
||||
// Load IAM (master key + JetStream KV) and create the gateway
|
||||
gw := gateway.GatewayConfig{
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
IAMService: iamService,
|
||||
// ...
|
||||
}, ecrControlPlane, acmControlPlane, ec2ControlPlane /* ... */)
|
||||
}
|
||||
|
||||
// Serve over TLS
|
||||
server.ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
Request routing (`spinifex/ingress/aws/pipeline/gateway.go`):
|
||||
Request routing (`spinifex/gateway/gateway.go`):
|
||||
|
||||
1. **Public routes**: `/oidc/eks/...` and `/v2` (the ECR registry) are mounted ahead of SigV4 by `runtime/roles/awsgw`
|
||||
2. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
|
||||
3. **Throttling**: Per-account+action token bucket rejects bursts post-auth
|
||||
4. **Service Detection**: Extracts service name from the Authorization header
|
||||
5. **Action Dispatch**: Every service is a registration (`ingress/aws/dispatch`) built by its domain's `awsapi.NewRegistration` and wired by `runtime/roles/awsgw`. The pipeline dispatches by registered service name and holds no per-service code. The private `/admin/<Method>` surface dispatches to the registration `awsgw` names as `Admin.Service`.
|
||||
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 from the Authorization header
|
||||
4. **Action Dispatch**: Routes to service-specific handler. A service registered through the ingress dispatch registry (`ingress/aws/dispatch`, wired by `runtime/roles/awsgw`) is dispatched by its registration; currently those are the ECR and ACM control planes, registered from `domains/ecr/awsapi` and `domains/acm/awsapi`. Every other service uses the legacy switch:
|
||||
|
||||
```go
|
||||
switch svc {
|
||||
case "ec2":
|
||||
err = gw.EC2_Request(w, r)
|
||||
case "iam":
|
||||
err = gw.IAM_Request(w, r)
|
||||
case "sts":
|
||||
err = gw.STS_Request(w, r)
|
||||
case "elasticloadbalancing":
|
||||
err = gw.ELBv2_Request(w, r)
|
||||
case "eks":
|
||||
err = gw.EKS_Request(w, r)
|
||||
case "bedrock":
|
||||
err = gw.Bedrock_Request(w, r)
|
||||
// ... bedrock-runtime, bedrock-agent, bedrock-agent-runtime
|
||||
case "ecs":
|
||||
err = gw.ECS_Request(w, r)
|
||||
case "rds":
|
||||
err = gw.RDS_Request(w, r)
|
||||
case "tagging":
|
||||
err = gw.Tagging_Request(w, r)
|
||||
case "spinifex":
|
||||
err = gw.Spinifex_Request(w, r)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. EC2 Handler
|
||||
|
||||
The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses the `Action` parameter and looks the handler up in its action table. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal. Each action's handler is built from the owning EC2 resource package (`domains/ec2/instance`, `volume`, `vpc` and so on), with PassRole, quota and client-token idempotency wrapped on at the action table rather than inside the resource.
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via the generic `ec2Handler` and `ec2HandlerWithReq` wrappers. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
|
||||
|
||||
```go
|
||||
"RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
// ... PassRole and launch-quota checks, then the vCPU quota charge
|
||||
return ec2instanceapi.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
|
||||
}),
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstancesChecked(ctx, input, gw.NATSConn, 0, gw.NodeIDs, accountID)
|
||||
}),
|
||||
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
|
||||
// security groups, network interfaces, elastic IPs, placement groups, key pairs,
|
||||
// images, tags, capacity reservations, spot instances
|
||||
```
|
||||
|
||||
### 4. NATS Messaging
|
||||
|
||||
The gateway communicates with daemons via NATS request/response. Most calls go through `natsmsg.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
|
||||
|
||||
```go
|
||||
func runIntoReservation(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID, crID string) (*ec2.Reservation, error) {
|
||||
subject := "ec2.RunInstances.cr." + crID
|
||||
reservation, err := natsmsg.NATSRequest[ec2.Reservation](ctx, natsConn, subject, input, 5*time.Minute, accountID)
|
||||
// ...
|
||||
func (s *NATSInstanceService) RunInstances(ctx context.Context, input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
|
||||
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
|
||||
return natsmsg.NATSRequest[ec2.Reservation](ctx, s.natsConn, topic, input, 5*time.Minute, accountID)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -112,16 +146,14 @@ When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
|
||||
A UEFI instance whose record carries a variable store pointer has no EFI volume: its `vars.fd` is fetched from the object under `uefi/` in the configured Predastore bucket that the pointer names, recorded in the `spinifex-uefi-stores` JetStream bucket (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a record with both or neither is refused.
|
||||
RunInstances still creates the EFI volume, nothing writes `vars.fd` back to the object store yet, and a terminated instance's store is removed by a cluster-wide reaper.
|
||||
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
|
||||
3. **Volume Mount**: Sends a mount request to the colocated Viperblock worker on `ebs.{node}.mount`, 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
|
||||
// Volume mounting through the EBS provider contract (runtime/daemon/vm_adapters.go)
|
||||
published, err := a.provider.PublishVolume(ebsprovider.WithAccountID(ctx, accountID), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewLatestVersioned(), VolumeID: req.Name, NodeID: a.node})
|
||||
// Volume mounting via NATS (runtime/daemon/vm_adapters.go)
|
||||
reply, err := ebsRequestWithTrace(ctx, a.nc, instance.AccountID, viperblocklegacyv1.MountSubject(a.node), ebsMountRequest, 30*time.Second)
|
||||
|
||||
// QEMU launch with NBD-backed drives in args (runtime/compute/vm/vm.go)
|
||||
cmd := exec.Command(qemuArchitecture, args...) // qemu-system-x86_64 or qemu-system-aarch64
|
||||
@@ -149,17 +181,12 @@ type Daemon struct {
|
||||
|
||||
Tracks available and allocated CPU/memory to prevent overcommit, and drives the dynamic `ec2.RunInstances.{type}` subscriptions:
|
||||
|
||||
One instance vCPU is one CPU the kernel has online: a thread with SMT on, a core with SMT off or on ARM, as AWS counts a vCPU.
|
||||
The OS and Spinifex's own services keep 1 CPU on a host with up to 8, 2 up to 16, 4 up to 63 and 8 from 64, and every other CPU is schedulable one-to-one with no overcommit.
|
||||
[ADR-0015](https://github.com/mulgadc/mulga/blob/main/docs/adr/0015-host-cpu-capacity-and-reservation.md) fixes this rule.
|
||||
|
||||
|
||||
```go
|
||||
type ResourceManager struct {
|
||||
mu sync.RWMutex
|
||||
hostVCPU int // online CPUs (ADR-0015:S1)
|
||||
hostVCPU int // raw runtime.NumCPU
|
||||
hostMemGB float64 // raw /proc/meminfo MemTotal
|
||||
reservedVCPU int // ADR-0015:S2 tier: 1, 2, 4 or 8
|
||||
reservedVCPU int // held back for spinifex services
|
||||
reservedMem float64 // held back for spinifex services
|
||||
allocatedVCPU int
|
||||
allocatedMem float64
|
||||
@@ -202,58 +229,6 @@ type EBSRequest struct {
|
||||
|
||||
Viperblock responds with an NBD URI that QEMU uses to access the block device.
|
||||
|
||||
#### Direct-boot microVM data volumes
|
||||
|
||||
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
|
||||
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
|
||||
The microVM boots with `pcie=on`, so its NICs are `virtio-net-pci` devices with MSI-X interrupts instead of `virtio-net-device` MMIO devices.
|
||||
Each data volume becomes a `virtio-blk-pci` device on the default PCIe bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the q35 device, and takes no PCIe hot-plug port.
|
||||
A microVM started without `pcie=on` keeps the `virtio-*-device` MMIO transports.
|
||||
|
||||
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
|
||||
Disk-boot (q35) instances keep the existing PCI hot-plug path.
|
||||
|
||||
Hot-attaching a volume to a running direct-boot microVM is not supported, because the microVM has no PCIe hot-plug root port to plug into.
|
||||
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
|
||||
|
||||
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
|
||||
|
||||
#### Volume leases
|
||||
|
||||
Before opening an engine on a volume, the Viperblock worker takes a lease: one entry per volume in the `VIPERBLOCK_VOLUME_LEASES` JetStream KV bucket, which expires 45 seconds after its last write (`providers/ebs/viperblock/volume_lease.go`).
|
||||
A renewal becomes eligible once the last confirmed write is 10 seconds old, and the holder checks for that periodically, so the rewrite lands some time after that point.
|
||||
Each rewrite is conditioned on the revision the holder last wrote, and a refused write that finds the entry gone or naming another owner or generation fences the export as a takeover.
|
||||
It may keep writing for 40 seconds after the last write the store acknowledged, measured from when that write was sent, because the server starts the entry's expiry when it applies the write and never earlier.
|
||||
A slow acknowledgement therefore shortens the remaining validity rather than extending it, and an acquisition acknowledged after its validity has already run out is refused as retryable.
|
||||
|
||||
For a holder that cannot renew, a timer is armed for the validity deadline, leaving a nominal 5 seconds before the entry can expire; when it fires, the export's nbdkit is fenced with SIGKILL (`volume_fence.go`).
|
||||
The kill comes first: the read that names the winner (bounded at 2 seconds) and the notice to the daemon follow it, so a hung lease store cannot hold the writer alive.
|
||||
The notice is a request that viperblockd repeats every 2 seconds until the local daemon replies with `VolumeFencedAck`, because the fence often runs while the node's NATS server is restarting.
|
||||
On the notice the daemon moves the guest using the volume to `error` with state reason `Server.VolumeFenced` and SIGKILLs its QEMU immediately rather than shutting it down gracefully, because there is no disk left to flush to; it only stops the guest process and removes its taps, and never terminates the instance or deletes a volume.
|
||||
The fence runs on its own goroutine, so Go scheduling, that goroutine and process termination all delay the kill, and enough delay can use up the whole margin; a kill held in uninterruptible sleep, which the fence waits up to 30 seconds for, is the extreme case.
|
||||
Once a lease is lost or has lapsed it stays lost: a renewal that succeeds afterwards is ignored, and another open on the same node cannot share it while the fence runs.
|
||||
|
||||
These rules bound only the holder's own clock.
|
||||
The storage backend does not reject a stale writer, so a paused or partitioned host that resumes after its validity can still write; generation enforcement at the write boundary is required by Q-24 in the umbrella repository's `docs/specs/viperblock/rfc3-1_overview.md` and is not implemented.
|
||||
An export that survives a daemon restart writes without a renewed lease until recovery adopts it, and a survivor that recovery cannot adopt while a guest still uses it is left running without a lease or a fence.
|
||||
|
||||
#### Unsealed writes and start elsewhere (interim)
|
||||
|
||||
A volume is unsealed while its node holds writes the backend has not confirmed; the node records this in a dirty marker naming itself as owner (`providers/ebs/viperblock/volume_dirty.go`).
|
||||
Opening such a volume on another node loses those writes, so two guards stop it while the previous owner may still hold them.
|
||||
This is an interim guard until the instance lifecycle work defines ownership, and it is not a fence.
|
||||
|
||||
When `ec2.start` is forwarded to the node that last ran a stopped instance and that node refuses for capacity, the forwarding node normally starts the instance locally.
|
||||
If the refusing node still holds unsealed writes for any of the instance's volumes, it sets the `X-Unsealed-Writes` NATS reply header and the forwarder relays `InsufficientInstanceCapacity` instead of starting elsewhere.
|
||||
The check asks the owner's viperblockd over the per-node `unsealed` verb with a 5 second bound, and a check that fails or times out counts as holding, so the refusal is kept.
|
||||
|
||||
Independently, a node that finds another node's dirty marker on a volume refuses to open it when that node's daemon heartbeat (`heartbeat.<node>` in the cluster-state bucket) is under 60 seconds old.
|
||||
It also refuses when the heartbeat cannot be read or no heartbeat source is wired, and the refusal maps to the held-elsewhere error that recovery retries.
|
||||
A previous owner that has stopped heartbeating is opened over with a warning, as before.
|
||||
The heartbeat age is compared across node clocks, so the 60 second window is also the clock-skew allowance.
|
||||
|
||||
EKS control-plane recovery treats both refusals, capacity and held-elsewhere, as retryable: it keeps retrying the stopped member and does not count them against its restart attempts.
|
||||
|
||||
### Predastore (S3)
|
||||
|
||||
Object storage used for:
|
||||
@@ -281,60 +256,6 @@ file. It does not connect to Predastore to do so, and no Predastore node
|
||||
exposes a status endpoint. This is an operator status interface, not a generic
|
||||
storage-provider contract.
|
||||
|
||||
### SQS message store (internal)
|
||||
|
||||
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
|
||||
It is used only by the SQS worker inside the SQS system instance and does not itself deliver an SQS API.
|
||||
Spinifex ships no host service, unit, user or firewall rule for the dedicated JetStream server; the system instance that runs it is described under *SQS system instance (operator-launched)*.
|
||||
|
||||
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
|
||||
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
|
||||
|
||||
The adapter provisions a fixed set of shared message streams (`SQS_M_<shard>`, one subject per queue) and shared state buckets (`sqs_state_<shard>`), keeps historical ones, and applies a 14-day-plus-one-hour retention backstop to both.
|
||||
It supports one node (R1) and three nodes (R3) only and refuses any other declared cluster size before provisioning.
|
||||
|
||||
Reads that must observe a completed write or delete go to the stream leader: `GetState`, the state-key pass behind `Count` and `Namespaces`, and the body pass behind `Namespaces` and `Scan`.
|
||||
This avoids the follower-served direct gets and follower-placed consumers that returned purged data in local three-node fault-injection tests; it is not a general linearizability guarantee.
|
||||
Each leader read is one request per record, and the body pass transfers each full body internally even though `Scan` returns metadata only, which is a material cost for a large backlog.
|
||||
A state-key pass is bounded but is not a snapshot when a key is overwritten during it.
|
||||
|
||||
The feed reads through an in-memory, headers-only ordered consumer.
|
||||
If that consumer delivers nothing for about two seconds while the stream holds a change after the feed's cursor, the feed replaces it from the cursor, so a stored change does not wait on a stalled consumer; the cause of those stalls is not established.
|
||||
`Close` is terminal, waits for consumer opens and deletes in progress and retries failed deletes, but cleanup is best effort for a create whose outcome is unknown: a consumer that lands after its last delete is removed by the server's one-minute inactive threshold.
|
||||
|
||||
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
|
||||
|
||||
### SQS system instance (operator-launched)
|
||||
|
||||
One system-owned microVM per Region runs both the SQS store and the SQS worker as separate processes, on one encrypted data volume pinned to one node (ADR-0011 Amendment 5, ADR-0012 Amendment 6).
|
||||
Only an operator activates it with the super-admin `EnsureSQSWorker` or `EnableSQS` actions; nothing starts it at daemon startup, and platform NATS is neither used nor changed for SQS data.
|
||||
This first profile is fresh single-node only: a host cluster of more than one node is refused before anything is recorded or allocated, and there is no disable.
|
||||
|
||||
`make build-sqs-worker-microvm-image` builds the one `sqs-worker` image: the static `sqs-worker` binary, the pinned `nats-server`, the ext4 tools and a network `/init`; releases ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker`.
|
||||
At boot the guest reads three firmware config values under `opt/spinifex/sqs-worker/` (the data disk's serial, its Region and the gateway URL on this node's mgmt bridge) and refuses before touching any disk if one is missing or malformed.
|
||||
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and confirms from `/proc/self/mountinfo` that the whole disk is what is mounted; the guest never formats.
|
||||
|
||||
The store is a dedicated `nats-server` listening only on `127.0.0.1`, with its JetStream directory at `/data/jetstream` and `GOMEMLIMIT=512MiB` (ADR-0011:S11), so nothing outside the VM can reach it and it needs no network identity of its own.
|
||||
The store starts only after the receipt key is settled, and the worker connects only after the store answers JetStream account info, so an unavailable store never appears ready.
|
||||
|
||||
The receipt-signing key lives on the data disk under `/data/sqs-worker-key`, written to a temporary file, synced, renamed and its directory synced before its fingerprint is recorded.
|
||||
`domains/sqs/workerident` holds the platform's receipt-key state on the worker's identity record, beside its generation, claim, pinned node and volume: `pending` is set only when the record first creates its volume, and `recorded` holds the fingerprint.
|
||||
Under `pending` the guest may create a key, or reuse a valid key it already wrote before a crash, only while no `/data/jetstream` directory exists; any store data under `pending` refuses, because it proves the volume is not new.
|
||||
Under `recorded` a missing, unreadable or different key refuses the boot and is never regenerated; a record with no receipt-key state is refused as not proven new.
|
||||
`RecordReceiptKey` writes the fingerprint by compare-and-swap that re-checks the current instance, generation, claim and volume, and a lost reply is answered by reading the record again, never by a new key.
|
||||
A replacement worker reuses the pinned volume and keeps its predecessor's receipt-key state.
|
||||
|
||||
The worker reaches the gateway over TLS 1.3 trusting only the cluster CA, signing with instance-role credentials from the metadata service only.
|
||||
The internal `spinifex-sqs` actions are `ClaimReceiptKey`, `RecordReceiptKey`, `PollWork` and `CompleteWork`; the gateway admits only an assumed-role session of `sqsWorkerRole` in the system account and resolves the instance through `workerident.Authorize`, a point-in-time check against the current bound record.
|
||||
The worker pulls tenant work from its own node's gateway relay and posts results back, so no SQS payload crosses platform NATS; the relay holds a bounded, in-memory set of waiting requests and answers `ServiceUnavailable` when it is full or no worker is polling.
|
||||
|
||||
`EnsureSQSWorker` takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB`; the instance type's memory must exceed the store's 512 MiB limit, and a later request for a different type or size is refused as `IncorrectState`.
|
||||
`EnableSQS` records the Region's desired state, pinned to one node, and then ensures the worker; `status` reports it ready only when the worker is bound, polling and the tenant API is registered.
|
||||
A store launch record or VM from the earlier separate-store design, or a worker record without a supported launch contract, is refused with its volume kept: enable stops before recording anything, and a relaunch of an old store VM is marked failed rather than started.
|
||||
|
||||
Evidence is local: unit tests, the guest shell test and an AWS SDK test that creates a queue and sends, receives and deletes a message through the real worker loop against a real local JetStream store and a restart.
|
||||
A real guest boot, its metadata route on a real ENI and the signed path on a live node are unproven, Viperblock's at-rest key is held by the platform, and SQS-managed message encryption is not implemented, so the profile must not hold tenant production data.
|
||||
|
||||
## Configuration
|
||||
|
||||
Cluster configuration (`spinifex/bootstrap/config/config.go`):
|
||||
|
||||
@@ -60,7 +60,7 @@ intact; ADR-0001 explicitly defers that design.
|
||||
|
||||
| Current path | Standing | Target ownership | Rule |
|
||||
|---|---|---|---|
|
||||
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. Debt: it reads each domain's declared `OperationInventory` from `domains/<service>/awsapi` (ACM, EC2, ECR, ECS, EKS, ELBv2, IAM, RDS, Route 53, SQS, STS) and never constructs a registration. |
|
||||
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. |
|
||||
| `cmd/dhcptest` | Development diagnostic | Thin main; reusable code in `internal/tooling` if extraction is useful | It is a DHCP probe, not a supported `spx admin` command. |
|
||||
| `cmd/spx-loadgen` | Qualification/load tool | Thin main; reusable code in `internal/testkit` | It drives test load and is not shipped to nodes. |
|
||||
| `tests/e2e/*/cmd/*`, `tests/bench/*/cmd/*` | Test and benchmark-private tools | Remain test-private | They are invoked by a test or benchmark harness, not a product process. |
|
||||
@@ -351,27 +351,8 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `spinifex#1269` | `spinifex/handlers/elbv2` | `spinifex/domains/elbv2` (package `elbv2`) | A mechanical whole-package relocation under the 2026-10-26 ADR-0001 amendment: the ELBv2 control plane keeps its files, tests and current dependencies, and importers alias it `elbv2domain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/elbv2`. Q-30's data-plane boundary stays deferred, recorded as `agents/elbv2` (Q-30 data-plane boundary) debt. |
|
||||
| `spinifex#1271` | `spinifex/handlers/ecs` | `spinifex/domains/ecs` (package `ecs`) | A mechanical whole-package relocation: ADR-0001's `handlers/ecs` row targets `domains/ecs`, the files are already organised by resource, and resource packages are introduced only where callers need a compiler boundary. The ECS control plane keeps its files, tests and current dependencies alongside the already-extracted `domains/ecs/taskdefinition`, and importers alias it `ecsdomain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/ecs`. `handlers` has no package left and is a retired legacy root; R5 holds `domains/ecs` to the interim rule's ban on importing the daemon. |
|
||||
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
|
||||
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
|
||||
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
|
||||
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` moved onto the same registration in `mulga-3nbla.7`, recorded below. |
|
||||
| `spinifex#1344` | `spinifex/gateway/rds.go` (`RDS_Request`, `rdsCaller`) | `spinifex/domains/rds/awsapi` (`NewRegistration`, `Deps`, `Quota`, `CallerFromActor`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | RDS registers through the ADR-0002 seam with the XML envelope it had. The check order is kept: query parse, action, account, the principal-class gate for the internal agent actions, scope, authorization, NATS, quota and dispatch, so a denied caller still gets `AccessDenied` before the handler runs and no customer grant is evaluated against an agent action. The gateway members RDS read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `ExpectedNodes` as `Deps.ExpectedNodes`, `Quota.EnforceRDSInstances` as the `Deps.Quota` interface, `Region` as `Invocation.Region` and the policy gate as `Invocation.Authorize`. A nil `Deps.Quota` skips the DB-instance cap. The caller comes from `dispatch.Actor`: the session name for an assumed-role session and the user name otherwise, and the role name from `Actor.RoleName()`, which parses the underlying role ARN and never the session name. The account comes from `Invocation.AccountID`, so a caller with no composable ARN still reaches the policy check and is refused there as before. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/rds/awsapi` allowlist row are removed, and `cmd/aws-model-coverage` reads the inventory under a fifth named-exception allowlist row. The characterization tests that pin the pipeline behaviour were committed first with spinifex#1336 and pass unchanged against the registration; only the `withRDS` and `serveRDS` test helpers and the setup of the direct-call RDS tests changed. |
|
||||
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
|
||||
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
|
||||
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
|
||||
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
|
||||
| `spinifex#1345` | `spinifex/gateway/admin*.go` (the `/admin/{method}` handlers, gates and body cap) and the `AccountQuotaRequest`/`AccountQuotaResponse`/`DeleteAccountResponse` wire types | `spinifex/operator/admin` (`rest.go`, `account_*.go`), served by the same `spinifex` registration | `/admin/{method}` stays a chi route in `gateway`, ahead of the service selector, but `Admin_Request` now only mints the request ID and header and hands the request to the registered entry through `dispatchRegistered`, the path every Query request takes. `dispatch.Invocation` gains `Service`, the credential-scope service, so the scope gate is the first identity gate in the dispatcher and a key signed for another service is denied exactly as before. The order is unchanged: verb and method, then the five fail-closed gates (scope, super-admin account, IAM user, identity, `spinifex:<Method>` policy), then the cluster check, then the 8 KiB body cap. The dispatcher returns errors and the route renders them as the JSON envelope with `requestId`, then logs once from the real result, so a denial or a 5xx is never logged as a completed request; the registration's `ResolveAction` labels the method, which removes the `auth.go` special case. The caller is read from `dispatch.Actor` (`UserName` for the gate and `updatedBy`). `BucketStore` and `SignupMaxAccounts` leave `GatewayConfig` for `operator/admin.Deps`, composed in `awsgw` beside `IAM`, `Quota`, `ExpectedNodes` and `NodeIDs`. The three wire types that could not enter `contracts` move to `operator/admin` with their handlers, `operator/cli` imports them from there, and the last `operator/cli` → `gateway` allowlist entry is deleted. |
|
||||
| `spinifex#1346` | `spinifex/gateway/iam.go` (`IAM_Request`, the IAM action table, `getUserForCaller`, the PassRole check) | `spinifex/domains/iam/awsapi` (`NewRegistration`, `Deps`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | IAM registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service and account checks, then one parse of the input, the `GetUser` default for a missing `UserName`, scope resolution, authorization, the `iam:PassRole` check for `AddRoleToInstanceProfile`, then the handler. The caller is read from `dispatch.Actor` (`PrincipalType`, `UserName`), with no ARN parsing. The dependencies are `Deps.IAM` and `Deps.CountInstanceProfileAssociations`, composed in `awsgw`, and the gateway keeps no IAM fallback. The `cmd/aws-model-coverage` named exception gains an `iam` row. |
|
||||
| `spinifex#1346` | `spinifex/gateway/sts.go` (`STS_Request`, the STS action table, the anonymous STS dispatch) | `spinifex/domains/sts/awsapi` (`NewRegistration`, `Deps`, `ActionNames`, `NewAnonymousAssumeRoleWithWebIdentity`), composed by `spinifex/runtime/roles/awsgw` | STS registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service check, caller, the `AssumeRole` identity gate scoped to the stored role ARN, then the handler. The caller is read from `dispatch.Actor` (`CallerARN`, `UserName`, `SessionName`, `UserID`, `AccessKeyID`), with no ARN parsing. The gateway's authorization failure wraps a generic `dispatch.PolicyDenialError`, and STS matches it to answer with the caller, action and resource, so the gateway holds no STS-specific denial code. An unsigned `AssumeRoleWithWebIdentity` stays ahead of SigV4 in the gateway and reaches the handler through `GatewayConfig.AnonymousSTS`, a func composed in `awsgw`, never the STS service. The `cmd/aws-model-coverage` named exception gains an `sts` row. |
|
||||
| `spinifex#1348` | `spinifex/gateway/ecs.go` (`ECS_Request`) and `spinifex/gateway/eks.go` (`EKS_Request`, the EKS route table) | `spinifex/domains/ecs/awsapi` and `spinifex/domains/eks/awsapi` (`NewRegistration`, `Deps`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | ECS (AWS JSON 1.1) and EKS (REST JSON) register through the ADR-0002 seam with the JSON error envelopes they had, both declared as AWS JSON 1.1 (see the envelope divergence below). The check order and every response are kept, pinned by the pipeline characterisation tests committed first. EKS and ECS resolve roles through `foundation/aws/passrole` `Check` with `domains/iam` `RoleResolver`: a malformed or foreign role is refused first, a missing role is authorized as written before it is reported, and a resolver outage is returned unchanged. Role handling now follows the AWS capture of 2026-10-30. ECS RegisterTaskDefinition refuses a foreign role with `ClientException` "Role is not valid" before PassRole and accepts a nonexistent, invented-path or non-ARN role from a caller who can pass roles. ECS RunTask refuses a foreign `taskRoleArn` or `executionRoleArn` override the same way and answers a nonexistent or invented-path one with "ECS was unable to assume the role". EKS CreateCluster and CreateNodegroup answer a non-ARN or foreign role with `AccessDeniedException` "Cross-account pass role is not allowed.". EKS public routes now report every parameter fault as `InvalidParameterException`, the code the EKS model declares, while the internal control-plane routes keep `InvalidParameterValue` because the guest agents match on it. Two deliberate EKS divergences remain. First, CreateCluster still refuses a nonexistent or invented-path role with `InvalidParameterException` "role ... does not exist", where AWS accepts it and the cluster later ends FAILED. Our EKS only stores, validates and echoes the cluster role and never assumes it, so accepting would leave an ACTIVE cluster bound to a role that does not exist. Second, CreateNodegroup keeps its existing refusal of a nonexistent role because AWS was not captured for it. A third divergence is the error envelope. EKS is restJson1 but registers `ErrorEnvelopeJSON`, so errors carry `application/x-amz-json-1.1` and a `__type` body where AWS sends `application/json` and a `message` body. The `X-Amzn-Errortype` header is correct and SDKs read the code from it, so impact is low. Switching to `ErrorEnvelopeRESTJSON` also changes the throttle code and the default internal-error code (`ServiceException` where the EKS model declares `ServerException`), so it is left for its own change. Verified live on env14: the ECS and EKS role cases above, and a valid CreateCluster reaching ACTIVE with the control-plane agent publishing through the gateway. The EKS route table and its tests move with the registration, and the gateway keeps no ECS or EKS fallback. The `cmd/aws-model-coverage` named exception gains an `ecs` and an `eks` row, and the two `gateway` allowlist rows are removed. |
|
||||
| `spinifex#1350` | `spinifex/gateway` (`ecr.go`, `ecrauth_middleware.go`, the `/v2/token` exchange, `ecr_principal.go` with `ResolveECRPrincipal`, the per-operation `/v2` authorization, the registry host matcher, `ctxECRPrincipal`) | `spinifex/domains/ecr/registry` (`Bridge`, `NewBridge`, `BridgeConfig`, and its own `Principal`, `Rehydrator`, `Authorizer`, `Admission` types with `ErrInvalidCredential` and `ErrUnavailable`), `spinifex/ingress/http` (`HostMatcher`, `TargetAccount`, `RequestClientIP`; names no service), `spinifex/ingress/aws/credential` (`Rehydrator`, `Principal.CallerARN`), and under `spinifex/runtime/roles/awsgw`: `credverify` (`Rehydrate`, sharing the SigV4 key and session lookups), `policyauth`, `accountadmission`, `registryadapt` | ADR-0001 amendment 2026-10-29 and ADR-0005:S2: the OCI `/v2` bridge and its token auth move out of the gateway, and ECR resolves its principal through the credential capability instead of reading `IAMService` and `STSService` directly. ADR-0002:S5 keeps `/v2` outside the seam, so the gateway keeps only a `RegistryHandler` mount that answers 501 when unset. `domains/ecr/registry` imports no ingress package (`TestADR0001_Layering` unchanged): the principal, rehydrator and authorizer are registry-local types and `registryadapt` maps `credential.Failure` codes onto `ErrInvalidCredential` (401) and `ErrUnavailable` (503). No widening of `dispatch.Actor` or `dispatch.Invocation`. The SigV4 path and the bridge share one policy evaluator (`policyauth`) and one account-admission check. Deliberate change: the open-registry mode for a nil token verifier is deleted and `NewBridge` refuses a nil Verifier, Issuer, Rehydrator, Authorizer or Admission, so tests that want an unauthenticated registry mount `registry.Handler` directly. The pre-move pipeline characterisation (status codes, error bodies, the `WWW-Authenticate` challenge, referrers, token exchange, revoked and deleted principals) passes unchanged apart from setup, and the retired `ResolveECRPrincipal` parity rows live on as `credverify` `Rehydrate` rows and `Bridge` claim-check rows. Live on env14 (DHCP external IP): crane, skopeo and docker push and pull (`tests/e2e/ecr`), a cosign sign and verify in the legacy tag mode and in OCI 1.1 referrers mode with the signature returned by `GET /v2/{repo}/referrers/{digest}`, `/v2/token` refresh from fresh logins, and a deactivated access key answering 401 on `/v2/token`, manifest GET, crane pull and crane push, then 200 again once reactivated. The ECR signing-key deadlock bead (`mulga-6wqce`) is untouched. |
|
||||
| `spinifex#1351` | `spinifex/gateway/{bedrock,bedrockruntime,bedrockagent,bedrockagentruntime}.go` (`Bedrock_Request`, `BedrockRuntime_Request`, `BedrockAgent_Request`, `BedrockAgentRuntime_Request`, the four route tables), `spinifex/domains/ochre/authz.go`, and the 13 `Bedrock*` fields of `GatewayConfig` | `spinifex/domains/ochre/awsapi` (`NewRegistration`, `Deps`, `Quota`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` | The four Bedrock services register through the ADR-0002 seam as one registration, per ADR-0002:S1 (one per signing service) and the ADR-0001 amendment of 2026-10-06: the service is `bedrock` and `bedrock-runtime`, `bedrock-agent` and `bedrock-agent-runtime` are aliases. A request signed with a sub-service scope is dispatched to that sub-service whatever its path, so a path owned by another sub-service answers `InvalidActionException` as before; only the shared `bedrock` scope is split by path. The pipeline characterisation tests committed first (status, error type and envelope per service, metering order, nil-capability answers) pass unchanged apart from setup. The 13 gateway fields become `awsapi.Deps`: `Credentials`, `Endpoints`, `Access`, `Recorder`, `Logging`, `Provisioned`, `Guardrails`, `Embedder`, `KB`, `DataSources`, `Vector` and `Quota`, with the documented nil fallbacks (no grants denies every model, a nil store errors rather than panics); `BedrockAccessAdmin` had no reader outside one test and is gone, and `Deps.NATS` is dropped because the nil-NATS answer is given by the gateway-wide gate before dispatch. `Logging`, `Provisioned`, `Guardrails`, `KB` and `DataSources` are the concrete exported store types, a temporary edge until Ochre exposes capability interfaces for them. The wire-typed action functions stay in the `domains/ochre` root and the SDK-typed adapters move; `authz.go` moves with them, which removes the `domains/ochre` to `ingress/aws/bodyscope` allowlist row, the only allowlist change. The legacy switch cases, the `supportedServices` and `jsonErrorService` entries and the path-based sub-service rewrite in `GetService` are removed, so `ec2` is the only service left on the legacy table; the rewrite's rows are pinned in `awsapi` as `resolveSubservice` and dispatch tests. The registration declares the AWS JSON 1.1 envelope it had and `cmd/aws-model-coverage` is not wired, so no coverage allowlist row is added. Recorded divergences, not changed by this move: Bedrock is restJson1 in AWS but answers with the JSON 1.1 envelope (`application/x-amz-json-1.1`, `__type` body), and CreateGuardrail answers 200 where AWS answers 202; both are tracked by `mulga-xug8b`. No change to `dispatch.Actor` or `dispatch.Invocation`. |
|
||||
| `spinifex#1354` | `spinifex/gateway/ec2.go` (the EC2 action table, `ec2Handler`, `ec2Actions`, the dispatch pipeline), `ec2_idempotency.go`, `ec2_validation.go`, and the EC2 token-bucket fields of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`Handler`, `NewHandler`, `Deps`, `Quota`, `Dispatch`, `ActionNames`, `InputHasField`), composed by `spinifex/gateway/ec2.go` | Slice 9a of the EC2 move: the capabilities first, with no behaviour change. The EC2 action table, idempotency and validation move to `domains/ec2/awsapi` behind `Deps{NATS, IAM, Quota, ActiveNodes, ExpectedNodes, NodeIDs, AZ, Endpoint, InstanceStatus}`; the gateway keeps the composition and `EC2_Request`, still on the legacy switch until slice 9b registers EC2 through the ADR-0002 seam. `Quota` is declared in `awsapi` because `domains/admission/quota` imports the EC2 action packages and cannot be imported back, and the gateway wraps `quota.Service` to supply the NATS instance-type resolver its retype gate takes. The instance and spot-instance adapters take the two-method `InstanceProfiles` interface (`ResolveInstanceProfile`, `CanonicalResourceARN`) instead of `iamdomain.IAMService`, which pays off the `spotinstance` to `domains/admission/quota` and `domains/iam` allowlist rows. Allowlist changes are 20 deletions and no additions: the 18 `gateway` to `domains/ec2/awsapi/*` rows and those 2. The pipeline characterisation tests committed first (error envelopes, check order, gateway-local actions, idempotent replay, PassRole, ImportKeyPair padding, launch-template expansion before authorization, RunInstances instance-profile refusal order) pass unchanged. Package-local tests drive every routed action through `Dispatch` against a stub daemon. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and MaxResults errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. |
|
||||
| `spinifex#1355` | `spinifex/gateway/ec2.go` (`EC2_Request` in production, the legacy service switch, its `supportedServices` and JSON tables) and the `Quota` field of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`NewRegistration`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` (`ec2.go`: `newEC2Registration`, `advertisedEndpoint`; `ec2quota`) | Slice 9b of the EC2 move: EC2 registers through the ADR-0002 seam as service `ec2` and the legacy gateway switch is empty. The EC2 query-protocol error envelope is kept through `ErrorEnvelopeXML`, and the policy denial match is now `*dispatch.PolicyDenialError`. The characterisation tests committed first also pin the Authorize service name and action, the trace span and audit action, the throttle key (account plus action) and the cluster-unavailable gate, which runs before policy and action lookup for gateway-local and NATS-backed actions alike. They and the 9a tests pass unchanged apart from setup: STS and EC2 test setups now register the services they sign as, and the two tests that claimed a legacy service name are removed because no legacy name remains. `EC2_Request` survives only as a test shim. Allowlist changes are two deletions (the `gateway` to `domains/ec2/awsapi` and `domains/ec2/awsapi/instance` rows) and one addition, the per-service model-coverage row `cmd/aws-model-coverage` to `domains/ec2/awsapi`. `startInstanceStatus` stays in `awsgw.go` because ADR-0007:S6 records the raw instance-state uses by file. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. The gateway request logs carry `service=ec2` and the per-action label. Not exercised live: a throttled request (the default limits are not reachable from one client; the throttle key is pinned by the characterisation tests) and an agent-signed EC2 call. |
|
||||
| `spinifex#1363` | `spinifex/gateway` context keys (`ctxService`, `ctxRegion`, `ctxAction`, the principal and access key id keys, and the unread `ctxQueryArgs`), the duplicate `callerPrincipal` and `principalContext` types, `gateway/caller_arn.go` (`buildCallerARN`), and the direct `policyauth`, `accountadmission` and NATS-connection reads in the auth pipeline | `spinifex/ingress/aws/reqctx` (the request record), `credverify.CallerARN`, and the gateway-consumed capabilities `PolicyEvaluator`, `AccountAdmission` and `Readiness`, composed by `spinifex/runtime/roles/awsgw` (`clusterready`) | Slice 11a of the gateway removal: the request-context record and the ADR-0002:S8 capabilities, with no behaviour change. The record is a copy-on-write value under one context key; the principal is sealed once and a second `WithPrincipal` keeps the first caller. The layering rule `S7_RequestRecordPrivateToIngress` lets only `gateway` and `ingress/aws/*` import `reqctx`, with a fault-injection test, and slice 11c moves the package to `ingress/aws/internal/reqctx` and drops the rule. The caller ARN is built once in `credverify` (root cases kept) and filled on `Principal.CallerARN`. The policy evaluator, account admission (`credential.ErrAccountNotActive`, with `accountadmission.ErrNotActive` an alias) and cluster readiness (`Available() bool`) are interfaces the gateway consumes and fails closed on when nil. The step 1 characterisation tests (throttle against both readiness gates and the policy gate for ECS and IAM, `/admin` skipping `GetService` and the second gate, the nil-connection difference between the two gates, five error writers each minting a request id that differs from the audit record, and the credential-scope region reaching only the span) pass unchanged apart from setup. No allowlist rows change, and there is no change to `dispatch.Actor` or `dispatch.Invocation`. Debt for slice 11c: the test-only `defaultCapabilities` seam (a package variable set by a test `init` so about 60 gateway test files can build a config from `IAMService` alone), the nil-connection case of the early readiness gate, the remaining `NATSConn` readers (`DiscoverActiveNodes` and the field that `awsgw` and the integration harness set), and the `IAMService`, `STSService`, `Config`, `RootCAs` and `InternalSuffix` fields of `GatewayConfig`, which no production code reads. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a bad access key refused with InvalidClientTokenId on a Query service and InvalidClientTokenIdException on a JSON service, a no-policy user refused with UnauthorizedOperation, and an assumed-role session allowed and refused per its policy. Not exercised live: a suspended-account refusal, a throttled request and the NATS-down 503, which the step 1 tests pin. |
|
||||
| `spinifex#1378` | `spinifex/gateway` `oidc_discovery.go` and `registry.go` (the `/oidc/eks` routes and the `/v2` mount), the `DiscoverActiveNodes` method and its cache, and the `GatewayConfig` fields `Config`, `RootCAs`, `InternalSuffix`, `Version`, `Commit`, `IAMService`, `STSService`, `NATSConn` and `ExpectedNodes` | `spinifex/runtime/roles/awsgw` (`PublicRoutes`, `NodeDiscovery.ActiveNodes`, `composeGateway`); the gateway keeps one `PublicRoutes` mount hook and the `Admin` registration name supplied by the role |
|
||||
| `spinifex#1384` | `spinifex/gateway` (`GatewayConfig`, the SigV4 and policy middleware, `Request`, the admin dispatch, `AWSOperationInventory`) | `spinifex/ingress/aws/pipeline` (`Config`) and `spinifex/ingress/aws/dispatch` (`AWSOperationInventory`); the gateway layering rows are retired and R2 binds the pipeline |
|
||||
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
|
||||
| `spinifex#1245`, `spinifex#1390` | — | `contracts/admission/v1` | The quota override seal route (`admission.v1.SealAccountQuotaOverride`, served by `awsgw`) and the per-gateway read-only capability probe (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) are an explicit versioned cross-process contract between the quota owner and `operator/accountlifecycle`. The consumer wiring is new: `NewClusterEngine` takes a `ClusterNodes` value with separate daemon and gateway node sets, `ClusterConfig.NodesRunning` is the single definition of which nodes run a service, and the seal reaper runs in `StageAccount` after the EC2 settings seal. A termination whose gateway probe is incomplete seals nothing and leaves the account `TERMINATING`. Contract, responder, probe and reaper tests pin the subjects, JSON shapes and fail-closed cases; `TestAccountTermination_QuotaOverrideEndsSealed` replaces the former characterization that overrides survive teardown. |
|
||||
The EC2 contract row is intentionally different from the directory moves: it creates
|
||||
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
|
||||
subscription and permission shape, while callers use
|
||||
@@ -406,9 +387,9 @@ the canonical IAM/STS principal before dispatch, because ECR must not depend on
|
||||
gateway identity context or ARN construction.
|
||||
|
||||
All implemented ECR control-plane actions now dispatch through explicit ECR
|
||||
capabilities. The OCI `/v2` bridge, its token exchange and its principal
|
||||
resolution now live in `domains/ecr/registry`; the gateway keeps only the
|
||||
`RegistryHandler` mount, and `awsgw` composes the bridge.
|
||||
capabilities. The remaining ECR-named gateway code is generic `/v2/*` route
|
||||
assembly, SigV4/registry authentication and policy enforcement; it remains in
|
||||
gateway rather than inverting those dependencies into the ECR domain.
|
||||
|
||||
### ACM relocation residuals
|
||||
|
||||
@@ -457,7 +438,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `runtime/roles/vpcd` (placement): the role still mixes process/role supervision with network-controller responsibilities. `lease_reaper.go` (orphaned external-pool DHCP lease sweep), `ovn_controller_watchdog.go` (OVN controller liveness and restart), `host_eip_loop.go` (host-EIP reconcile loop) and `lease_datapath_reconcile.go` (lease datapath reconcile) are periodic network-control loops, not role wiring; `vpcd.go` itself also carries bridge-mode detection and verification, chassis-list parsing, external-CIDR resolution, DHCP manager startup and lease/IP-change handling, and gateway/host-EIP allocator and neighbour flush/prime construction alongside `Service.Start`. The future placement question is whether these belong in `domains/network`. Removal condition: an accepted decision on network-controller placement, and extraction of those loops and helpers behind the role.
|
||||
- ECR control-plane registration: `awsapi.Deps.NATS` is a raw `*nats.Conn`, not a narrow capability. Every NATS-backed `Handler` passes it to `domains/ecr.NewNATSMetaStore`, which calls `natsmsg.NATSRequest`, so narrowing it means changing the exported `Handler` type, every handler and the meta store; it must not be broadened further meanwhile.
|
||||
- ACM control-plane registration: `domains/acm/awsapi.Deps.NATS` (`registration.go`) is likewise a raw `*nats.Conn` carried by every `Handler`, for the same reason; it must not be broadened further meanwhile.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `gateway` and the `awsapi` package of each registered service (`domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi`, `domains/elbv2/awsapi`, `domains/rds/awsapi`, `domains/ecs/awsapi`, `domains/eks/awsapi`) to read their declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi` and `gateway` to read ECR's and ACM's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- Ingress pipeline (ADR-0002 phase 4): the request-audit record, the trace action enricher, the throttle key functions and the gateway request-context keys stay in `gateway` until the auth/IAM and request-context boundary is designed. SigV4 authentication writes the audit record and context keys from four places, so moving them now would export exactly the setters ADR-0002 says must not be exported solely to make a directory move possible.
|
||||
- NAT projection delivery is inconsistent and is preserved as found: EIP `AssociateAddress` sends `vpc.add-nat` unacknowledged (`AnnounceNATAdd`), skipping the 45s OVN barrier every other add path waits on, and `DeleteNetworkInterface` sends `vpc.delete-nat` unacknowledged (`AnnounceNATDelete`), skipping the ack and the ErrNoResponders retry. The reconciler's orphan sweep is the only backstop for both. Clears when those call sites use `AddNAT`/`AddNATBestEffort` and `RemoveNAT`, a reliability change of its own.
|
||||
- `vpc.add-system-egress` has a `vpcd` subscriber and a contract entry but no producer (its former `domains/ec2/eip` publisher is gone); `domains/eks/cluster` still publishes the delete. Either a producer is missing or the add route is dead; it needs an owner decision.
|
||||
@@ -465,7 +446,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `foundation/messaging/nats/nats_trace.go` still names its OpenTelemetry tracer `github.com/mulgadc/spinifex/spinifex/utils`. It is an emitted instrumentation-scope name that dashboards may filter on, so renaming it is a telemetry change, not a path fix.
|
||||
- `runtime/compute/cache.New`: two construction-time panics carry a lint exemption pending a constructor that returns an error.
|
||||
- `domains/ec2/systeminstance`: managed-service parent metadata is only the `ManagedBy` tag value.
|
||||
- `domains/ochre` (action adapters): cleared by the Bedrock registration in `spinifex#1351`. The SDK-typed adapters and `authz.go` moved to `domains/ochre/awsapi`, the `ingress/aws/bodyscope` allowlist row is deleted, and the adapters take the concrete exported Ochre stores through `awsapi.Deps` as a temporary edge until Ochre exposes capability interfaces for them.
|
||||
- `domains/ochre` (action adapters): since `spinifex#1266` the Bedrock action layer (handlers, action authorization, event-stream framing and the CRUD, catalogue and Converse/Invoke entry points) shares package `ochre` with the domain core, stores and inference engine, and imports `ingress/aws/bodyscope`. Moving it to `domains/ochre/awsapi` is not mechanical: the adapters reach package-private store internals (the guardrail and provisioned-throughput store handles and get/put/update methods), and adapter tests override the package-private model catalogue. It needs core capabilities first, then the move; the top-level `gateway/bedrock*.go` HTTP handlers additionally wait for the ingress registration seam.
|
||||
- `domains/network`: `reconcile/intent.go` builds desired network state by reading EC2's KV buckets and decoding EC2's private VPC, subnet, ENI, route-table, gateway, EIP, NAT and security-group records, and `reconcile/drift.go` watches those buckets. ADR-0001 names this pair explicitly: EC2 and network communicate through a capability/projection or versioned contract, never by one being the other's KV reader. Step 7 fix: an EC2-owned `domains/ec2/projection` exposing network intent with list and watch (it may stay KV-backed, so no data moves); a narrow intent interface declared by `reconcile`; wiring at the composition root; and the duplicated `PurposeEIP`/`PurposeIGWLRP` constants and mirrored `SGRule`/`SGEvent` folded into the projection or `contracts/ec2/v1`. The drift-watch coverage test and reconcile scenario tests are the evidence the projection must keep. `domains/network` also imports `runtime/compute/vm` and `runtime/host/command` (domain-to-runtime).
|
||||
- `domains/ec2/instance`: imports `runtime/compute/vm` and `runtime/compute/gpu` (domain-to-runtime) and `domains/dns` (cross-domain implementation import).
|
||||
- `domains/ec2/instance/state` (dependency): the vpcd IMDS local reader imports `runtime/compute/vm` to return `vm.VM`. Removal condition: that type moves into a domain-owned package that `runtime/compute/vm` consumes (runtime to domain is allowed).
|
||||
@@ -475,12 +456,14 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `operator/installer/firstboot` (security): `Config.InstallCallback` is rendered into `spinifex-firstboot.sh`, which is world-readable (0755). The field is currently unused (`fireInstallCallback` reads `SPINIFEX_INSTALL_CALLBACK` from the environment instead), but any future wiring must not place secrets or tokenised callback URLs in the generated script; sensitive callback credentials should reach first boot through a protected channel such as a 0600 file or a credential store.
|
||||
- `operator/cli` (known security risk): `spx admin join` contacts the formation server with `InsecureSkipVerify`, sends the join token as a bearer credential and receives the cluster CA certificate and key, master key, Viperblock key and shared credentials, so an on-path attacker during formation can capture the token and the cluster root secrets or substitute a CA. This predates the move; lint surfaced it when the tree left `cmd/`. It is accepted for now and carries a line-level gosec annotation stating it is not a false positive. Removal condition: authenticate formation TLS with a pinned key or certificate delivered with the join material, or an out-of-band CA, with tests proving a mismatched server certificate fails before credentials are sent.
|
||||
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS, ECS and ELBv2 imports `domains/{iam,sts,eks,rds,ecs,elbv2}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `operator/accountlifecycle` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The amended ADR-0001 `accountteardown` row keeps replacing these with domain-owned cleanup capabilities as deferred debt; it was not a relocation prerequisite. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
|
||||
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `domains/elbv2/awsapi` and `domains/rds/instance` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
|
||||
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` for the `InstanceProfile` type that `InstanceProfiles.ResolveInstanceProfile` returns (`RunInstances.go`, `IamInstanceProfileAssociation.go`), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge is now the two-method `InstanceProfiles` interface plus that type; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- `domains/ec2/awsapi/spotinstance` (dependency): imports `domains/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
|
||||
- Instance adapter consumers outside EC2: `domains/rds/awsapi/enginecatalog.go`, `runtime/daemon/eks_cp_control.go`, `runtime/daemon/rds_deps.go`, `domains/admission/quota`'s `vcpu.go` and `gateway/iam.go` (`CountInstanceProfileAssociations`) call `domains/ec2/awsapi/instance` directly; each needs an EC2 capability rather than the AWS action adapter.
|
||||
- `runtime/daemon` (Q-28 ownership and deployment): the daemon moved whole, keeping its current ownership, deployment and `daemon → gateway → handlers` dependencies. Q-28's write-side consolidation and any daemon decomposition or split remain deferred by ADR-0001 and are not paid by the move.
|
||||
|
||||
@@ -489,8 +472,8 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
|
||||
- `domains/rds/instance` → `domains/rds/parametergroup` (legacy edges): `parameterGroupReferences` binds `Dependants` by scanning instance records; this clears when the RDS instance gains its own owner and answers `Dependants` from it. `tags.go` writes the meta record through its generic tag registry; this clears when tagging invokes a parameter-group-owned tag mutation capability, and moving the tagging adapter to `domains/rds/awsapi` alone does not clear it. The Modify handler still calls `propagateParameterGroup` after the owner's write; propagation is instance-side realization and stays legacy until it is designed as asynchronous realization (group generation, instance staleness, retry). `parameterGroupValues` returns the persisted `Record` and overrides to instance code; when the instance owner is extracted, replace them with a consumer-owned resolved-parameters projection.
|
||||
- `domains/ecs` → `domains/ecs/taskdefinition` (owner bypass): `tags.go` reads and writes the revision record through its generic tag registry; this clears when ECS tagging invokes an owner-provided tag mutation capability, and moving `tags.go` to `domains/ecs/awsapi` alone does not clear it. `taskDefinitionResolver` returns the persisted `Record` to task and service launch code; when the task and service owners are extracted, replace the record crossing that boundary with a consumer-owned resolved task-definition (launch-spec) projection. Moving packages has not made the record private.
|
||||
- `domains/ecs/taskdefinition` (deliberate duplication, not debt): private `getJSON`, `putJSON` and `keysWithPrefix` copy the raw-KV helpers in `domains/ecs` so the extraction keeps ECS's get/put/list error and recovery semantics. Revisit only if a later, tested state abstraction proves it preserves those semantics; do not migrate ECS to `kvstore` merely to remove them.
|
||||
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterChildren` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
|
||||
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterChildren` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
|
||||
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterPrefix` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
|
||||
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterPrefix` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
|
||||
- `domains/eks/cluster` and `domains/eks/awsapi` → `domains/eks/addon` (further edges): the AWS adapter reads the persisted `Record` through `addon.Get`/`addon.List`; this clears when the owner returns a view and the add-on adapter moves to `domains/eks/awsapi`. The guest route reaches the owner through the `handlers_eks` NATS RPC (`ListStagedAddonManifests`); this clears with per-resource commands behind EKS registration. The add-on ARN is derived in both the owner and `domains/eks/awsapi/authz.go`; this clears when authorization obtains the ARN from the owner, which an incarnation suffix would require. The owner's `Manifest`/`Report` beside the `contracts/eks/v1` types is deliberate duplication, not debt.
|
||||
- `agents/rds/agent` (dependency, narrowed): engine metadata now arrives through the agent-declared `EngineCatalog`, but the agent still imports `domains/rds/instance` for the command protocol, wire and record types (`Command*`, `Parameter`, `EngineHealth*`, `BootstrapMode*`) and API output types. This clears when those move behind a guest/controller wire contract.
|
||||
- `domains/rds/awsapi` → `domains/rds/instance` (`InstanceSizing`): the gateway obtains its `Sizing` through the RDS handler package's adapter rather than its own composition root. This clears when the gateway's RDS composition moves to `runtime/roles/awsgw` and builds `Sizing` there.
|
||||
@@ -689,12 +672,12 @@ series above; "needs decision" is open.
|
||||
| `handlers/rds` | — | Done. The remaining RDS instance resource moved to `domains/rds/instance` (package `instance`) in `spinifex#1251` with no API, NATS subject, storage or behaviour change, fulfilling ADR-0001's organise-by-resource condition alongside the already-extracted subnet group, parameter group and engine catalogue. The ADR-0004 blocked-dependency imports (quota admission, the duplicated expected-node fan-out, the IAM ensurer and the Bluebottle master key) remain dependency-rule debt, with their allowlist entries now sourced from `domains/rds/instance`. |
|
||||
| `handlers/elbv2` | — | Done. The implementation moved to `domains/elbv2` (package `elbv2`) with no API, NATS subject, storage or behaviour change. Q-30's data-plane boundary remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. Its cross-domain, IAM and `agents/elbv2` imports remain dependency-rule debt, with their allowlist entries now sourced from `domains/elbv2`. |
|
||||
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
|
||||
| `gateway` (root) | Generic HTTP route assembly, SigV4 authentication, the policy gate, request-audit middleware and request-context keys. | Done and deleted. The request pipeline moved into `ingress/aws/pipeline` (as `pipeline.Config`) and the operation inventory into `ingress/aws/dispatch` in `spinifex#1384`; the `/oidc`, `/v2` and node-discovery code had already moved to `runtime/roles/awsgw` in `spinifex#1378`. The layering test binds the pipeline with R2 and `gateway` is a retired legacy root. |
|
||||
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. The action adapters then moved to `domains/ochre/awsapi` behind one ADR-0002 registration in `spinifex#1351`. |
|
||||
| `gateway` (root) | Generic HTTP route assembly, SigV4/registry authentication, the policy gate, request-audit middleware, request-context keys, and the legacy per-service dispatch switch for every service not yet registered. | Deferred: the ADR-0002 phases above (auth/IAM and request-context boundary design) gate moving the audit, context-key and legacy-dispatch mechanics. |
|
||||
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
|
||||
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `gateway/spx` | `promote_image.go` and `spx.go` dispatch (`storage.go` already consumes `contracts/operator/v1`). | Needs decision: which package owns operator-plane action dispatch registration — an `ingress/aws`-style seam for the operator plane, or `gateway` stays its home. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the root `gateway` keeps only the legacy dispatch in `gateway/tagging.go`. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
|
||||
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
|
||||
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
|
||||
@@ -713,7 +696,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
|
||||
| Class | Paths | Status |
|
||||
|---|---|---|
|
||||
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Done: the `spinifex` service and `/admin` dispatch registration, owned by `operator/admin` | Each needs one decision or one capability, then a mechanical move. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`). Open: `gateway/spx` (owner of operator-plane dispatch registration) | Each needs one decision or one capability, then a mechanical move. |
|
||||
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
|
||||
|
||||
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
|
||||
|
||||