chore: remove the half line breaks

This commit is contained in:
Julian Sommer
2026-08-06 11:10:15 +10:00
parent 688ce5ccfd
commit 8c07f80032
17 changed files with 125 additions and 428 deletions
+10 -35
View File
@@ -23,22 +23,13 @@
# Spinifex: the open, AWS-compatible cloud you run yourself
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your
software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control.
Move workloads off the hyperscalers, run AI at a fraction of the cost, or take
the cloud to the edge, without rewriting a line. The exact build you ship to AWS,
Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line. The exact build you ship to AWS, Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
## What is Spinifex?
Spinifex is an open-source infrastructure platform that recreates the AWS service
surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises
environments. Run cloud-native software without a hyperscaler.
Spinifex is an open-source infrastructure platform that recreates the AWS service surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises environments. Run cloud-native software without a hyperscaler.
Most AWS alternatives wrap an API and call it a cloud. We rebuilt the engineering
underneath: distributed object storage with erasure coding, block storage built
to survive failure, and compute on bare metal. That depth is the reason your
software runs unchanged, instead of rewritten.
Most AWS alternatives wrap an API and call it a cloud. We rebuilt the engineering underneath: distributed object storage with erasure coding, block storage built to survive failure, and compute on bare metal. That depth is the reason your software runs unchanged, instead of rewritten.
It's built for teams that need:
@@ -59,34 +50,23 @@ From commodity hardware up to unmodified AWS tooling, every layer is replaceable
## Three ways to deploy
One AWS-compatible surface, three deployment shapes. Pick the one that matches
your reality; the platform on top is the same.
One AWS-compatible surface, three deployment shapes. Pick the one that matches your reality; the platform on top is the same.
### Neocloud
Lift and shift onto a partner Neocloud. Move workloads off the hyperscalers and
onto our Neocloud partner ecosystem without rewriting them. GPU capacity
(H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the
software.
Lift and shift onto a partner Neocloud. Move workloads off the hyperscalers and onto our Neocloud partner ecosystem without rewriting them. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
### On-premise
Bring your own hardware and host Spinifex in your own data centre. A real
multi-node HA cluster integrated with your storage and networking, with full
control of stack, data, and jurisdiction. A predictable bill, no egress
surprises, an auditable open-source core.
Bring your own hardware and host Spinifex in your own data centre. A real multi-node HA cluster integrated with your storage and networking, with full control of stack, data, and jurisdiction. A predictable bill, no egress surprises, an auditable open-source core.
### Edge
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels,
factories, and clinics. Compute next to the data, running through disconnection,
on hardware you own. The same AWS APIs your software already uses.
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels, factories, and clinics. Compute next to the data, running through disconnection, on hardware you own. The same AWS APIs your software already uses.
## AWS compatibility
Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform:
everything you deploy on AWS deploys on Spinifex unchanged. At the edge,
on-premise, or on a partner Neocloud.
Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform: everything you deploy on AWS deploys on Spinifex unchanged. At the edge, on-premise, or on a partner Neocloud.
| Service | What it is | Status |
| --- | --- | --- |
@@ -102,8 +82,7 @@ on-premise, or on a partner Neocloud.
| **RDS** | Databases | Available |
| **Bedrock** | AI Deployment | Q3 2026 |
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land.
[Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
## Core Components
@@ -139,11 +118,7 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
</p>
Every AWS API call is authenticated at the gateway, published to a NATS subject,
and answered by whichever daemon claims it. Daemons are stateless: scale
horizontally by starting more. No etcd, no Kubernetes, no external control plane.
Just systemd units, a NATS cluster, and your hardware. Deep dive:
**[`docs/DESIGN.md`](docs/DESIGN.md)**.
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Just systemd units, a NATS cluster, and your hardware. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Key Features
+1 -2
View File
@@ -825,8 +825,7 @@ Spinifex both stores externally-issued certificates (`import-certificate`) and i
| `CNAME_DELEGATION` | operator once, then Spinifex | CNAME, stable | automatic | deferred — never selected yet (northstar cannot serve public authoritative queries); an ARN-stable delegation token is minted on every managed certificate now so this lands as a non-breaking addition |
| `PRIVATE_CA` | nobody — no validation | none | automatic | **DONE** — issues synchronously against the tenant CA, no domain outside its name constraints |
`PROVIDER_API` is selected when a DNS provider credential is configured; `MANUAL_TXT` when northstar hosts the zone; otherwise `PRIVATE_CA` — the only option for a deployment with no real, publicly delegated domain. Terraform's canonical `aws_acm_certificate` → `aws_route53_record` → `aws_acm_certificate_validation` → `aws_lb_listener` flow works unmodified in every mode: where Spinifex owns the record write, no `ResourceRecord` is emitted, so `for_each` over `domain_validation_options` yields zero records and `aws_acm_certificate_validation` still blocks correctly by polling until
`ISSUED`.
`PROVIDER_API` is selected when a DNS provider credential is configured; `MANUAL_TXT` when northstar hosts the zone; otherwise `PRIVATE_CA` — the only option for a deployment with no real, publicly delegated domain. Terraform's canonical `aws_acm_certificate` → `aws_route53_record` → `aws_acm_certificate_validation` → `aws_lb_listener` flow works unmodified in every mode: where Spinifex owns the record write, no `ResourceRecord` is emitted, so `for_each` over `domain_validation_options` yields zero records and `aws_acm_certificate_validation` still blocks correctly by polling until `ISSUED`.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
+1 -3
View File
@@ -2,6 +2,4 @@
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browsing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
The [AWS model operation coverage](aws-model-operation-coverage.md) report is
generated from Spinifex's dispatch tables and the pinned AWS API models. Run
`make aws-model-coverage` to regenerate and print it.
The [AWS model operation coverage](aws-model-operation-coverage.md) report is generated from Spinifex's dispatch tables and the pinned AWS API models. Run `make aws-model-coverage` to regenerate and print it.
-32
View File
@@ -1,32 +0,0 @@
# Spinifex Tag Conventions
Spinifex uses a small set of reserved tag keys to mark resources that are owned and managed by the platform itself (as opposed to resources created directly by customer AWS API calls). The UI uses these tags to hide system-owned resources from customer-facing listings so operators do not
mistake them for user workloads and accidentally terminate or modify them.
## `spinifex:managed-by`
Identifies the Spinifex platform component that owns a resource.
| Value | Component | Tagged resource types |
|---------|---------------------------------|---------------------------------------|
| `elbv2` | ALB / HAProxy load balancers | LB VM (`instance`), LB ENI, LB AMI |
The tag is applied by the backend at create/launch time:
- **Instance** — set in `spinifex/spinifex/daemon/daemon_system_instance.go` when `LaunchSystemInstance` builds the `RunInstancesInput`, and mirrored onto the stored `vm.VM.ManagedBy` field for fast lookup.
- **ENI** — set in `spinifex/spinifex/handlers/elbv2/service_impl.go` when `CreateLoadBalancer` calls `CreateNetworkInterface`.
- **AMI** — declared on the catalog entry in `spinifex/spinifex/utils/images.go` and copied into `AMIMetadata.Tags` by the image importer in `spinifex/cmd/spinifex/cmd/admin.go`.
Tag keys and values are defined once in `spinifex/spinifex/tags/tags.go`; new system components should register their value there and reuse the constants rather than hardcoding strings.
## UI behaviour
The admin **Nodes** page and the **Images** (AMIs) page filter out resources that carry `spinifex:managed-by`. Engineers who need to see system resources for debugging can append `?system=1` to the URL — the filter is bypassed and a hidden-count banner is replaced with the full listing. Direct navigation to a system AMI detail page without `?system=1` renders the "Image not found" fallback.
The EC2 **Instances** page does not need UI-side filtering: backend SigV4 authentication scopes `DescribeInstances` by account ID, and system instances live under the global system account
(`000000000000`), so customers never see them.
## `spinifex:lb-arn`
Applied to ELBv2-managed ENIs alongside `spinifex:managed-by=elbv2`. Stores the ARN of the parent load balancer so reconciliation can map an ENI back to its owner without a separate lookup. Customer-facing
filtering keys off `spinifex:managed-by`; `spinifex:lb-arn` is informational.
+1 -4
View File
@@ -116,10 +116,7 @@ To run EKS, import the prebuilt node image from the catalog:
spx admin images import --name spinifex-eks-node
```
This pulls the Alpine + K3s node AMI from `iso.mulgadc.com`, verifies its
checksum, and registers it tagged `spinifex:managed-by=eks`. `eks create-cluster`
and `eks create-nodegroup` resolve the boot AMI by that tag, so no further
configuration is needed.
This pulls the Alpine + K3s node AMI from `iso.mulgadc.com`, verifies its checksum, and registers it tagged `spinifex:managed-by=eks`. `eks create-cluster` and `eks create-nodegroup` resolve the boot AMI by that tag, so no further configuration is needed.
## Cluster Shutdown
+71 -216
View File
@@ -16,10 +16,7 @@ tags:
## Overview
Spinifex provides AWS-compatible VPC networking on bare-metal. Every EC2 instance
runs inside an isolated virtual network backed by OVN (Open Virtual Network).
Instances can operate in two modes: **private** (overlay-only, no WAN access) or
**public** (routable from the WAN with a unique public IP).
Spinifex provides AWS-compatible VPC networking on bare-metal. Every EC2 instance runs inside an isolated virtual network backed by OVN (Open Virtual Network). Instances can operate in two modes: **private** (overlay-only, no WAN access) or **public** (routable from the WAN with a unique public IP).
## Instructions
@@ -42,35 +39,25 @@ Spinifex maps AWS VPC concepts directly to OVN constructs:
<img src="../../../.github/assets/diagrams/vpc-network-path.svg" alt="VPC logical topology — WAN, br-wan, VPC logical router, subnets, ENIs" width="900">
</p>
Cross-host traffic uses **Geneve tunnels** (UDP 6081) over the management/overlay
NIC. Each host runs `ovn-controller` which programs OpenFlow rules on `br-int`
(the integration bridge where all VM TAP devices connect).
Cross-host traffic uses **Geneve tunnels** (UDP 6081) over the management/overlay NIC. Each host runs `ovn-controller` which programs OpenFlow rules on `br-int` (the integration bridge where all VM TAP devices connect).
## Private vs Public Subnets
A subnet's behavior depends on three things: whether the VPC has an Internet
Gateway, whether the subnet's route table has a default route to that IGW, and
whether the subnet has `MapPublicIpOnLaunch` enabled.
A subnet's behavior depends on three things: whether the VPC has an Internet Gateway, whether the subnet's route table has a default route to that IGW, and whether the subnet has `MapPublicIpOnLaunch` enabled.
## Private Subnet (Default)
Instances get a private IP only. They can communicate with other instances in the
same VPC (even across subnets and hosts via the overlay). They cannot reach the
internet or be reached from the WAN.
Instances get a private IP only. They can communicate with other instances in the same VPC (even across subnets and hosts via the overlay). They cannot reach the internet or be reached from the WAN.
<p align="center">
<img src="../../../.github/assets/diagrams/vpc-private-subnet-flow.svg" alt="Private subnet — instance hits router, no default route, packet dropped" width="900">
</p>
Private subnet instances reach the internet only if their route table has a
default route to the IGW (shared SNAT, outbound only — they share the gateway
IP) or to a NAT gateway. With no default route, egress is dropped. Either way
they cannot be reached from the WAN because they have no public IP.
Private subnet instances reach the internet only if their route table has a default route to the IGW (shared SNAT, outbound only — they share the gateway IP) or to a NAT gateway. With no default route, egress is dropped. Either way they cannot be reached from the WAN because they have no public IP.
## Public Subnet
Instances get both a private IP and a public IP. The public IP is a 1:1 NAT
managed by OVN — the instance OS only sees its private IP.
Instances get both a private IP and a public IP. The public IP is a 1:1 NAT managed by OVN — the instance OS only sees its private IP.
<p align="center">
<img src="../../../.github/assets/diagrams/vpc-public-subnet-flow.svg" alt="Public subnet — outbound SNAT and inbound DNAT between private and public IPs" width="900">
@@ -83,14 +70,7 @@ managed by OVN — the instance OS only sees its private IP.
3. Subnet has `MapPublicIpOnLaunch = true`
4. External IP pool configured in `spinifex.toml`
Spinifex follows AWS route-table semantics: a subnet is only "public" if its
effective route table carries a default route to the IGW. A new VPC's main
route table has the local route only — Spinifex does **not** add the IGW route
for you. Without it, the subnet's egress is gated with a drop policy, so
instances cannot reach the internet (and inbound connections cannot complete
because return traffic is dropped) even with a public IP and an attached IGW.
Add the route explicitly — either to the main route table, or to a custom route
table associated with the subnet (see [Quick Start](#3-create-vpc-with-public-subnet)).
Spinifex follows AWS route-table semantics: a subnet is only "public" if its effective route table carries a default route to the IGW. A new VPC's main route table has the local route only — Spinifex does **not** add the IGW route for you. Without it, the subnet's egress is gated with a drop policy, so instances cannot reach the internet (and inbound connections cannot complete because return traffic is dropped) even with a public IP and an attached IGW. Add the route explicitly — either to the main route table, or to a custom route table associated with the subnet (see [Quick Start](#3-create-vpc-with-public-subnet)).
## Comparison
@@ -105,14 +85,11 @@ table associated with the subnet (see [Quick Start](#3-create-vpc-with-public-su
## External Connectivity Modes
The `[network]` section in `spinifex.toml` controls how VMs reach the outside
world. There are three modes, and pool mode has two IP sources (static or DHCP).
The `[network]` section in `spinifex.toml` controls how VMs reach the outside world. There are three modes, and pool mode has two IP sources (static or DHCP).
## `pool` — Full Public Networking (Recommended)
Each VM in a public subnet gets its own public IP with bidirectional 1:1 NAT.
Supports the full AWS feature set: public subnets, auto-assign public IPs,
Elastic IPs, and security groups.
Each VM in a public subnet gets its own public IP with bidirectional 1:1 NAT. Supports the full AWS feature set: public subnets, auto-assign public IPs, Elastic IPs, and security groups.
Pool mode supports two ways to obtain public IPs:
@@ -120,11 +97,9 @@ Pool mode supports two ways to obtain public IPs:
The admin defines a range of routable IPs that Spinifex manages exclusively.
**Use when:** You have a block of IPs you control — datacenter ISP allocation,
homelab range carved out of your router's DHCP scope, enterprise DMZ range.
**Use when:** You have a block of IPs you control — datacenter ISP allocation, homelab range carved out of your router's DHCP scope, enterprise DMZ range.
**Requirement:** The IP range must NOT be served by any other DHCP server. In a
homelab, shrink your router's DHCP scope to exclude the Spinifex range.
**Requirement:** The IP range must NOT be served by any other DHCP server. In a homelab, shrink your router's DHCP scope to exclude the Spinifex range.
```toml
[network]
@@ -141,17 +116,11 @@ dns_servers = ["192.168.1.1", "8.8.8.8"]
### DHCP Source
Instead of a static range, public IPs come from the upstream router's DHCP
server. When a VM launches, Spinifex requests a DHCP lease from the router
on behalf of the VM. When the VM terminates, the lease is released.
Instead of a static range, public IPs come from the upstream router's DHCP server. When a VM launches, Spinifex requests a DHCP lease from the router on behalf of the VM. When the VM terminates, the lease is released.
The VM itself never talks to the router's DHCP — it only sees its private
VPC IP (from OVN's internal DHCP). The host-side DHCP conversation is
invisible to the guest.
The VM itself never talks to the router's DHCP — it only sees its private VPC IP (from OVN's internal DHCP). The host-side DHCP conversation is invisible to the guest.
**Use when:** You don't control a static IP block but the router's DHCP
server has enough leases. Homelabs where you don't want to carve out a range.
Environments where IPs are managed centrally by the network team's DHCP.
**Use when:** You don't control a static IP block but the router's DHCP server has enough leases. Homelabs where you don't want to carve out a range. Environments where IPs are managed centrally by the network team's DHCP.
**Requirement:** `dhclient` or `dhcpcd-base` installed on the host.
@@ -170,8 +139,7 @@ dns_servers = ["192.168.1.1", "8.8.8.8"]
### How Pool Mode Works (Both Sources)
Regardless of whether IPs come from a static range or DHCP, the OVN behavior
is identical:
Regardless of whether IPs come from a static range or DHCP, the OVN behavior is identical:
<p align="center">
<img src="../../../.github/assets/diagrams/vpc-dhcp-conversations.svg" alt="Two independent DHCP conversations — host-to-router and VM-to-OVN" width="900">
@@ -188,15 +156,11 @@ is identical:
| **Best for** | Datacenters, ISP blocks, production | Homelabs, dev environments, shared networks |
| **Capacity** | Exact: `range_end - range_start` IPs | Limited by router's DHCP pool size |
Both support the same AWS features: public subnets, Elastic IPs, security groups,
DescribeInstances showing public IPs.
Both support the same AWS features: public subnets, Elastic IPs, security groups, DescribeInstances showing public IPs.
## `nat` — Shared SNAT (Simple)
All VMs share a single external IP for outbound SNAT. By default there are no
public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as
private subnets with internet access. On routed-NAT nodes, adding a public pool
restores full public IP parity (see below).
All VMs share a single external IP for outbound SNAT. By default there are no public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as private subnets with internet access. On routed-NAT nodes, adding a public pool restores full public IP parity (see below).
> **Limitation (routed-NAT v1):** System instances (ECS/EKS/load-balancer
> agents) source egress from ExternalIPAM pool IPs, which do not exist in
@@ -204,12 +168,9 @@ restores full public IP parity (see below).
> either allocate transit IPs for system instances or reject the feature at the
> API level in nat mode.
The `gateway_ip` is the IP that OVN uses for SNAT. You can set it statically or
use `setup-ovn.sh --dhcp` to obtain one from the router. This is the router's
DHCP — not Spinifex's internal OVN DHCP for VMs.
The `gateway_ip` is the IP that OVN uses for SNAT. You can set it statically or use `setup-ovn.sh --dhcp` to obtain one from the router. This is the router's DHCP — not Spinifex's internal OVN DHCP for VMs.
**Use when:** VMs only need outbound access (apt update, pulling images). Edge
deployments behind ISP NAT. Single WAN IP available.
**Use when:** VMs only need outbound access (apt update, pulling images). Edge deployments behind ISP NAT. Single WAN IP available.
```toml
[network]
@@ -224,13 +185,9 @@ prefix_len = 24
### Host access to instances (jumpbox pattern)
The spinifex host automatically reaches every instance's **private IP**: IGW
attach installs a host route into OVN (`<vpc-cidr> via <gateway-transit-ip>
dev spx-nat-host`) and exempts the transit net from SNAT, so replies to
host-initiated connections come back un-NATted. No per-instance setup.
The spinifex host automatically reaches every instance's **private IP**: IGW attach installs a host route into OVN (`<vpc-cidr> via <gateway-transit-ip> dev spx-nat-host`) and exempts the transit net from SNAT, so replies to host-initiated connections come back un-NATted. No per-instance setup.
Security groups still apply and the default SG is closed to the host, same as
AWS — open SSH/ICMP from the transit net first:
Security groups still apply and the default SG is closed to the host, same as AWS — open SSH/ICMP from the transit net first:
```bash
aws ec2 authorize-security-group-ingress --group-id $SG \
@@ -243,19 +200,11 @@ Then use the host as a jumpbox for remote access:
ssh -J admin@<spinifex-host> ubuntu@<instance-private-ip>
```
Extra networks that must reach instances without SNAT (e.g. a management LAN)
can be added via `[network] nat_exempt_cidrs = ["192.168.50.0/24"]`.
Extra networks that must reach instances without SNAT (e.g. a management LAN) can be added via `[network] nat_exempt_cidrs = ["192.168.50.0/24"]`.
### Public IPs in NAT mode (public pool)
A routed-NAT node (`setup-ovn.sh --nat-uplink` + `spx admin init
--external-mode=nat`) can carry a public pool alongside the internal
`nat-transit` pool. With one configured, nat mode behaves like pool mode for
public IPs: `MapPublicIpOnLaunch` on the default subnet, auto-assigned public
IPs, and Elastic IPs all work. Spinifex delivers each public IP at the host —
a `/32` route steers it into OVN and a proxy-ARP neighbor entry answers for it
on the uplink (L3 only, same MAC, so it works on WiFi and other non-bridgeable
uplinks).
A routed-NAT node (`setup-ovn.sh --nat-uplink` + `spx admin init --external-mode=nat`) can carry a public pool alongside the internal `nat-transit` pool. With one configured, nat mode behaves like pool mode for public IPs: `MapPublicIpOnLaunch` on the default subnet, auto-assigned public IPs, and Elastic IPs all work. Spinifex delivers each public IP at the host — a `/32` route steers it into OVN and a proxy-ARP neighbor entry answers for it on the uplink (L3 only, same MAC, so it works on WiFi and other non-bridgeable uplinks).
Static range carved out of the router's DHCP scope:
@@ -272,11 +221,7 @@ spx admin init --external-mode=nat --external-source=dhcp \
--external-bind-bridge wlan0
```
On WiFi/WWAN uplinks the leases are requested with the interface's own MAC
(`dhcp_mac = "interface"`, written automatically) and distinguished by DHCP
client-id. Some routers key leases by MAC and ignore the client-id — Spinifex
detects this (the router hands the same IP to two client-ids) and fails the
allocation with advice to switch to a static range.
On WiFi/WWAN uplinks the leases are requested with the interface's own MAC (`dhcp_mac = "interface"`, written automatically) and distinguished by DHCP client-id. Some routers key leases by MAC and ignore the client-id — Spinifex detects this (the router hands the same IP to two client-ids) and fails the allocation with advice to switch to a static range.
Resulting config:
@@ -298,18 +243,11 @@ gateway = "192.168.1.1"
prefix_len = 24
```
**Caveat — reaching an EIP from the spinifex host itself.** Host-sourced
traffic enters OVN from the transit net, which is exempt from NAT (that is what
makes the jumpbox pattern work) — so a host connection to an EIP that carries
the transit source IP would skip DNAT. Spinifex stamps the EIP route with the
uplink's LAN IP as source to avoid this, but if no uplink address can be
determined, connect to the instance's **private IP** from the host instead.
Other machines on the LAN are unaffected.
**Caveat — reaching an EIP from the spinifex host itself.** Host-sourced traffic enters OVN from the transit net, which is exempt from NAT (that is what makes the jumpbox pattern work) — so a host connection to an EIP that carries the transit source IP would skip DNAT. Spinifex stamps the EIP route with the uplink's LAN IP as source to avoid this, but if no uplink address can be determined, connect to the instance's **private IP** from the host instead. Other machines on the LAN are unaffected.
## Disabled (Empty/Omitted)
VPC networking is overlay-only. No external connectivity. Instances can only
communicate within their VPC.
VPC networking is overlay-only. No external connectivity. Instances can only communicate within their VPC.
## Mode Comparison
@@ -325,17 +263,11 @@ communicate within their VPC.
| Admin must reserve IP range | Yes | No | Only static pool | No |
| Needs router DHCP | No | Yes | Optional | No |
If you start with `nat` and later need public subnets: on a bridgeable uplink
switch to `pool` and define a range (or use `source = "dhcp"`); on a routed-NAT
node just add a public pool alongside `nat-transit` — no data migration needed.
If you start with `nat` and later need public subnets: on a bridgeable uplink switch to `pool` and define a range (or use `source = "dhcp"`); on a routed-NAT node just add a public pool alongside `nat-transit` — no data migration needed.
## Bridge Setup — Physical Network Wiring
The WAN NIC **must** be enslaved to a Linux bridge. This is a hard requirement —
`setup-ovn.sh` will not attach a physical NIC directly to OVS, and macvlan is
no longer supported. The Linux bridge owns the host IP, default route, and any
DHCP lease, so SSH and management traffic stay up while OVS/OVN are configured
underneath.
The WAN NIC **must** be enslaved to a Linux bridge. This is a hard requirement — `setup-ovn.sh` will not attach a physical NIC directly to OVS, and macvlan is no longer supported. The Linux bridge owns the host IP, default route, and any DHCP lease, so SSH and management traffic stay up while OVS/OVN are configured underneath.
The full datapath chain looks like this:
@@ -350,16 +282,9 @@ physical NIC (e.g. `wan`)
└─▶ TAP devices (VM NICs)
```
`setup-ovn.sh` auto-detects the Linux bridge that owns the default route
(typically `br-wan`, provisioned by cloud-init / netplan / systemd-networkd).
You can override the detection with `--wan-bridge=<name>`. Once detected, the
script creates the OVS bridge `br-ext` and links it to the WAN bridge with a
veth pair. The Linux bridge keeps its IP and routes — no interruption.
Bridge-mapping is set to `external:br-ext`.
`setup-ovn.sh` auto-detects the Linux bridge that owns the default route (typically `br-wan`, provisioned by cloud-init / netplan / systemd-networkd). You can override the detection with `--wan-bridge=<name>`. Once detected, the script creates the OVS bridge `br-ext` and links it to the WAN bridge with a veth pair. The Linux bridge keeps its IP and routes — no interruption. Bridge-mapping is set to `external:br-ext`.
If the default route is on a bare physical NIC (no bridge), `setup-ovn.sh`
stops and prints guidance on how to convert the NIC to a bridge before
re-running.
If the default route is on a bare physical NIC (no bridge), `setup-ovn.sh` stops and prints guidance on how to convert the NIC to a bridge before re-running.
### Example: Required `br-wan` State
@@ -374,8 +299,7 @@ The host must have something resembling this before `setup-ovn.sh` is run:
valid_lft forever preferred_lft forever
```
The physical NIC (e.g. `wan`, `eth0`, `eno1`) is enslaved to `br-wan` and has
no IP of its own — all L3 state lives on the bridge.
The physical NIC (e.g. `wan`, `eth0`, `eno1`) is enslaved to `br-wan` and has no IP of its own — all L3 state lives on the bridge.
Example netplan that produces this:
@@ -401,14 +325,9 @@ Every Spinifex node has three bridges in the datapath:
| `br-ext` | OVS bridge | `setup-ovn.sh` | OVN external uplink (`localnet`) | veth peer to `br-wan` |
| `br-int` | OVS bridge | `setup-ovn.sh` | VM overlay traffic (Geneve tunnels) | VM TAP devices, tunnel ports |
`br-wan` is provisioned by your distro's network configuration (cloud-init,
netplan, systemd-networkd, ifupdown). The name is configurable; `br-wan` is
the convention. `br-int` and `br-ext` are always created by `setup-ovn.sh`.
`br-wan` is provisioned by your distro's network configuration (cloud-init, netplan, systemd-networkd, ifupdown). The name is configurable; `br-wan` is the convention. `br-int` and `br-ext` are always created by `setup-ovn.sh`.
The link between `br-ext` and the VM datapath is logical, not physical: OVN's
`localnet` port type maps the logical external switch to `br-ext` via
`ovn-bridge-mappings`. Frames egressing a VM travel TAP → `br-int` → OVN
pipeline → `br-ext` → veth → `br-wan` → physical NIC → wire.
The link between `br-ext` and the VM datapath is logical, not physical: OVN's `localnet` port type maps the logical external switch to `br-ext` via `ovn-bridge-mappings`. Frames egressing a VM travel TAP → `br-int` → OVN pipeline → `br-ext` → veth → `br-wan` → physical NIC → wire.
<p align="center">
<img src="../../../.github/assets/diagrams/vpc-datapath-localnet.svg" alt="Data path — VM TAP through br-int, OVN pipeline, br-ext, veth pair, br-wan, physical NIC" width="900">
@@ -424,17 +343,13 @@ sudo setup-ovn.sh
sudo setup-ovn.sh --wan-bridge=br-wan
```
In environments where the WAN IP comes from a router's DHCP server (homelab,
small office), add `--dhcp` to obtain a gateway IP from the router
automatically:
In environments where the WAN IP comes from a router's DHCP server (homelab, small office), add `--dhcp` to obtain a gateway IP from the router automatically:
```bash
sudo setup-ovn.sh --dhcp
```
This requests an IP from the **router's DHCP** (e.g., 192.168.1.1 serving
addresses on the LAN). This is not Spinifex's internal OVN DHCP that assigns
private IPs to VMs — it's your network's existing DHCP server.
This requests an IP from the **router's DHCP** (e.g., 192.168.1.1 serving addresses on the LAN). This is not Spinifex's internal OVN DHCP that assigns private IPs to VMs — it's your network's existing DHCP server.
| Flags | Result |
| ------------------------------ | --------------------------------------------------------------------- |
@@ -442,8 +357,7 @@ private IPs to VMs — it's your network's existing DHCP server.
| `--wan-bridge=<name>` | Use the specified Linux bridge as the WAN uplink |
| `--dhcp` | Obtain the OVN gateway IP from the router's DHCP |
If no Linux bridge owns the default route, `setup-ovn.sh` exits with guidance
rather than silently breaking host connectivity.
If no Linux bridge owns the default route, `setup-ovn.sh` exits with guidance rather than silently breaking host connectivity.
## Per-Node Configuration
@@ -460,10 +374,7 @@ external_interface = "br-public"
external_interface = "br-wan" # br-wan enslaving bond0
```
`external_interface` is the **Linux bridge** that owns the WAN uplink on this
node — not the physical NIC. The physical NIC lives underneath the bridge.
Each node runs `setup-ovn.sh` with its own WAN bridge name (or relies on
auto-detection). OVN only requires `ovn-bridge-mappings` to point at `br-ext`.
`external_interface` is the **Linux bridge** that owns the WAN uplink on this node — not the physical NIC. The physical NIC lives underneath the bridge. Each node runs `setup-ovn.sh` with its own WAN bridge name (or relies on auto-detection). OVN only requires `ovn-bridge-mappings` to point at `br-ext`.
## Bridge Verification
@@ -500,8 +411,7 @@ ip -d link show wan
## Configuration Reference
All network configuration lives in `spinifex.toml`. Settings are split into three
levels: cluster-wide mode, IP pool definitions, and per-node NIC settings.
All network configuration lives in `spinifex.toml`. Settings are split into three levels: cluster-wide mode, IP pool definitions, and per-node NIC settings.
## Configuration Levels
@@ -524,8 +434,7 @@ external_mode = "pool" # "pool", "nat", or "" (disabled)
## IP Pools: network.external_pools
Each pool defines where external IPs come from. You can have one pool (homelab)
or many (multi-region datacenter).
Each pool defines where external IPs come from. You can have one pool (homelab) or many (multi-region datacenter).
```toml
[[network.external_pools]]
@@ -559,19 +468,14 @@ dns_servers = ["8.8.8.8"] # DNS for VMs (optional)
### Why range_start/range_end Instead of CIDR?
Customer IP ranges rarely align to CIDR boundaries. A datacenter might have
`203.0.113.10-203.0.113.200` from their ISP. Start/end avoids forcing admins to
calculate CIDR blocks.
Customer IP ranges rarely align to CIDR boundaries. A datacenter might have `203.0.113.10-203.0.113.200` from their ISP. Start/end avoids forcing admins to calculate CIDR blocks.
### Gateway vs Gateway_IP
These are different things:
- **`gateway`** = Your network's default gateway (e.g., 192.168.1.1). This is
where OVN sends packets destined for the internet. It's your router.
- **`gateway_ip`** = The IP that OVN uses for outbound SNAT. In pool mode,
defaults to the first IP in the range. In NAT mode, set this explicitly.
Must be on the same subnet as the gateway.
- **`gateway`** = Your network's default gateway (e.g., 192.168.1.1). This is where OVN sends packets destined for the internet. It's your router.
- **`gateway_ip`** = The IP that OVN uses for outbound SNAT. In pool mode, defaults to the first IP in the range. In NAT mode, set this explicitly. Must be on the same subnet as the gateway.
## Per-Node: nodes.NAME.vpcd
@@ -598,15 +502,11 @@ When an instance needs a public IP:
3. **Unscoped fallback**: Pool with no `region`/`az` (global, homelab configs)
4. **Exhausted**: All pools full → `InsufficientAddressCapacity` error
`AllocateAddress` accepts optional pool name to target a specific block
(maps to AWS `PublicIpv4Pool`).
`AllocateAddress` accepts optional pool name to target a specific block (maps to AWS `PublicIpv4Pool`).
## IPAM Storage
Pool allocation state is stored durably in the cluster (NATS KV bucket
`spinifex-external-ipam`, one entry per pool) and survives restarts. Each
allocation records the ENI and instance holding the address. Pools are
initialized from `spinifex.toml` on vpcd startup (idempotent).
Pool allocation state is stored durably in the cluster (NATS KV bucket `spinifex-external-ipam`, one entry per pool) and survives restarts. Each allocation records the ENI and instance holding the address. Pools are initialized from `spinifex.toml` on vpcd startup (idempotent).
## Deployment Examples
@@ -633,10 +533,7 @@ prefix_len = 24
external_interface = "br-wan"
```
**Setup:** Configure `br-wan` to enslave your physical WAN NIC (netplan,
cloud-init, or systemd-networkd). Change your router's DHCP range to end
at .149. Run `sudo setup-ovn.sh` — it auto-detects the WAN bridge from the
default route, or specify it with `--wan-bridge=br-wan`.
**Setup:** Configure `br-wan` to enslave your physical WAN NIC (netplan, cloud-init, or systemd-networkd). Change your router's DHCP range to end at .149. Run `sudo setup-ovn.sh` — it auto-detects the WAN bridge from the default route, or specify it with `--wan-bridge=br-wan`.
## Homelab / Dev (DHCP Pool — No Range Reservation)
@@ -661,9 +558,7 @@ dns_servers = ["192.168.1.1", "8.8.8.8"]
external_interface = "br-wan"
```
**Setup:** No router changes needed. Spinifex requests IPs from the router's
DHCP server when VMs launch and releases them on terminate. Requires `dhclient`
on the host (`apt install isc-dhcp-client`).
**Setup:** No router changes needed. Spinifex requests IPs from the router's DHCP server when VMs launch and releases them on terminate. Requires `dhclient` on the host (`apt install isc-dhcp-client`).
## Host-Local Subnet (No Upstream Router)
@@ -673,9 +568,7 @@ Host WAN: 198.51.100.10/24 on br-wan (existing address — unchanged)
Gateway: 192.168.10.1 — second address added to br-wan
```
Add the VM pool gateway as a second address on `br-wan` alongside the existing WAN
IP. The host acts as the gateway for the pool — no upstream router or DHCP server
needed for this range.
Add the VM pool gateway as a second address on `br-wan` alongside the existing WAN IP. The host acts as the gateway for the pool — no upstream router or DHCP server needed for this range.
```yaml
# /etc/netplan/…
@@ -703,8 +596,7 @@ prefix_len = 24
dns_servers = ["8.8.8.8"]
```
**Setup:** Apply with `sudo netplan apply`. VMs are reachable from the host at
`192.168.10.x`. For internet access through the host's WAN interface:
**Setup:** Apply with `sudo netplan apply`. VMs are reachable from the host at `192.168.10.x`. For internet access through the host's WAN interface:
```bash
sysctl -w net.ipv4.ip_forward=1
@@ -744,9 +636,7 @@ external_interface = "br-public" # br-public enslaves eno1
## Enterprise On-Prem (VLAN)
The Linux WAN bridge enslaves a VLAN sub-interface (e.g. `eth1.200`) instead
of a raw NIC. From OVN's perspective nothing changes — `external_interface`
still points at the bridge.
The Linux WAN bridge enslaves a VLAN sub-interface (e.g. `eth1.200`) instead of a raw NIC. From OVN's perspective nothing changes — `external_interface` still points at the bridge.
```toml
[network]
@@ -817,21 +707,15 @@ prefix_len = 23
region = "eu-west-1"
```
Spinifex allocates from the correct pool based on where the instance launches.
An instance in `us-east-1a` gets an IP from `us-east-1a` first; if exhausted,
falls back to `us-east-overflow`.
Spinifex allocates from the correct pool based on where the instance launches. An instance in `us-east-1a` gets an IP from `us-east-1a` first; if exhausted, falls back to `us-east-overflow`.
## Security Groups
Security groups are stateful firewalls enforced at the OVS datapath level on
each hypervisor. Traffic is filtered before it reaches the wire — equivalent to
AWS Nitro card enforcement. The VM never sees dropped packets.
Security groups are stateful firewalls enforced at the OVS datapath level on each hypervisor. Traffic is filtered before it reaches the wire — equivalent to AWS Nitro card enforcement. The VM never sees dropped packets.
## How Security Groups Work
Each security group maps to an OVN **Port Group**. When an instance launches,
its ENI port is added to the port group(s) for its security groups. ACL rules
on the port group control traffic:
Each security group maps to an OVN **Port Group**. When an instance launches, its ENI port is added to the port group(s) for its security groups. ACL rules on the port group control traffic:
- **Default deny**: All inbound traffic dropped at priority 900
- **Allow rules**: Specific ports/protocols allowed at priority 1000 (overrides deny)
@@ -881,9 +765,7 @@ Rule changes take effect immediately — no instance restart needed.
## Elastic IPs
Elastic IPs are static public IPs that persist across instance stop/start cycles.
Unlike auto-assigned public IPs (which change on stop/start), an Elastic IP stays
with your instance.
Elastic IPs are static public IPs that persist across instance stop/start cycles. Unlike auto-assigned public IPs (which change on stop/start), an Elastic IP stays with your instance.
```bash
# Allocate
@@ -901,8 +783,7 @@ aws ec2 disassociate-address --association-id $ASSOC_ID
aws ec2 release-address --allocation-id $EIP
```
When you associate an Elastic IP with an instance that already has an
auto-assigned public IP, the auto-assigned IP is released and replaced.
When you associate an Elastic IP with an instance that already has an auto-assigned public IP, the auto-assigned IP is released and replaced.
## OVN Reference
@@ -945,10 +826,7 @@ ovn-nbctl lr-nat-add vpc-{vpcId} dnat_and_snat {public_ip} {private_ip} \
port-{eniId} {vm_mac}
```
With the WAN NIC on a Linux bridge wired to OVS via veth, OVS sees every frame
on the wire regardless of MAC, so OVN can use distributed NAT. The DNAT is
processed on the chassis hosting the VM rather than hairpinning through a
single gateway chassis.
With the WAN NIC on a Linux bridge wired to OVS via veth, OVS sees every frame on the wire regardless of MAC, so OVN can use distributed NAT. The DNAT is processed on the chassis hosting the VM rather than hairpinning through a single gateway chassis.
## Security Group
@@ -1004,8 +882,7 @@ sudo ovn-nbctl pg-get-ports sg-{groupId}
## 1. Set Up OVN Bridges
Make sure the WAN NIC is enslaved to a Linux bridge (e.g. `br-wan`) and that
bridge owns the default route. Then run:
Make sure the WAN NIC is enslaved to a Linux bridge (e.g. `br-wan`) and that bridge owns the default route. Then run:
```bash
sudo setup-ovn.sh # auto-detect WAN bridge
@@ -1078,8 +955,7 @@ aws ec2 describe-instances --instance-ids $INSTANCE \
### Debugging Toolkit
These commands are used throughout the troubleshooting sections below. Learn
them — they cover 90% of VPC networking issues.
These commands are used throughout the troubleshooting sections below. Learn them — they cover 90% of VPC networking issues.
### OVN Northbound (Logical Topology)
@@ -1129,9 +1005,7 @@ sudo ovn-trace ext-vpc-{vpcId} \
### OVN DB RAFT cluster (NB/SB replication)
The NB and SB databases run clustered across the first three nodes via native
OVSDB RAFT. A 3-node quorum tolerates the loss of one DB node; check status when
the control plane stalls.
The NB and SB databases run clustered across the first three nodes via native OVSDB RAFT. A 3-node quorum tolerates the loss of one DB node; check status when the control plane stalls.
```bash
# NB cluster status: expect 3 servers and exactly one "Role: leader"
@@ -1144,9 +1018,7 @@ sudo ovn-appctl -t /var/run/ovn/ovnsb_db.ctl cluster/status OVN_Southbound
sudo ovn-nbctl --db=tcp:10.1.3.181:6641,tcp:10.1.3.182:6641,tcp:10.1.3.183:6641 show
```
A node showing only itself under `Servers:` never joined the cluster — confirm
it was bootstrapped with `--db-cluster-remote-addr` pointing at the creator and
that RAFT ports 6643/6644 are reachable between DB nodes.
A node showing only itself under `Servers:` never joined the cluster — confirm it was bootstrapped with `--db-cluster-remote-addr` pointing at the creator and that RAFT ports 6643/6644 are reachable between DB nodes.
### OVS (datapath / physical wiring)
@@ -1272,10 +1144,7 @@ ip route show
### Instance Has Public IP But No Internet
The instance shows a public IP in `describe-instances` but cannot reach the
internet, and inbound connections hang. The usual cause is a missing default
route: the subnet's effective route table has no `0.0.0.0/0` route to the IGW,
so Spinifex gates the subnet with a drop policy.
The instance shows a public IP in `describe-instances` but cannot reach the internet, and inbound connections hang. The usual cause is a missing default route: the subnet's effective route table has no `0.0.0.0/0` route to the IGW, so Spinifex gates the subnet with a drop policy.
```bash
# Find the route table that applies to the subnet and check its routes
@@ -1285,16 +1154,14 @@ aws ec2 describe-route-tables \
# Expect a route with DestinationCidrBlock 0.0.0.0/0 and a GatewayId of igw-...
```
If the subnet has no explicit association it falls back to the VPC's main route
table — check that one too, then add the route to whichever table applies:
If the subnet has no explicit association it falls back to the VPC's main route table — check that one too, then add the route to whichever table applies:
```bash
aws ec2 create-route --route-table-id $RT \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW
```
On the host, the drop policy installed when a subnet lacks an IGW route appears
as a `Logical_Router_Policy` on the VPC router:
On the host, the drop policy installed when a subnet lacks an IGW route appears as a `Logical_Router_Policy` on the VPC router:
```bash
sudo ovn-nbctl lr-policy-list vpc-$VPC
@@ -1387,9 +1254,7 @@ sudo tcpdump -i br-wan -n "host {public_ip}"
sudo tcpdump -i br-ext -n -e "host {public_ip}"
```
If traffic arrives on the physical NIC but not br-wan, the NIC is not
enslaved to the bridge. If it reaches br-wan but not br-ext, the veth
pair between them is missing or down — re-run `setup-ovn.sh`.
If traffic arrives on the physical NIC but not br-wan, the NIC is not enslaved to the bridge. If it reaches br-wan but not br-ext, the veth pair between them is missing or down — re-run `setup-ovn.sh`.
### 6. Use ovn-trace for pipeline debugging
@@ -1402,8 +1267,7 @@ sudo ovn-trace --ct=new ext-vpc-{vpcId} \
arp.tpa=={public_ip}'
```
The output shows every table the packet passes through and what action is
taken. Look for `drop` actions or unexpected paths.
The output shows every table the packet passes through and what action is taken. Look for `drop` actions or unexpected paths.
### OVN SB Commit Failure Loop
@@ -1415,9 +1279,7 @@ OVNSB commit failed, force recompute next time.
Repeated millions of times. Port binding never happens (`up: false`).
**Cause:** Stale entries in the OVN Southbound DB (old chassis records, port
bindings, datapath bindings) conflict with ovn-controller's expected state,
typically after an ungraceful shutdown.
**Cause:** Stale entries in the OVN Southbound DB (old chassis records, port bindings, datapath bindings) conflict with ovn-controller's expected state, typically after an ungraceful shutdown.
**Fix:** Delete both OVN DB files and restart:
@@ -1431,15 +1293,11 @@ sudo systemctl start ovn-central ovn-controller
### WAN NIC Not Enslaved to a Bridge
**Symptom:** `setup-ovn.sh` exits with an error like "default route is on a
physical NIC, not a bridge" and refuses to continue.
**Symptom:** `setup-ovn.sh` exits with an error like "default route is on a physical NIC, not a bridge" and refuses to continue.
**Cause:** Spinifex requires the WAN NIC to be enslaved to a Linux bridge
(typically `br-wan`). Macvlan is no longer supported, and attaching the NIC
directly to OVS would break SSH and any other host services using the NIC.
**Cause:** Spinifex requires the WAN NIC to be enslaved to a Linux bridge (typically `br-wan`). Macvlan is no longer supported, and attaching the NIC directly to OVS would break SSH and any other host services using the NIC.
**Fix:** Move the host IP and default route onto a Linux bridge. Example
netplan:
**Fix:** Move the host IP and default route onto a Linux bridge. Example netplan:
```yaml
network:
@@ -1453,13 +1311,11 @@ network:
dhcp4: true
```
Apply (`sudo netplan apply`), confirm the host IP is now on `br-wan`
(`ip -br addr show br-wan`), and re-run `setup-ovn.sh`.
Apply (`sudo netplan apply`), confirm the host IP is now on `br-wan` (`ip -br addr show br-wan`), and re-run `setup-ovn.sh`.
### Stale ARP on Remote Hosts
**Symptom:** Ping from a LAN host to a VM public IP fails after a reset, but
worked before. The remote host has a stale ARP entry with the old MAC.
**Symptom:** Ping from a LAN host to a VM public IP fails after a reset, but worked before. The remote host has a stale ARP entry with the old MAC.
**Fix:** Flush the ARP entry on the remote host:
@@ -1469,8 +1325,7 @@ sudo ip neigh flush dev {nic} {public_ip}
ping {public_ip} # should work now
```
OVN sends periodic gratuitous ARPs from the chassis hosting the VM that will
eventually update remote ARP caches, but flushing is faster for testing.
OVN sends periodic gratuitous ARPs from the chassis hosting the VM that will eventually update remote ARP caches, but flushing is faster for testing.
### Security Group Rules Not Taking Effect
+1 -3
View File
@@ -85,9 +85,7 @@ aws ecr describe-repositories --repository-names my-app \
--query 'repositories[0].repositoryUri'
```
The `repositoryUri` is the value you tag and push to — the gateway `host:9999`
with the repository path, port included (for example `192.0.2.10:9999/my-app`).
Use it verbatim; don't hand-build a hostname.
The `repositoryUri` is the value you tag and push to — the gateway `host:9999` with the repository path, port included (for example `192.0.2.10:9999/my-app`). Use it verbatim; don't hand-build a hostname.
### 2. Authenticate Docker
+1 -5
View File
@@ -156,11 +156,7 @@ aws ecs register-task-definition \
### 3. Add capacity
Launch one or more EC2 instances from the ECS node image with the `ecsInstanceRole`
instance profile and a cloud-init that points the agent at the cluster. The
[ECS Quickstart workbook](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/ecs-quickstart)
produces this user-data for you; the console **Provision capacity** action is the
one-click equivalent. Once an instance boots and its agent registers, it appears here:
Launch one or more EC2 instances from the ECS node image with the `ecsInstanceRole` instance profile and a cloud-init that points the agent at the cluster. The [ECS Quickstart workbook](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/ecs-quickstart) produces this user-data for you; the console **Provision capacity** action is the one-click equivalent. Once an instance boots and its agent registers, it appears here:
```bash
aws ecs list-container-instances --cluster demo
+1 -4
View File
@@ -228,10 +228,7 @@ Auto-detected encap IP: 192.168.0.249
...
```
The `system-id` is the OVS-managed chassis UUID (read back from
`/etc/openvswitch/system-id.conf`); this is the same value that appears as
`Chassis.name` in `ovn-sbctl show`. Cross-reference with `Chassis.hostname`
when you need the human-readable host:
The `system-id` is the OVS-managed chassis UUID (read back from `/etc/openvswitch/system-id.conf`); this is the same value that appears as `Chassis.name` in `ovn-sbctl show`. Cross-reference with `Chassis.hostname` when you need the human-readable host:
```bash
sudo ovn-sbctl --columns=name,hostname list Chassis
@@ -97,8 +97,7 @@ bridges:
via: 198.51.100.1
```
Then apply with `sudo netplan apply`. VMs, once provisioned, are reachable from the host at
`192.168.10.x`. For internet access through the host's WAN interface:
Then apply with `sudo netplan apply`. VMs, once provisioned, are reachable from the host at `192.168.10.x`. For internet access through the host's WAN interface:
```bash
sysctl -w net.ipv4.ip_forward=1
+6 -16
View File
@@ -1,19 +1,14 @@
# Spinifex EKS demo app
A small, Spinifex-themed web app shared by the EKS Terraform workbooks. It
replaces `nginxdemos/hello` with something production-shaped that you build
yourself and push to **ECR**, then pull onto your EKS workers.
A small, Spinifex-themed web app shared by the EKS Terraform workbooks. It replaces `nginxdemos/hello` with something production-shaped that you build yourself and push to **ECR**, then pull onto your EKS workers.
The page reports the pod, node, cluster, and region that answered the request,
plus a hit counter — refresh and watch requests land on different pods across a
multi-node cluster.
The page reports the pod, node, cluster, and region that answered the request, plus a hit counter — refresh and watch requests land on different pods across a multi-node cluster.
## What it does
- `GET /` — themed HTML page.
- `GET /api/info` — JSON of the same facts.
- `GET /healthz` — liveness/readiness probe (`200 ok`), used by the ALB target
group health check.
- `GET /healthz` — liveness/readiness probe (`200 ok`), used by the ALB target group health check.
All configuration is read from the environment, so one image serves every tier:
@@ -25,14 +20,11 @@ All configuration is read from the environment, so one image serves every tier:
| `PORT` | listen port | default `8080` |
| `DATA_DIR` | when set, the hit counter is persisted here and survives pod restarts | mounted **EBS-CSI PVC** in `eks-gitops-argocd` |
When `DATA_DIR` points at a writable volume the page shows a **"persisted to EBS
volume"** badge — the gitops workbook mounts a Viperblock-backed PersistentVolume
there to demonstrate durable storage.
When `DATA_DIR` points at a writable volume the page shows a **"persisted to EBS volume"** badge — the gitops workbook mounts a Viperblock-backed PersistentVolume there to demonstrate durable storage.
## Build and push to ECR
The image is named `spinifex-demo`. Build it once and push it to a repository in
your Spinifex ECR; every workbook references it by its ECR URI.
The image is named `spinifex-demo`. Build it once and push it to a repository in your Spinifex ECR; every workbook references it by its ECR URI.
```bash
export AWS_PROFILE=spinifex
@@ -57,9 +49,7 @@ docker push "${REGISTRY}:latest"
echo "Image: ${REGISTRY}:latest"
```
Pass the resulting `${REGISTRY}:latest` value to a workbook as the `demo_image`
variable (or accept its default, which resolves the URI from the ECR repository
the workbook creates).
Pass the resulting `${REGISTRY}:latest` value to a workbook as the `demo_image` variable (or accept its default, which resolves the URI from the ECR repository the workbook creates).
## Run locally
@@ -146,10 +146,7 @@ This registers the repo credential, creates the Argo CD `Application`, the demo
### Step 5. Watch the Sync and Open the App
The demo app and the Argo CD UI share **one ALB** (an LBC IngressGroup). On
`:443` the app is the catch-all and `argocd.eks-gitops.spinifex.local`
host-routes to the UI; Argo CD also gets a hostless `:8443` listener so it is
reachable on the raw ALB IP before DNS exists. Grab the shared ALB address once:
The demo app and the Argo CD UI share **one ALB** (an LBC IngressGroup). On `:443` the app is the catch-all and `argocd.eks-gitops.spinifex.local` host-routes to the UI; Argo CD also gets a hostless `:8443` listener so it is reachable on the raw ALB IP before DNS exists. Grab the shared ALB address once:
```bash
# Point kubectl at the cluster first (writes ~/.kube/config) — without this every
@@ -179,25 +176,15 @@ curl -k https://"$ALB_IP"/ # app — returns the Spinifex page
curl -k https://"$ALB_IP":8443/ # Argo CD UI
```
The bare ALB IP serves the **app** on `:443`. Hitting `https://<ALB_IP>/` and
expecting Argo CD gives the app instead — use `:8443` for the UI. Once northstar
(or Route 53) resolves the hostnames to the ALB, `app.eks-gitops.spinifex.local`
and `argocd.eks-gitops.spinifex.local` both work on `:443`. Self-signed cert —
accept the browser warning.
The bare ALB IP serves the **app** on `:443`. Hitting `https://<ALB_IP>/` and expecting Argo CD gives the app instead — use `:8443` for the UI. Once northstar (or Route 53) resolves the hostnames to the ALB, `app.eks-gitops.spinifex.local` and `argocd.eks-gitops.spinifex.local` both work on `:443`. Self-signed cert — accept the browser warning.
The page shows the **"persisted to EBS volume"** badge and a hit counter that keeps climbing — delete the pod (`kubectl delete pod -l app=spinifex-demo`) and the count survives, proving the volume is durable.
### Step 6. Open the Argo CD UI
Managing deployments through the Argo CD console is the point of this workbook, so
the UI is exposed on the **same ALB** as the app, not behind a port-forward. The
`workloads` module adds a `NodePort` Service in front of `argocd-server` (the addon
ships it `ClusterIP` only) and two Ingresses in the shared group — a host-routed
one on `:443` and a hostless one on `:8443` for raw-IP access. `argocd-server`
serves TLS on its own port, so both use `backend-protocol: HTTPS`.
Managing deployments through the Argo CD console is the point of this workbook, so the UI is exposed on the **same ALB** as the app, not behind a port-forward. The `workloads` module adds a `NodePort` Service in front of `argocd-server` (the addon ships it `ClusterIP` only) and two Ingresses in the shared group — a host-routed one on `:443` and a hostless one on `:8443` for raw-IP access. `argocd-server` serves TLS on its own port, so both use `backend-protocol: HTTPS`.
**Get the admin credentials.** Argo CD generates a one-time `admin` password into
a Secret on install; the username is always `admin`:
**Get the admin credentials.** Argo CD generates a one-time `admin` password into a Secret on install; the username is always `admin`:
```bash
kubectl -n argocd get secret argocd-initial-admin-secret \
@@ -211,8 +198,7 @@ Open the UI and log in as `admin`:
- By raw IP: `https://<ALB_IP>:8443/`
- By DNS once wired: `https://argocd.eks-gitops.spinifex.local`
The `spinifex-demo` Application shows the sync status, the resource tree, and live
diffs against git — change a manifest in the repo and watch Argo CD reconcile it.
The `spinifex-demo` Application shows the sync status, the resource tree, and live diffs against git — change a manifest in the repo and watch Argo CD reconcile it.
If you'd rather not expose the UI at all, port-forward instead:
@@ -269,12 +255,7 @@ kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50
Confirm the cluster carries `spinifex.io/managed-ingress = "false"` so the LBC owns ingress.
If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ...
403 ... AccessDenied`, the node role is missing the LBC permissions. The
controller runs with the node instance-profile credentials, so the
`${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be
attached to the node role — re-run `tofu apply` on the parent module if it was
provisioned before that policy existed.
If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ... 403 ... AccessDenied`, the node role is missing the LBC permissions. The controller runs with the node instance-profile credentials, so the `${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be attached to the node role — re-run `tofu apply` on the parent module if it was provisioned before that policy existed.
### Provider Connection Refused
+2 -10
View File
@@ -1,13 +1,5 @@
# AWS model cache
The conformance suite reads `api-2.json` from the Go module cache for the
pinned `github.com/aws/aws-sdk-go` version `v1.55.8`. Running Go tests or
`go mod download` populates that cache; the large upstream model files are
intentionally not tracked in this repository.
The conformance suite reads `api-2.json` from the Go module cache for the pinned `github.com/aws/aws-sdk-go` version `v1.55.8`. Running Go tests or `go mod download` populates that cache; the large upstream model files are intentionally not tracked in this repository.
`ec2/error-codes.json` is separate from the SDK models because EC2's
`api-2.json` declares no operation errors. It is a curated subset of the
official [EC2 error-code reference](https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html):
all documented common and server codes, plus the action-specific codes that
Spinifex currently emits. The catalog records its verification date and must be
reviewed against that source when it changes.
`ec2/error-codes.json` is separate from the SDK models because EC2's `api-2.json` declares no operation errors. It is a curated subset of the official [EC2 error-code reference](https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html): all documented common and server codes, plus the action-specific codes that Spinifex currently emits. The catalog records its verification date and must be reviewed against that source when it changes.
+3 -7
View File
@@ -1,7 +1,6 @@
# Spinifex Scripts
Node installation, dev-environment lifecycle, guest image builds, and verification
tooling for Spinifex.
Node installation, dev-environment lifecycle, guest image builds, and verification tooling for Spinifex.
## Install and node setup
@@ -47,9 +46,7 @@ tooling for Spinifex.
### `images/` — per-image build inputs
Each subdirectory holds a `manifest.conf`, init scripts, and a `setup.sh` that
`build-system-image.sh` runs inside the libguestfs appliance after packages and files are
placed. `*_test.sh` files are shell unit tests for the helper of the same name.
Each subdirectory holds a `manifest.conf`, init scripts, and a `setup.sh` that `build-system-image.sh` runs inside the libguestfs appliance after packages and files are placed. `*_test.sh` files are shell unit tests for the helper of the same name.
| Path | What it builds |
|---|---|
@@ -62,8 +59,7 @@ placed. `*_test.sh` files are shell unit tests for the helper of the same name.
### `images/eks-node/` — in-guest helpers
Installed into the EKS node AMI and run at boot or on a timer. All host communication
goes through the AWS gateway over SigV4; the VM never speaks NATS directly.
Installed into the EKS node AMI and run at boot or on a timer. All host communication goes through the AWS gateway over SigV4; the VM never speaks NATS directly.
| Script | What it does |
|---|---|
+4 -4
View File
@@ -8,7 +8,7 @@ Note: This is not needed for actually building spinifex-ui and running it. We co
1. **Install Node.js using nvm**
This project uses Node.js 24.12.0 (specified in `.nvmrc`).
This project uses Node.js 24.12.0 (specified in `.nvmrc`).
```sh
cd frontend
@@ -23,7 +23,7 @@ Note: This is not needed for actually building spinifex-ui and running it. We co
nvm use
```
The `.nvmrc` file in the project root ensures you use the correct Node.js version. Run `nvm use` whenever you enter this directory.
The `.nvmrc` file in the project root ensures you use the correct Node.js version. Run `nvm use` whenever you enter this directory.
2. **Enable Corepack**
@@ -45,11 +45,11 @@ Note: This is not needed for actually building spinifex-ui and running it. We co
5. **Accept Certs In Browser**
If you have added the CA to your machine you do not need to do this. But if you are sshd into a spinifex machine and want to view the ui, go to [https://localhost:9999](https://localhost:9999) and [https://localhost:8443](https://localhost:8443) and accept the certificates
If you have added the CA to your machine you do not need to do this. But if you are sshd into a spinifex machine and want to view the ui, go to [https://localhost:9999](https://localhost:9999) and [https://localhost:8443](https://localhost:8443) and accept the certificates
6. **Launch Server**
For development use:
For development use:
```sh
cd frontend
+5 -14
View File
@@ -1,9 +1,6 @@
# DDIL E2E Test Harness
Go-based E2E harness that exercises Spinifex and Predastore against DDIL
(Denied, Disrupted, Intermittent, Limited) failure modes — NATS kill,
daemon-without-NATS restart, cluster partition, degraded links, Predastore
writes under partition, Raft under SATCOM latency.
Go-based E2E harness that exercises Spinifex and Predastore against DDIL (Denied, Disrupted, Intermittent, Limited) failure modes — NATS kill, daemon-without-NATS restart, cluster partition, degraded links, Predastore writes under partition, Raft under SATCOM latency.
## Layout
@@ -15,8 +12,7 @@ tests/e2e/ddil/
└── scenarios/ # TestScenarioA..F, build tag e2e
```
All test and helper files use `//go:build e2e` so `go build ./...` skips them.
Run with `-tags=e2e`.
All test and helper files use `//go:build e2e` so `go build ./...` skips them. Run with `-tags=e2e`.
## Running
@@ -36,15 +32,13 @@ Run a single scenario:
go test -tags=e2e -run TestScenarioA ./tests/e2e/ddil/scenarios/...
```
Dry-run mode (no cluster ops; verifies SSH reachability, tool availability on
remote nodes, and runs `TestCoverageDrift` only):
Dry-run mode (no cluster ops; verifies SSH reachability, tool availability on remote nodes, and runs `TestCoverageDrift` only):
```bash
DDIL_DRY_RUN=1 go test -tags=e2e ./tests/e2e/ddil/scenarios/...
```
Quarantine listed scenarios (failures downgrade to `t.Skip` with a tagged
reason):
Quarantine listed scenarios (failures downgrade to `t.Skip` with a tagged reason):
```bash
DDIL_QUARANTINED=D,F go test -tags=e2e ./tests/e2e/ddil/scenarios/...
@@ -68,7 +62,4 @@ DDIL_QUARANTINED=D,F go test -tags=e2e ./tests/e2e/ddil/scenarios/...
## Scenario status
Scenario-level status (SKIPPED / ENABLED / QUARANTINED) plus profile
validation tracking lives in [`tests/e2e/TEST_COVERAGE.md`](../TEST_COVERAGE.md).
Scenarios begin as `t.Skip("requires <dep>")` and are flipped to real
assertions by the hardening epics they exercise.
Scenario-level status (SKIPPED / ENABLED / QUARANTINED) plus profile validation tracking lives in [`tests/e2e/TEST_COVERAGE.md`](../TEST_COVERAGE.md). Scenarios begin as `t.Skip("requires <dep>")` and are flipped to real assertions by the hardening epics they exercise.
+11 -46
View File
@@ -1,9 +1,6 @@
# AWS model conformance ratchet
The integration suite validates every successful modelled AWS response. Services
listed in `conformance-promoted-services.json` are blocking in the default
`fail` mode; findings from all other services remain warnings until triaged and
fixed.
The integration suite validates every successful modelled AWS response. Services listed in `conformance-promoted-services.json` are blocking in the default `fail` mode; findings from all other services remain warnings until triaged and fixed.
Run the ratchet with:
@@ -11,18 +8,15 @@ Run the ratchet with:
make test-integration
```
Use `AWS_MODEL_CONFORMANCE_MODE=warn` only for investigation. The report is
written to `.cache/aws-model-conformance-report.txt` by default.
Use `AWS_MODEL_CONFORMANCE_MODE=warn` only for investigation. The report is written to `.cache/aws-model-conformance-report.txt` by default.
## Promoted services
- `sts` — promoted on 2026-08-05 after the integration baseline completed with
no STS violations or decoder errors.
- `sts` — promoted on 2026-08-05 after the integration baseline completed with no STS violations or decoder errors.
## Triaged warnings
Baseline from 2026-08-05: 370 successful responses checked, three violations,
zero decoder errors.
Baseline from 2026-08-05: 370 successful responses checked, three violations, zero decoder errors.
| Service / operation | Finding | Classification | Implementation cause |
|---|---|---|---|
@@ -30,32 +24,15 @@ zero decoder errors.
| IAM `ListAccessKeys` | Required `AccessKeyMetadata` missing | Defect | The empty result starts as a nil slice, so Query XML serialization omits the required list container. |
| IAM `ListGroups` | Required `Groups` missing | Defect | When the KV bucket has keys but none match the account, the result slice remains nil and Query XML serialization omits the required list container. |
These warnings are not suppressed by a finding allowlist. They remain visible
on every run. Handler changes belong in focused follow-up work; once a service's
warnings are resolved, add that service to the promoted-services file in the
same change that proves its full integration baseline is clean.
These warnings are not suppressed by a finding allowlist. They remain visible on every run. Handler changes belong in focused follow-up work; once a service's warnings are resolved, add that service to the promoted-services file in the same change that proves its full integration baseline is clean.
## Error-code coverage
Phase 4 validates IAM, STS, ECS, and ELBv2 error envelopes against their service
models. The 2026-08-05 full integration baseline checked 46 operation-declared
errors (45 IAM, one STS) with no error-code violations. ECS and ELBv2
decoding/model comparison have focused tests but their error paths are not
exercised by the current integration suite, so neither service is ready for
promotion on that evidence.
Phase 4 validates IAM, STS, ECS, and ELBv2 error envelopes against their service models. The 2026-08-05 full integration baseline checked 46 operation-declared errors (45 IAM, one STS) with no error-code violations. ECS and ELBv2 decoding/model comparison have focused tests but their error paths are not exercised by the current integration suite, so neither service is ready for promotion on that evidence.
Twelve responses are reported separately as `errors_unmodelled`: four IAM and
eight STS. These are protocol-level authentication/authorization/signature/
throttling errors that AWS omits from operation error lists, plus STS's runtime
`ValidationError` and `InvalidParameterValue` responses. “Unmodelled” is not a
conformance pass; it means the operation model cannot judge the code. They stay
visible in the report rather than being silently counted as conforming.
Twelve responses are reported separately as `errors_unmodelled`: four IAM and eight STS. These are protocol-level authentication/authorization/signature/ throttling errors that AWS omits from operation error lists, plus STS's runtime `ValidationError` and `InvalidParameterValue` responses. “Unmodelled” is not a conformance pass; it means the operation model cannot judge the code. They stay visible in the report rather than being silently counted as conforming.
EC2's model declares no operation errors, so EC2 uses a separate checked-in
catalog curated from AWS's official EC2 error-code reference. It contains every
documented common and server error plus the documented action-specific codes
Spinifex currently emits. The validator checks the EC2 XML envelope, catalog
membership, and AWS's documented 4xx client / 5xx server classification.
EC2's model declares no operation errors, so EC2 uses a separate checked-in catalog curated from AWS's official EC2 error-code reference. It contains every documented common and server error plus the documented action-specific codes Spinifex currently emits. The validator checks the EC2 XML envelope, catalog membership, and AWS's documented 4xx client / 5xx server classification.
The same baseline examined 42 EC2 error responses and exposed 16 violations:
@@ -64,22 +41,10 @@ The same baseline examined 42 EC2 error responses and exposed 16 violations:
| EC2 authorization failures return `AccessDenied`, which is absent from the EC2 catalog; EC2 documents `UnauthorizedOperation` | 15 | Defect | The shared policy evaluator returns the Query/IAM-oriented `AccessDenied` code for EC2 requests. |
| `RequestSpotInstances` returns `InsufficientInstanceCapacity` with HTTP 400 instead of the documented server-error 5xx class | 1 | Defect | The global error lookup currently assigns HTTP 400 to `InsufficientInstanceCapacity`. |
Six additional production-referenced values are deliberately not accepted by
the catalog because the chosen EC2 reference does not list them: `ExpiredToken`,
`IamInstanceProfileAlreadyAssociated`, `InvalidIamInstanceProfile.NotFound`,
`NoSuchAssociation`, `NoSuchEntity`, and `RequestEntityTooLarge`. A runtime
occurrence remains a visible violation until it is either backed by an
authoritative EC2 source or replaced with a documented EC2 code.
Six additional production-referenced values are deliberately not accepted by the catalog because the chosen EC2 reference does not list them: `ExpiredToken`, `IamInstanceProfileAlreadyAssociated`, `InvalidIamInstanceProfile.NotFound`, `NoSuchAssociation`, `NoSuchEntity`, and `RequestEntityTooLarge`. A runtime occurrence remains a visible violation until it is either backed by an authoritative EC2 source or replaced with a documented EC2 code.
## Operation coverage
Phase 5 generates the public model-versus-dispatch inventory at
`docs/aws-model-operation-coverage.md`. Run `make aws-model-coverage` to
regenerate and print it. A normal `make build` and the integration target both
regenerate the document so handler-map changes cannot silently leave it stale.
Phase 5 generates the public model-versus-dispatch inventory at `docs/aws-model-operation-coverage.md`. Run `make aws-model-coverage` to regenerate and print it. A normal `make build` and the integration target both regenerate the document so handler-map changes cannot silently leave it stale.
The inventory counts modelled operations bound to real handlers separately
from registered stubs and deliberately unsupported handlers. S3 is marked
opaque because Spinifex delegates its REST surface to Predastore rather than
using an operation-name dispatch table; no mechanical S3 coverage percentage
is claimed.
The inventory counts modelled operations bound to real handlers separately from registered stubs and deliberately unsupported handlers. S3 is marked opaque because Spinifex delegates its REST surface to Predastore rather than using an operation-name dispatch table; no mechanical S3 coverage percentage is claimed.