Compare commits

...

18 Commits

Author SHA1 Message Date
Ben Duncan fd9bea1ca7 docs: rework the Vultr guide into the OCI guide's flow, with bare metal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:32:20 +10:00
Ben Duncan f810b07e1e feat: deploy Spinifex on Vultr bare metal, renamed deploy-vultr.sh
A vbm- plan creates bare-metal servers, one or three. Without --name the
deployment writes the AWS profile spinifex, up prints every node's address
and the API key allowlist, credentials and ssh subcommands are added, no AMI
is imported unless --image is given, and OpenTofu or Terraform is used. The
host key is pinned only once cloud-init is done, since bare metal first
answers SSH from Vultr's installer with a different key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:32:20 +10:00
Ben Duncan 37ecadea6c fix: answer ARP for Vultr reserved IPs on bare metal
Vultr routes a reserved IP to a VM by MAC but ARPs for it on a bare-metal
server's public segment, and a Vultr pool gave vpcd neither a gateway nor a
bind bridge to proxy-ARP on, so nothing answered and no traffic arrived. A
Vultr pool now proxy-ARPs on the interface the node routes the internet
through. setup.sh installs arping, which the gratuitous ARP already called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:32:20 +10:00
Ben Duncan 1a6d5a8e2a docs: install the Vultr guide's build from the dev channel until Vultr support is released
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:41:13 +10:00
Ben Duncan b5d16a0173 docs: replace the Vultr guide's apt upgrade warning now the installer keeps needrestart off the stack
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:43:02 +10:00
Ben Duncan e5b901cf11 chore: drop the Vultr cloud-init needrestart override now setup.sh writes it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:35:04 +10:00
Ben Duncan 27ae2e5868 Merge branch 'fix/needrestart-override' into feat/vultr-provider 2026-10-30 02:34:38 +10:00
Ben Duncan 81cd67b12d fix: keep needrestart from restarting the Spinifex, OVS and OVN units
Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:34:24 +10:00
Ben Duncan d4eb7e3029 docs: drop the failover write note from the Vultr guide 2026-10-28 07:15:49 +10:00
Ben Duncan f799f9137a docs: add the Vultr guide, one command to deploy one or three nodes and one to remove them 2026-10-28 07:00:15 +10:00
Ben Duncan 11230b2702 fix: read the pinned host key with ssh-keygen -F when forming a Vultr cluster
ssh_config hashes known_hosts entries by default on Debian, so a grep for the
address found nothing and set -e ended the run without a message.
2026-10-28 06:36:26 +10:00
Ben Duncan 30d6767339 feat: form a three-node Vultr deployment with install-node.sh over the VPC
Each node comes up as an armed single node, the state install-node.sh forms
from, with the cluster and Geneve planes on the VPC NIC. The pool and every
node's provider_host_id go on each node after formation.
2026-10-28 06:05:24 +10:00
Ben Duncan bf78b0e9ac feat: add a Vultr deploy script for a single-node Spinifex on a VX VM
deploy.sh up/destroy/status drives Terraform for the VPC and VM, installs and
forms Spinifex over SSH, copies the API key to the node rather than through
user-data, and sweeps boot volumes and reserved IPs Terraform does not own.
Cloud-init keeps needrestart off the Spinifex, OVS and OVN units and disables
unattended upgrades on these disposable hosts.
2026-10-28 05:55:59 +10:00
Ben Duncan c6a9d46a26 fix: stop reporting the released launch address after an EIP is disassociated
Associating an EIP now releases the auto-assigned public IP, but the stored
instance kept its launch stamp, and DescribeInstances fell back to it once the
EIP was disassociated. The public-IP change handler now moves the stamp with
the runtime address.
2026-10-28 05:55:59 +10:00
Ben Duncan d623f0eb0e fix: retry Vultr's refused detach on reserved-IP detach and delete
Vultr has answered a detach with 400 "Unable to detach subnet" and accepted
the same detach moments later. Surfacing it left the address for reconcile
to collect, up to twenty billed minutes later; retry it like a 5xx instead.
2026-10-28 05:34:17 +10:00
Ben Duncan 6d35140f75 fix: release the auto-assigned public IP an Elastic IP displaces
AssociateAddress restamped the ENI with the EIP and never handed back the
address launch had assigned. Nothing referred to it afterwards, so it stayed
allocated for good, and on a cloud pool every associate left a billed address.
AWS releases it on associate; withdraw its NAT rule and release it, scoped to
the ENI, leaving any address another associated EIP holds alone.
2026-10-28 05:25:34 +10:00
Ben Duncan 00ee6eb244 fix: detach a Vultr reserved IP before deleting it
Vultr was measured refusing delete of an attached address with 400 "Unable to detach subnet", so release, rollback and reconcile now detach first. A first claim after allocation logs at info rather than warn.
2026-10-28 04:56:36 +10:00
Ben Duncan 941c7c3fb0 feat: Vultr reserved-IP provider and floating external-address pool
Adds a source="vultr" external pool backed by Vultr reserved IPs: an API
client pinned by captured responses, an in-memory fake with the measured
semantics, and a floating allocator that creates, claims, reconciles and
releases labelled addresses. OCI and Vultr claims now share one
AddressClaimer pass.

Also fixes the host firewall dropping guest IMDS when imds_host_meta_ip is
set, by matching the pre-DNAT destination, and arms the Spinifex firewall in
place of an active ufw.
2026-10-28 03:23:36 +10:00
55 changed files with 3860 additions and 70 deletions
+307
View File
@@ -0,0 +1,307 @@
---
title: "Spinifex on Vultr"
seoTitle: "Run Spinifex on Vultr with reserved IPs — Spinifex Docs"
description: "Deploy Spinifex on Vultr VMs or bare metal with one command, and run EC2, EBS, S3, VPC, load balancers and EKS on public Vultr reserved IPs it manages."
category: "Cloud Install"
tags:
- install
- vultr
- cloud
- terraform
- cluster
resources:
- title: "Setting Up Your Cluster"
url: "/docs/setting-up-your-cluster"
- title: "Spinifex on Oracle Cloud"
url: "/docs/oci-integration"
- title: "Single-Node Install"
url: "/docs/install"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Running Spinifex on Vultr
> Run the AWS surface (EC2, EBS, S3, VPC, load balancers and EKS) on Vultr, with guests that get real public addresses through Vultr's own API.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Instructions](#instructions)
- [1.
Download Spinifex](#1-download-spinifex)
- [2.
Choose a deployment](#2-choose-a-deployment)
- [3.
Deploy](#3-deploy)
- [4.
Restrict the API key](#4-restrict-the-api-key)
- [5.
Check it worked](#5-check-it-worked)
- [6.
Obtain AWS credentials](#6-obtain-aws-credentials)
- [7.
Set up your cluster](#7-set-up-your-cluster)
- [Removing the deployment](#removing-the-deployment)
- [Limits](#limits)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services to bare-metal, edge, and on-prem environments.
It serves the EC2, EBS, S3, VPC, IAM and STS APIs, plus load balancers and EKS, over an endpoint of your own, so the AWS CLI, the AWS SDKs and the AWS Terraform provider all work against it unmodified.
This guide runs Spinifex on Vultr, on one node or three, on VMs or on bare metal.
It is the same product you would install on your own servers with [Single-Node Install](/docs/install), so everything you build on it behaves the same wherever Spinifex runs.
**Why run it on Vultr:**
- **A global network.** Vultr has 33 locations across six continents, so you can put an AWS-compatible region close to your users or your data.
- **Compute when you need it.** VMs and bare metal are available by the hour in most Vultr locations, with no long-term commitment.
- **Flexibility.** Start on one VM for development, then move to three nodes or bare metal for production with the same command.
- **Portable workloads.** Your applications and Terraform code against the AWS APIs, not against Vultr, so they move between Vultr, AWS, your own rack or an edge site as a deployment decision.
- **Keep your AWS shape.** VPCs, subnets, security groups, instances, volumes and load balancers stay exactly as you define them today.
No rewrite of your application or your infrastructure code is required.
### How Spinifex uses your Vultr API key
Spinifex includes Vultr support, which needs a Vultr API key to provide public networking.
Whenever something you create on Spinifex needs a public address, such as an EC2 instance in a public subnet, an Elastic IP, an internet-facing load balancer or a NAT gateway, Spinifex creates a Vultr reserved IP through the API, attaches it to the node the resource runs on, and routes it to that resource.
It deletes the reserved IP again when the resource goes away.
The key is copied to the nodes over SSH and stored at `/etc/spinifex/vultr/api-key`, readable only by the Spinifex daemon.
It is never written to the Terraform state or to the instance metadata.
---
## Prerequisites
You need:
- **A Vultr account** with room for the nodes you choose in [step 2](#2-choose-a-deployment).
- **A Vultr API key.** In the Vultr portal, open **Account → API** and enable it.
Under **Access Control**, add `0.0.0.0/0` for now.
The nodes call the API to manage their addresses, and their addresses do not exist until the deploy creates them.
You replace `0.0.0.0/0` with the real addresses in [step 4](#4-restrict-the-api-key).
- **OpenTofu or Terraform**, version 1.5 or newer. Install [OpenTofu](https://opentofu.org/docs/intro/install/) or [Terraform](https://developer.hashicorp.com/terraform/install); the deploy script uses whichever it finds, preferring OpenTofu.
- **The AWS CLI v2** and `python3`.
- **An SSH key pair** at `~/.ssh/id_ed25519`.
Create one with `ssh-keygen -t ed25519` if you do not have one.
The deploy installs the public key on every node.
---
## Instructions
## 1. Download Spinifex
The deploy script, `deploy-vultr.sh`, is in the Spinifex repository:
```bash
git clone https://github.com/mulgadc/spinifex.git
cd spinifex
git switch dev
cd scripts/terraform/vultr-spx
```
> [!NOTE]
> Vultr support is on the development branch and not yet in a published release.
> `git switch dev` gives you the deploy script for it, and `--channel dev` in [step 3](#3-deploy) installs the matching Spinifex build, the pre-release published from every change to `dev`.
> Once Vultr support is in a release, skip `git switch dev` and pass `--channel latest`.
---
## 2. Choose a deployment
| Use | Nodes | Plan | Cost while running |
| --- | --- | --- | --- |
| Development and testing | 1 | `vx1-g-16c-64g` VM (16 vCPU, 64 GB) | $0.48 an hour |
| Production | 3 | `vx1-g-16c-64g` VM | $1.44 an hour |
| Production on bare metal | 1 or 3 | `vbm-8c-64gb-amd` bare metal (8 cores, 16 threads, 64 GB, 2 × 1.9 TB NVMe) | $0.55 an hour per server |
**For development and testing, use one `vx1-g-16c-64g` VM.** It is the smallest size we recommend, because the EC2 instances, load balancers, EKS and ECS clusters you create all run inside it and need headroom.
**For production, use three VMs, or bare metal.** On three nodes, an instance whose node fails is restarted on another node, and its public address follows it.
`vbm-8c-64gb-amd` is the smallest bare-metal server we recommend for production workloads, as one server or as three.
Bare metal takes longer to deliver than a VM, and Vultr bills a bare-metal server from the moment it is created, whether it is running or stopped.
---
## 3. Deploy
`deploy-vultr.sh` is a helper script around Terraform or OpenTofu and the official [Vultr provider](https://registry.terraform.io/providers/vultr/vultr/latest), so the Vultr side of the deployment is infrastructure as code you can read, keep and change in `scripts/terraform/vultr-spx/`.
OpenTofu or Terraform creates a private Vultr VPC and the nodes.
The script then installs Spinifex on every node over SSH and forms the cluster.
Each node runs **Ubuntu 24.04 LTS** as its host operating system, and Spinifex runs on top of it.
Vultr installs the operating system when it creates the node, so there is no host to prepare first.
Set your API key, then run the command for the deployment you chose:
```bash
export VULTR_API_KEY=<your key>
# Development and testing: one VM
./deploy-vultr.sh up --channel dev --plan vx1-g-16c-64g
# Production: three VMs
./deploy-vultr.sh up --channel dev --plan vx1-g-16c-64g --nodes 3
# Production on bare metal: one server, or three
./deploy-vultr.sh up --channel dev --plan vbm-8c-64gb-amd
./deploy-vultr.sh up --channel dev --plan vbm-8c-64gb-amd --nodes 3
```
One VM takes about 4 minutes and three VMs about 16 minutes.
Bare metal is slower, because Vultr takes 5 to 6 minutes to deliver a server: one server took 10 minutes in our test, and three took 18½.
The default Vultr region is `ord` (Chicago); pass `--region` for another, and run `./deploy-vultr.sh --help` for every option.
If a step fails, fix the cause and run the same command again: it resumes from the step that failed.
When it finishes, the deploy prints:
```ini
endpoint=https://203.0.113.10:9999
profile=spinifex
ca=/home/you/.spinifex/vultr-default-ca.pem
console=https://203.0.113.10:3000/
node1=203.0.113.10
api_key_allowlist=203.0.113.10/32
```
A three-node deployment lists `node2` and `node3` as well, and all three addresses in `api_key_allowlist`.
---
## 4. Restrict the API key
Now that the node addresses are known, close the API key to everything else.
In the Vultr portal, open **Account → API**, and under **Access Control**:
1. Add each address from the `api_key_allowlist` line.
2. Add your own machine's public address, so that `./deploy-vultr.sh` can still remove the deployment later.
3. Remove `0.0.0.0/0`.
`./deploy-vultr.sh status` prints the node addresses again whenever you need them.
---
## 5. Check it worked
```bash
./deploy-vultr.sh status
```
This lists each node and its address, then every VM or bare-metal server, VPC, volume and reserved IP the deployment holds in your Vultr account, then the completed deploy steps.
To log in to a node:
```bash
./deploy-vultr.sh ssh # node1
./deploy-vultr.sh ssh node2 # another node of a three-node deployment
```
For a three-node deployment, check that every node has joined:
```bash
./deploy-vultr.sh ssh 'spx get nodes'
```
Expect three nodes with `Ready` status.
---
## 6. Obtain AWS credentials
The deploy generated an AWS access key and secret on node 1.
Display them with:
```bash
./deploy-vultr.sh credentials
```
> [!WARNING]
> This reveals an AWS secret.
> Do not redirect its output, or paste the credentials into chat, tickets, source control or logs.
The output has this shape:
```ini
[spinifex]
aws_access_key_id = <access-key>
aws_secret_access_key = <secret-key>
```
Use these to sign in to the web console at the `console` address the deploy printed, `https://<node1 address>:3000/`.
The deploy has also written them to an AWS profile named `spinifex` on your machine, with the endpoint and the cluster's certificate already set, so the AWS CLI works straight away:
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
The same profile is set up on node 1, if you prefer to work from there:
```bash
./deploy-vultr.sh ssh 'export AWS_PROFILE=spinifex; aws ec2 describe-instance-types'
```
If this returns a list of available instance types, your installation is working.
<p align="center">
<img src="../../.github/assets/spinifex-ui.jpg" alt="Spinifex Web UI" width="900">
</p>
**Congratulations, Spinifex is installed.**
---
## 7. Set up your cluster
Spinifex is running, but it holds nothing yet: no machine images, no networks, no instances.
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance.
An instance you launch into a public subnet gets a Vultr reserved IP, which you can see in the Vultr portal under **Network → Reserved IPs**.
---
## Removing the deployment
Remove anything you built on the cluster first, then:
```bash
./deploy-vultr.sh destroy
```
It shows what it will delete and asks you to type the deployment name, `default`, to confirm.
It deletes the nodes and the VPC, plus anything else the deployment left in your Vultr account that keeps billing: VM boot volumes, and any reserved IP Spinifex created.
When it finishes, nothing of the deployment is left, and if anything is, it lists it and fails.
After removing the deployment, remove the node addresses from the API key's **Access Control** list, since Vultr may give them to someone else.
---
## Limits
- **One or three nodes.** Other cluster sizes are not supported.
- **At least 16 vCPU per VM.** Smaller VX plans report their vCPUs as hyperthreads, and Spinifex schedules half of them.
- **A deleted reserved IP keeps its quota slot for 24 hours**, by Vultr's documentation, so creating and deleting many addresses in a day can reach the account's limit.
- **Disassociating an Elastic IP leaves the instance with no public address**, where AWS would give it a new one.
Stopping and starting the instance gets it one.
---
## Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| `the API key was refused; check its IP allowlist` | Your machine's address is not in the key's **Access Control** list. Add it in the portal. |
| `the node's API calls are refused` or `never logged its Vultr allocator` | A node's address is not in the key's **Access Control** list. Add `0.0.0.0/0` until the deploy finishes, then follow [step 4](#4-restrict-the-api-key), and run `up` again. |
| `plan ... is not available in this region` | Pick another region with `--region`, or check the plan's availability in the Vultr portal. |
| `formation failed` | The full log is in `~/.spinifex/vultr/default/form.log`. Run `up` again once the cause is fixed. |
| Any other failure | The logs are under `~/.spinifex/vultr/default/`, and the message names the log on the node. `up` resumes from the failed step. |
+1 -1
View File
@@ -6,7 +6,7 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
| Script | What it does |
|---|---|
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
+63 -8
View File
@@ -16,8 +16,8 @@
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, dns, users, sudoers, firewall, timesync, files,
# directories, env, systemd, sysctl, modules, logrotate,
# udev, fixown, migrations
# directories, env, systemd, sysctl, needrestart, modules,
# logrotate, udev, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -556,6 +556,11 @@ resolve_firewall_mode() {
info "Host firewall: firewalld is active, so the Spinifex policy will be armed in its place"
return 0
fi
if [ "${ISO_BUILD:-0}" != "1" ] && ufw_active; then
INSTALL_SPINIFEX_FIREWALL="on"
info "Host firewall: ufw is active, so the Spinifex policy will be armed in its place"
return 0
fi
# The ISO is an appliance and arms; any other host was already doing something.
if [ "${ISO_BUILD:-0}" = "1" ]; then
INSTALL_SPINIFEX_FIREWALL="on"
@@ -597,6 +602,29 @@ mask_firewalld() {
fi
}
ufw_active() {
command -v ufw > /dev/null 2>&1 || return 1
${SUDO:-} ufw status 2>/dev/null | grep -q '^Status: active'
}
# Disable ufw if it is enforcing, as some cloud images ship it (Vultr's Ubuntu
# does, with INPUT DROP). Its rules would have to accept everything the Spinifex
# policy does, so guest IMDS, routed NAT and the API break with nothing logged.
# `ufw disable` is persistent and reversible; the rules are kept, not migrated.
disable_ufw() {
[ "${ISO_BUILD:-0}" = "1" ] && return 0
ufw_active || return 0
$SUDO ufw disable > /dev/null \
|| fatal "Cannot disable ufw. The Spinifex nftables policy cannot run beside it"
warn "ufw is now disabled: the Spinifex nftables policy is the only supported node firewall."
warn " ufw's rules were not migrated. Restate any restriction you relied on in $FIREWALL_CUSTOM."
if [ "$INSTALL_SPINIFEX_FIREWALL" = "on" ]; then
warn " Until the daemon first loads the policy, this host accepts every connection."
else
warn " The Spinifex policy is not armed (--firewall=off), so this host now has no firewall."
fi
}
# The policy is written here rather than shipped in the tarball so the ISO chroot
# and the ansible path get an identical file without a packaging step. Installed
# but NOT enabled: the peer set is empty until spinifex-daemon resolves the
@@ -651,9 +679,12 @@ table inet spinifex_filter {
# would expose every wildcard-bound host service (sshd, the AWS gateway,
# northstar, the UI) to any tenant. IMDS is HTTP on .254:80; VPC DNS is
# 53 on .253. Everything else on these ports falls through to policy drop.
iifname "ime-*" ip daddr 169.254.169.254 tcp dport 80 accept
iifname "ime-*" ip daddr 169.254.169.253 udp dport 53 accept
iifname "ime-*" ip daddr 169.254.169.253 tcp dport 53 accept
#
# Matched on the pre-DNAT destination: with imds_host_meta_ip set, the
# remap rewrites the address in prerouting, before this hook sees it.
iifname "ime-*" ct original ip daddr 169.254.169.254 tcp dport 80 accept
iifname "ime-*" ct original ip daddr 169.254.169.253 udp dport 53 accept
iifname "ime-*" ct original ip daddr 169.254.169.253 tcp dport 53 accept
# PMTUD is not optional under a Geneve overlay.
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
@@ -771,8 +802,9 @@ CUSTOM
info " $FIREWALL_MODE_FILE ($INSTALL_SPINIFEX_FIREWALL)"
# After the mode file, so a retry after an interrupted install still finds
# 'on' once firewalld is no longer active.
# 'on' once firewalld or ufw is no longer active.
mask_firewalld
disable_ufw
$SUDO tee "$FIREWALL_APPLY" > /dev/null << 'APPLY'
#!/bin/sh
@@ -1171,7 +1203,7 @@ APT_RUNTIME_PACKAGES="nbdkit
qemu-utils gdisk ovmf qemu-efi-aarch64 less
libvirt-daemon-system libvirt-clients
pciutils
jq curl iproute2 ethtool netcat-openbsd wget unzip xz-utils file
jq curl iproute2 iputils-arping ethtool netcat-openbsd wget unzip xz-utils file
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
chrony nftables"
@@ -1181,7 +1213,7 @@ chrony nftables"
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
qemu-kvm qemu-img edk2-ovmf seabios-bin less
pciutils
jq curl iproute ethtool nmap-ncat wget unzip xz file
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
chrony nftables iptables-nft systemd-resolved"
@@ -1895,6 +1927,28 @@ SYSCTL
info "Kernel tunables installed (net.core.rmem_max/wmem_max = 16MB)"
}
# --- Keep needrestart off the Spinifex, OVS and OVN units ---
# Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charges QEMU's
# replaced libraries to the daemon and restarts it after every dpkg run, which
# never clears them and ends at the unit's start limit. Restarting the stack
# after an upgrade is the operator's call; needrestart still lists the units.
install_needrestart_conf() {
stage "installing needrestart override"
local dir="${DISTRO_ROOT:-}/etc/needrestart/conf.d"
$SUDO install -d "$dir"
$SUDO tee "$dir/90-spinifex.conf" > /dev/null << 'NEEDRESTART'
# Managed by spinifex setup.sh — edits are overwritten.
# Never restart these automatically; restart them by hand when it suits.
$nrconf{override_rc}{qr(^spinifex-)} = 0;
$nrconf{override_rc}{qr(^ovs-)} = 0;
$nrconf{override_rc}{qr(^ovsdb-)} = 0;
$nrconf{override_rc}{qr(^ovn-)} = 0;
$nrconf{override_rc}{qr(^openvswitch-)} = 0;
NEEDRESTART
$SUDO chmod 0644 "$dir/90-spinifex.conf"
info "needrestart will not restart the spinifex, OVS or OVN units"
}
# --- Install logrotate ---
install_logrotate() {
stage "installing logrotate config"
@@ -2089,6 +2143,7 @@ main() {
stage_enabled fixown && fix_file_ownership
stage_enabled systemd && install_systemd
stage_enabled sysctl && install_sysctl
stage_enabled needrestart && install_needrestart_conf
stage_enabled modules && install_kernel_modules
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
+47 -3
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# setup_host_test.sh — tests for setup.sh's DNS handover to systemd-resolved,
# its chrony drop-in and its nbdkit plugin directory.
# its chrony and needrestart drop-ins and its nbdkit plugin directory.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs. PATH
# holds stub commands and links to a few real tools only, so no host resolver,
@@ -17,10 +17,10 @@ fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- setup.sh host DNS, chrony and nbdkit"
echo "-- setup.sh host DNS, chrony, needrestart and nbdkit"
mkdir -p "$TMP/sys"
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod; do
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
done
@@ -249,6 +249,50 @@ case "$RC:$OUT" in
*) fail "no chrony: rc=$RC: $OUT" ;;
esac
# --------------------------------------------------------------------------
# needrestart override.
# --------------------------------------------------------------------------
# restarts <conf> <unit> — how needrestart would treat <unit> under <conf>:
# "no" when an override_rc pattern deselects it, "yes" otherwise.
restarts() {
perl -e 'our %nrconf; do $ARGV[0]; die $@ if $@;
for my $re (keys %{$nrconf{override_rc}}) {
if ($ARGV[1] =~ $re) { print $nrconf{override_rc}{$re} ? "yes" : "no"; exit }
}
print "yes"' "$1" "$2"
}
host
run install_needrestart_conf
conf="$TMP/root/etc/needrestart/conf.d/90-spinifex.conf"
if [ "$RC" != 0 ] || [ ! -f "$conf" ]; then
fail "needrestart: rc=$RC: $OUT"
elif ! command -v perl > /dev/null; then
fail "needrestart: perl is not installed, so the override cannot be checked"
else
wrong=""
for unit in spinifex-daemon.service spinifex-viperblock.service ovs-vswitchd.service \
ovsdb-server.service ovn-controller.service openvswitch-switch.service; do
[ "$(restarts "$conf" "$unit")" = no ] || wrong="$wrong $unit"
done
for unit in ssh.service rsyslog.service polkit.service; do
[ "$(restarts "$conf" "$unit")" = yes ] || wrong="$wrong $unit"
done
if [ -n "$wrong" ]; then
fail "needrestart override treats these wrongly:$wrong"
else
pass "needrestart keeps off the stack's units and still restarts everything else"
fi
fi
before=$(cat "$conf" 2>/dev/null)
run install_needrestart_conf
if [ "$RC" = 0 ] && [ "$(cat "$conf" 2>/dev/null)" = "$before" ]; then
pass "needrestart override is unchanged by a second install"
else
fail "needrestart override changed on re-run: rc=$RC: $OUT"
fi
# --------------------------------------------------------------------------
# nbdkit plugin directory.
# --------------------------------------------------------------------------
+39
View File
@@ -0,0 +1,39 @@
# Spinifex on Vultr
`deploy-vultr.sh` builds a one- or three-node Spinifex deployment on Vultr VX VMs or bare-metal servers in their own VPC, and tears it down again leaving nothing billing. The customer guide is [`docs/vultr-integration/README.md`](../../../docs/vultr-integration/README.md).
```bash
export VULTR_API_KEY=... # the key's IP allowlist must admit this host and every node
./deploy-vultr.sh up --channel dev # one VX VM
./deploy-vultr.sh up --channel dev --nodes 3 --plan vbm-8c-64gb-amd # three bare-metal servers
./deploy-vultr.sh up --name v1 --distro spinifex-<version>.tar.gz # a named deployment from a local tarball
./deploy-vultr.sh status
./deploy-vultr.sh credentials
./deploy-vultr.sh ssh [nodeN] [command]
./deploy-vultr.sh destroy
```
Without `--name` the deployment is called `default`, and `up` writes the AWS profile `spinifex`. A named deployment writes `spinifex-vultr-<name>` instead, so several can run side by side. Either way the cluster's CA is at `~/.spinifex/vultr-<name>-ca.pem` and the profile works straight away. `up` ends by printing every node's address and the `/32`s the API key's allowlist needs.
## What it creates
| Object | Created by | Removed by |
| --- | --- | --- |
| VPC `spinifex-<name>` (`10.10.8.0/24` unless `--vpc-subnet`) | Terraform | Terraform |
| SSH key `spinifex-<name>` | Terraform | Terraform |
| VMs or bare-metal servers `spinifex-<name>-node1` to `node3` | Terraform | Terraform |
| VM boot volumes `spinifex-<name>-nodeN-boot` (50 GB each) | Terraform, through the VM | `destroy`: Vultr keeps it after the VM is deleted |
| Reserved IPs labelled `spinifex-vultr-<name>:` | Spinifex, for each public IP and Elastic IP | Spinifex on release; `destroy` sweeps any left |
A `vbm-` plan selects bare metal. It boots from local NVMe, so it has no boot volume, and Spinifex keeps its data on the root disk. `destroy` fails, and lists what remains, if anything above is still in the account afterwards.
## Where things live
- **State** is per deployment under `~/.spinifex/vultr/<name>/`: the Terraform state, the nodes' SSH host keys, logs, and one `step.*` file per completed step. A failed `up` resumes from the step that failed.
- **The API key** is copied to `/etc/spinifex/vultr/api-key` on each node over SSH. It is never a Terraform variable, so it is in neither the state nor the instance metadata.
- **The host firewall** is armed before Spinifex first starts. The nodes have no Vultr firewall group, so it is the only one.
## Requirements
- OpenTofu 1.5 or newer, the AWS CLI v2, `python3` and an SSH key (`--ssh-key`, default `~/.ssh/id_ed25519`).
- A VX plan with at least 16 vCPU. Smaller VX plans report their vCPUs as hyperthreads and Spinifex schedules half of them.
@@ -0,0 +1,11 @@
#cloud-config
# Host preparation only. Spinifex is installed and formed over SSH by deploy.sh,
# and the API key is copied the same way so it never sits in instance metadata.
write_files:
- path: /etc/cloud/cloud.cfg.d/99-spinifex-network.cfg
content: |
network: {config: disabled}
runcmd:
- [sh, -c, "command -v ufw >/dev/null && ufw --force disable || true"]
- [sh, -c, "systemctl disable --now unattended-upgrades.service apt-daily-upgrade.timer apt-daily.timer || true"]
- [sh, -c, "touch /var/lib/cloud/spinifex-host-ready"]
+50
View File
@@ -0,0 +1,50 @@
resource "vultr_ssh_key" "deploy" {
name = "spinifex-${var.deployment_name}"
ssh_key = trimspace(file(pathexpand(var.ssh_public_key_path)))
}
# A vbm- plan is a bare-metal server; anything else is a VX VM.
locals {
bare_metal = startswith(var.plan, "vbm-")
}
# The boot volume is labelled so destroy can find it: Vultr keeps a VX boot
# volume, and bills it, after the instance that booted from it is deleted.
resource "vultr_instance" "node" {
count = local.bare_metal ? 0 : var.node_count
region = var.region
plan = var.plan
os_id = var.os_id
label = "spinifex-${var.deployment_name}-node${count.index + 1}"
hostname = "node${count.index + 1}"
ssh_key_ids = [vultr_ssh_key.deploy.id]
vpc_ids = [vultr_vpc.lan.id]
user_data = file("${path.module}/cloud-init/user-data.yaml")
tags = ["spinifex", "deployment:${var.deployment_name}"]
backups = "disabled"
block_devices {
disk_size = var.boot_disk_gb
bootable = true
label = "spinifex-${var.deployment_name}-node${count.index + 1}-boot"
}
}
# Bare metal boots from its local NVMe, so there is no boot volume to label.
resource "vultr_bare_metal_server" "node" {
count = local.bare_metal ? var.node_count : 0
region = var.region
plan = var.plan
os_id = var.os_id
label = "spinifex-${var.deployment_name}-node${count.index + 1}"
hostname = "node${count.index + 1}"
ssh_key_ids = [vultr_ssh_key.deploy.id]
vpc_id = vultr_vpc.lan.id
user_data = file("${path.module}/cloud-init/user-data.yaml")
tags = ["spinifex", "deployment:${var.deployment_name}"]
activation_email = false
timeouts {
create = "60m"
}
}
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env bash
# deploy-vultr.sh — build a one- or three-node Spinifex deployment on Vultr, or tear it down.
#
# deploy-vultr.sh up --channel dev one VX VM
# deploy-vultr.sh up --channel dev --nodes 3 --plan vbm-8c-64gb-amd three bare-metal servers
# deploy-vultr.sh status nodes, addresses and what the deployment holds
# deploy-vultr.sh credentials print the AWS access key and secret
# deploy-vultr.sh ssh [nodeN] [command] SSH to a node, node1 by default
# deploy-vultr.sh destroy
#
# VULTR_API_KEY must be set. OpenTofu, or Terraform when OpenTofu is not
# installed, creates the VPC, the nodes and any boot volumes; this script
# installs and forms Spinifex over SSH, and records each step under the state
# directory so a rerun resumes rather than re-initialising.
# Three nodes are formed by install-node.sh with cluster and Geneve traffic on
# the VPC, after each has come up as an armed single node.
#
# The API key is copied to the node over SSH, so it is in neither the Terraform
# state nor the instance metadata. Destroy also deletes what Terraform never
# created and Vultr keeps billing: the boot volume and every reserved IP the
# node allocated under the deployment's label prefix.
#
# Without --name the deployment is called "default" and its AWS profile is
# "spinifex"; a named deployment's profile is "spinifex-vultr-<name>".
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPINIFEX_SRC="$(cd "$SCRIPT_DIR/../../.." && pwd)"
VULTR_API="https://api.vultr.com/v2"
CMD="${1:-}"
[ $# -gt 0 ] && shift
NODE_ARG=""
NAME="default"
REGION="ord"
PLAN="vx1-g-16c-64g"
DISTRO=""
CHANNEL=""
SPX_REGION="us-east-1"
SPX_AZ="us-east-1a"
SSH_KEY="$HOME/.ssh/id_ed25519"
IMAGE=""
VPC_SUBNET="10.10.8.0"
STATE_ROOT="$HOME/.spinifex/vultr"
NODES=1
YES=0
usage() {
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
cat <<EOF
Options:
--name NAME deployment name (default $NAME); labels every resource
--region ID Vultr region (default $REGION)
--plan ID Vultr plan (default $PLAN); vbm-8c-64gb-amd for bare metal
--distro TARBALL install this local release tarball
--channel CHANNEL install from a release channel (latest, dev)
--spx-region NAME Spinifex region (default $SPX_REGION)
--spx-az NAME Spinifex availability zone (default $SPX_AZ)
--ssh-key PATH private key; PATH.pub is installed (default $SSH_KEY)
--image NAME also import this AMI, such as debian-13-x86_64 (default none)
--vpc-subnet ADDR the deployment VPC's /24 (default $VPC_SUBNET)
--nodes N 1 or 3 (default $NODES)
--yes destroy without asking
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="$2"; shift ;;
--region) REGION="$2"; shift ;;
--plan) PLAN="$2"; shift ;;
--distro) DISTRO="$2"; shift ;;
--channel) CHANNEL="$2"; shift ;;
--spx-region) SPX_REGION="$2"; shift ;;
--spx-az) SPX_AZ="$2"; shift ;;
--ssh-key) SSH_KEY="$2"; shift ;;
--image) IMAGE="$2"; shift ;;
--vpc-subnet) VPC_SUBNET="$2"; shift ;;
--nodes) NODES="$2"; shift ;;
--yes) YES=1 ;;
-h|--help) usage; exit 0 ;;
node[1-3])
if [ "$CMD" != ssh ] || [ -n "$NODE_ARG" ]; then usage >&2; exit 2; fi
NODE_ARG="$1" ;;
*) [ "$CMD" = ssh ] && break
echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
log() { printf '[deploy %s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
[[ "$NAME" =~ ^[a-z0-9][a-z0-9-]{0,23}$ ]] || die "--name must be 1-24 lowercase letters, digits or hyphens"
[ "$NODES" = 1 ] || [ "$NODES" = 3 ] || die "--nodes must be 1 or 3"
case "$CMD" in up|destroy|status) [ -n "${VULTR_API_KEY:-}" ] || die "VULTR_API_KEY is not set" ;; esac
if command -v tofu >/dev/null; then TF_BIN=tofu
elif command -v terraform >/dev/null; then TF_BIN=terraform
else die "neither OpenTofu (tofu) nor Terraform is installed"; fi
STATE_DIR="$STATE_ROOT/$NAME"
TF_STATE="$STATE_DIR/terraform.tfstate"
KNOWN_HOSTS="$STATE_DIR/known_hosts"
LABEL_PREFIX="spinifex-vultr-$NAME"
PROFILE="spinifex-vultr-$NAME"
[ "$NAME" = default ] && PROFILE="spinifex"
CA_FILE="$HOME/.spinifex/vultr-$NAME-ca.pem"
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
# vultr METHOD PATH [BODY] prints the response body and fails on a non-2xx.
vultr() {
local out code
out="$(curl -sS -w '\n%{http_code}' -X "$1" -H "Authorization: Bearer $VULTR_API_KEY" \
-H 'Content-Type: application/json' ${3:+-d "$3"} "$VULTR_API$2")"
code="${out##*$'\n'}"
out="${out%$'\n'*}"
case "$code" in
2??) printf '%s' "$out" ;;
*) log "Vultr $1 $2 returned $code: $out"; return 1 ;;
esac
}
tf() {
"$TF_BIN" -chdir="$SCRIPT_DIR" "$@" -state="$TF_STATE" \
-var "deployment_name=$NAME" -var "region=$REGION" -var "plan=$PLAN" \
-var "ssh_public_key_path=$SSH_KEY.pub" -var "vpc_subnet=$VPC_SUBNET" \
-var "node_count=$NODES"
}
done_step() { [ -f "$STATE_DIR/step.$1" ]; }
mark_step() { date -u +%FT%TZ > "$STATE_DIR/step.$1"; }
# node_field FIELD prints FIELD of every node, in node order.
node_field() {
"$TF_BIN" -chdir="$SCRIPT_DIR" output -state="$TF_STATE" -json nodes |
python3 -c 'import json,sys; [print(n[sys.argv[1]]) for n in json.load(sys.stdin)]' "$1"
}
# for_nodes STEP FN runs FN once per node with IP and NODE_NAME set,
# recording STEP.<node> so a rerun skips the nodes already done.
for_nodes() {
local step="$1" fn="$2" i
for i in "${!IPS[@]}"; do
IP="${IPS[$i]}" NODE_NAME="node$((i + 1))"
done_step "$step.$NODE_NAME" && continue
"$fn"
mark_step "$step.$NODE_NAME"
done
}
# vpc_addr prints the VPC NIC's name and address on the current node.
vpc_addr() { on 'ip -4 -o addr show' | awk -v p="${VPC_SUBNET%.*}." 'index($4, p) == 1 {sub(/\/.*/, "", $4); print $2, $4; exit}'; }
on() {
ssh -i "$SSH_KEY" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$KNOWN_HOSTS" "root@$IP" "$@"
}
# ── up ───────────────────────────────────────────────────────────────────────
preflight() {
[ -r "$SSH_KEY" ] && [ -r "$SSH_KEY.pub" ] || die "SSH key $SSH_KEY (and .pub) not readable"
if [ -n "$DISTRO" ]; then
[ -r "$DISTRO" ] || die "--distro $DISTRO not readable"
elif [ -z "$CHANNEL" ]; then
die "pass --distro TARBALL or --channel latest|dev"
fi
vultr GET /account >/dev/null || die "the API key was refused; check its IP allowlist"
local type=vx1
[[ "$PLAN" == vbm-* ]] && type=vbm
vultr GET "/regions/$REGION/availability?type=$type" | python3 -c '
import json,sys
plan=sys.argv[1]
if plan not in json.load(sys.stdin).get("available_plans",[]):
sys.exit("plan %s is not available in this region" % plan)' "$PLAN" || die "plan check failed"
}
provision() {
log "terraform apply ($PLAN in $REGION)"
"$TF_BIN" -chdir="$SCRIPT_DIR" init -input=false >/dev/null
tf apply -input=false -auto-approve >"$STATE_DIR/apply.log" 2>&1 || {
tail -20 "$STATE_DIR/apply.log" >&2
die "terraform apply failed; log in $STATE_DIR/apply.log"
}
}
# wait_host pins the host key only once the marker cloud-init writes exists:
# bare metal first answers SSH from Vultr's installer, with a key of its own.
wait_host() {
log "waiting for SSH and cloud-init on $IP"
local i
for i in $(seq 1 120); do
ssh -i "$SSH_KEY" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "root@$IP" \
'test -f /var/lib/cloud/spinifex-host-ready' 2>/dev/null && break
[ "$i" = 120 ] && die "host $IP was not ready after 20 minutes"
sleep 10
done
ssh-keygen -R "$IP" -f "$KNOWN_HOSTS" >/dev/null 2>&1 || true
on 'cloud-init status --wait >/dev/null; test -e /dev/kvm' || die "no /dev/kvm on $IP"
}
install_spinifex() {
log "installing Spinifex"
if [ -n "$DISTRO" ]; then
scp -q -i "$SSH_KEY" -o BatchMode=yes -o UserKnownHostsFile="$KNOWN_HOSTS" \
"$DISTRO" "$SPINIFEX_SRC/scripts/setup.sh" "root@$IP:/tmp/" || die "copy to $IP failed"
on "INSTALL_SPINIFEX_TARBALL=/tmp/$(basename "$DISTRO") bash /tmp/setup.sh >/root/setup.log 2>&1" ||
die "setup.sh failed; see /root/setup.log on $IP"
else
on "curl -sfL https://install.mulgadc.com | bash -s -- --channel $CHANNEL >/root/setup.log 2>&1" ||
die "install failed; see /root/setup.log on $IP"
fi
}
# form brings a node up as its own one-node cluster, the state an ISO install
# leaves and the one install-node.sh forms a larger cluster from.
form() {
local vpc_if vpc_ip
read -r vpc_if vpc_ip <<<"$(vpc_addr)"
[ -n "$vpc_ip" ] || die "no VPC address on $IP"
log "initialising $NODE_NAME on $IP (VPC $vpc_if $vpc_ip, region $SPX_REGION)"
on "/usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink --encap-ip=$vpc_ip >/root/setup-ovn.log 2>&1" ||
die "setup-ovn.sh failed; see /root/setup-ovn.log on $IP"
on "spx admin init --node $NODE_NAME --nodes 1 --region $SPX_REGION --az $SPX_AZ --ipsec=false --external-mode=nat >/root/init.log 2>&1" ||
die "spx admin init failed; see /root/init.log on $IP"
}
install_key() {
log "installing the API key on $NODE_NAME"
printf '%s' "$VULTR_API_KEY" | on 'install -d -m 0700 -o spinifex-daemon -g spinifex /etc/spinifex/vultr &&
umask 077 && cat > /etc/spinifex/vultr/api-key &&
chown spinifex-daemon:spinifex /etc/spinifex/vultr/api-key && chmod 0600 /etc/spinifex/vultr/api-key'
on 'curl -sf -o /dev/null -H "Authorization: Bearer $(cat /etc/spinifex/vultr/api-key)" https://api.vultr.com/v2/reserved-ips' ||
die "the node's API calls are refused; add $IP to the key's allowlist"
}
# configure_pool adds the pool and every node's instance ID: the config is
# validated against all [nodes.*] tables, and a takeover attaches to the
# taking node's own ID.
configure_pool() {
log "adding the vultr pool on $NODE_NAME (label prefix $LABEL_PREFIX)"
local ids="" i
for i in "${!IDS[@]}"; do ids+="node$((i + 1))=${IDS[$i]},"; done
on "python3 - '$REGION' '$LABEL_PREFIX' '${ids%,}'" <<'PY'
import re, sys
region, prefix, ids = sys.argv[1:]
p = "/etc/spinifex/spinifex.toml"
s = open(p).read()
if 'source = "vultr"' not in s:
pool = ('[[network.external_pools]]\nname = "vultr"\nsource = "vultr"\n'
f'vultr_region = "{region}"\nlabel_prefix = "{prefix}"\n\n[bootstrap]')
assert s.count("\n[bootstrap]") == 1, "expected one [bootstrap] table"
s = s.replace("\n[bootstrap]", "\n" + pool, 1)
for pair in ids.split(","):
node, host_id = pair.split("=")
head = "[nodes.%s]\n" % node
m = re.search(r"^" + re.escape(head) + r"(.*?)(?=^\[|\Z)", s, flags=re.M | re.S)
assert m, "could not find [nodes.%s]" % node
if "provider_host_id" not in m.group(1):
s = s[:m.start()] + head + 'provider_host_id = "%s"\n' % host_id + s[m.start() + len(head):]
open(p, "w").write(s)
PY
}
start_spinifex() {
log "arming the firewall and starting $NODE_NAME"
on 'env SETUP_STAGES=firewall /usr/local/share/spinifex/setup.sh --firewall on >/root/firewall.log 2>&1' ||
die "arming the firewall failed; see /root/firewall.log on $IP"
on 'systemctl start spinifex.target'
wait_api
}
wait_api() {
local i
for i in $(seq 1 30); do
on 'curl -sk -o /dev/null -w "%{http_code}" https://127.0.0.1:9999/' 2>/dev/null | grep -q '^[34]' && break
[ "$i" = 30 ] && die "the API did not come up on $IP"
sleep 10
done
}
# form_cluster hands the armed single nodes to install-node.sh. It dials the
# public addresses over SSH and is told to put the cluster and Geneve planes on
# the VPC NIC, which must have the same name on every node.
form_cluster() {
local hosts_file="$STATE_DIR/hosts" vpc_if="" if_here ip_here pinned
printf '%s\n' "${IPS[@]}" > "$hosts_file"
for IP in "${IPS[@]}"; do
read -r if_here ip_here <<<"$(vpc_addr)"
[ -n "$ip_here" ] || die "no VPC address on $IP"
[ -z "$vpc_if" ] || [ "$vpc_if" = "$if_here" ] || die "VPC NIC is $vpc_if on one node and $if_here on $IP"
vpc_if="$if_here"
# install-node.sh reads ~/.ssh/known_hosts and Vultr reuses addresses, so
# replace any older key for this one with the key this deployment pinned.
# -F, not grep: ssh_config usually hashes the entries.
pinned="$(ssh-keygen -F "$IP" -f "$KNOWN_HOSTS" | grep -v '^#')" || die "no pinned host key for $IP in $KNOWN_HOSTS"
ssh-keygen -R "$IP" >/dev/null 2>&1 || true
printf '%s\n' "$pinned" >> "$HOME/.ssh/known_hosts"
done
log "forming ${#IPS[@]} nodes with install-node.sh (cluster plane $vpc_if)"
"$SPINIFEX_SRC/scripts/install-node.sh" --hosts-file "$hosts_file" --user root --identity "$SSH_KEY" \
--external-mode nat --ipsec off --region "$SPX_REGION" --az "$SPX_AZ" \
--lan-bridge "$vpc_if" --vpc-bridge "$vpc_if" --yes >"$STATE_DIR/form.log" 2>&1 ||
die "formation failed; see $STATE_DIR/form.log"
}
restart_spinifex() {
log "restarting $NODE_NAME with the pool"
on 'systemctl restart spinifex.target'
wait_api
local i
for i in $(seq 1 30); do
on 'journalctl -u spinifex-daemon --since "-10min" --no-pager | grep -q "floating using Vultr reserved IPs"' && return 0
sleep 10
done
die "$NODE_NAME never logged its Vultr allocator; see journalctl -u spinifex-daemon on $IP"
}
import_image() {
log "importing $IMAGE"
on "spx admin images import --name $IMAGE --config /etc/spinifex/spinifex.toml >/root/image-import.log 2>&1" ||
die "image import failed; see /root/image-import.log on $IP"
}
write_profile() {
log "writing the CA to $CA_FILE and AWS profile $PROFILE"
mkdir -p "$(dirname "$CA_FILE")"
on 'cat /etc/spinifex/ca.pem' > "$CA_FILE"
local creds key secret
creds="$(on 'awk "/^\[spinifex\]/{f=1;next} /^\[/{f=0} f" ~/.aws/credentials')"
key="$(awk -F' *= *' '/aws_access_key_id/{print $2}' <<<"$creds")"
secret="$(awk -F' *= *' '/aws_secret_access_key/{print $2}' <<<"$creds")"
[ -n "$key" ] && [ -n "$secret" ] || die "no [spinifex] credentials on $IP"
aws configure set --profile "$PROFILE" aws_access_key_id "$key"
aws configure set --profile "$PROFILE" aws_secret_access_key "$secret"
aws configure set --profile "$PROFILE" region "$SPX_REGION"
aws configure set --profile "$PROFILE" endpoint_url "https://$IP:9999"
aws configure set --profile "$PROFILE" ca_bundle "$CA_FILE"
}
cmd_up() {
preflight
done_step terraform || { provision; mark_step terraform; }
mapfile -t IPS < <(node_field public_ip)
mapfile -t IDS < <(node_field provider_host_id)
[ "${#IPS[@]}" = "$NODES" ] || die "the state holds ${#IPS[@]} node(s), not $NODES; destroy it or pass --nodes ${#IPS[@]}"
for_nodes host wait_host
for_nodes install install_spinifex
for_nodes form form
for_nodes start start_spinifex
if [ "$NODES" -gt 1 ]; then
done_step cluster || { form_cluster; mark_step cluster; }
fi
for_nodes key install_key
for_nodes pool configure_pool
for_nodes restart restart_spinifex
IP="${IPS[0]}"
if [ -n "$IMAGE" ]; then done_step image || { import_image; mark_step image; }; fi
write_profile
log "ready: https://$IP:9999 (profile $PROFILE)"
printf 'endpoint=https://%s:9999\nprofile=%s\nca=%s\nconsole=https://%s:3000/\n' "$IP" "$PROFILE" "$CA_FILE" "$IP"
local i
for i in "${!IPS[@]}"; do printf 'node%d=%s\n' "$((i + 1))" "${IPS[$i]}"; done
printf 'api_key_allowlist=%s\n' "$(printf '%s/32,' "${IPS[@]}" | sed 's/,$//')"
}
# ── destroy ──────────────────────────────────────────────────────────────────
# leftovers prints every billable object this deployment can own, one per line.
leftovers() {
{
vultr GET "/instances?per_page=500" | python3 -c '
import json,sys
for i in json.load(sys.stdin)["instances"]:
if "deployment:"+sys.argv[1] in i.get("tags",[]): print("instance", i["id"], i["label"])' "$NAME"
vultr GET "/bare-metals?per_page=500" | python3 -c '
import json,sys
for i in json.load(sys.stdin)["bare_metals"]:
if "deployment:"+sys.argv[1] in i.get("tags",[]): print("bare-metal", i["id"], i["label"])' "$NAME"
vultr GET "/blocks?per_page=500" | python3 -c '
import json,sys
for b in json.load(sys.stdin)["blocks"]:
if b["label"].startswith("spinifex-"+sys.argv[1]+"-"): print("block", b["id"], b["label"])' "$NAME"
vultr GET "/reserved-ips?per_page=500" | python3 -c '
import json,sys
for r in json.load(sys.stdin)["reserved_ips"]:
if r["label"].startswith(sys.argv[1]+":"): print("reserved-ip", r["id"], r["subnet"])' "$LABEL_PREFIX"
vultr GET "/vpcs?per_page=500" | python3 -c '
import json,sys
for v in json.load(sys.stdin)["vpcs"]:
if v["description"] == "spinifex-"+sys.argv[1]: print("vpc", v["id"], v["description"])' "$NAME"
}
}
cmd_destroy() {
if [ "$YES" != 1 ]; then
log "about to destroy deployment '$NAME':"
leftovers >&2
read -r -p "Type the deployment name to confirm: " answer
[ "$answer" = "$NAME" ] || die "not confirmed"
fi
if [ -f "$TF_STATE" ]; then
log "terraform destroy"
"$TF_BIN" -chdir="$SCRIPT_DIR" init -input=false >/dev/null
tf destroy -input=false -auto-approve >"$STATE_DIR/destroy.log" 2>&1 || {
tail -20 "$STATE_DIR/destroy.log" >&2
die "terraform destroy failed; log in $STATE_DIR/destroy.log"
}
fi
# Reserved IPs the node created, and boot volumes the instance left behind.
local kind id label i
for i in 1 2 3 4 5 6; do
local left
left="$(leftovers)"
[ -z "$left" ] && break
while read -r kind id label; do
case "$kind" in
reserved-ip)
log "deleting reserved IP $label"
vultr POST "/reserved-ips/$id/detach" >/dev/null || true
vultr DELETE "/reserved-ips/$id" >/dev/null || true ;;
block)
log "deleting block volume $label"
vultr DELETE "/blocks/$id" >/dev/null || true ;;
instance|bare-metal|vpc)
log "deleting $kind $label, still listed after terraform destroy"
vultr DELETE "/${kind}s/$id" >/dev/null || true ;;
esac
done <<<"$left"
sleep 10
done
local left
left="$(leftovers)"
if [ -n "$left" ]; then
log "these still exist and are billing:"
printf '%s\n' "$left" >&2
exit 1
fi
rm -f "$STATE_DIR"/step.*
log "deployment '$NAME' destroyed; nothing of it is left in the account"
}
cmd_status() {
local nodes
if [ -f "$TF_STATE" ] && nodes="$("$TF_BIN" -chdir="$SCRIPT_DIR" output -state="$TF_STATE" -json nodes 2>/dev/null)"; then
python3 -c 'import json,sys; [print(n["name"], n["public_ip"]) for n in json.load(sys.stdin)]' <<<"$nodes"
fi
leftovers
find "$STATE_DIR" -maxdepth 1 -name 'step.*' | sort | sed 's/.*step\./done: /'
}
# node_ip NODE prints the public address of nodeN from the Terraform state.
node_ip() {
[ -f "$TF_STATE" ] || die "no deployment '$NAME'; run up first"
local ip
ip="$(node_field public_ip | sed -n "${1#node}p")"
[ -n "$ip" ] || die "deployment '$NAME' has no $1"
printf '%s' "$ip"
}
cmd_credentials() {
IP="$(node_ip node1)"
on 'awk "/^\[spinifex\]/{f=1} /^\[/&&!/^\[spinifex\]/{f=0} f" ~/.aws/credentials'
}
cmd_ssh() {
IP="$(node_ip "${NODE_ARG:-node1}")"
ssh -i "$SSH_KEY" -o UserKnownHostsFile="$KNOWN_HOSTS" "root@$IP" "$@"
}
case "$CMD" in
up) cmd_up ;;
destroy) cmd_destroy ;;
status) cmd_status ;;
credentials) cmd_credentials ;;
ssh) cmd_ssh "$@" ;;
*) usage >&2; exit 2 ;;
esac
+8
View File
@@ -0,0 +1,8 @@
# One VPC per deployment, single node included, so the encap address and the
# east-west path are the same shape at one node as at three.
resource "vultr_vpc" "lan" {
region = var.region
description = "spinifex-${var.deployment_name}"
v4_subnet = var.vpc_subnet
v4_subnet_mask = var.vpc_subnet_mask
}
+27
View File
@@ -0,0 +1,27 @@
output "deployment_name" {
value = var.deployment_name
}
output "region" {
value = var.region
}
output "vpc_id" {
value = vultr_vpc.lan.id
}
# provider_host_id is what a reserved IP attaches to. The VPC address is read
# on the host, since the provider does not report it.
output "nodes" {
value = [for n in concat(vultr_instance.node, vultr_bare_metal_server.node) : {
name = n.hostname
provider_host_id = n.id
public_ip = n.main_ip
}]
}
# The addresses the Vultr API key's access control must allow once the
# deployment is up, besides the machine that runs deploy-vultr.sh.
output "api_key_allowlist" {
value = [for n in concat(vultr_instance.node, vultr_bare_metal_server.node) : "${n.main_ip}/32"]
}
+6
View File
@@ -0,0 +1,6 @@
# The key comes from VULTR_API_KEY in the environment, never a variable, so it
# is not written to the state or to a tfvars file.
provider "vultr" {
rate_limit = 700
retry_limit = 3
}
@@ -0,0 +1,5 @@
deployment_name = "v1"
region = "ord"
plan = "vx1-g-16c-64g"
node_count = 1
ssh_public_key_path = "~/.ssh/id_ed25519.pub"
+56
View File
@@ -0,0 +1,56 @@
variable "deployment_name" {
type = string
description = "Names every resource and the reserved-IP label prefix, so two deployments in one account never collide."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{0,23}$", var.deployment_name))
error_message = "deployment_name must be 1-24 lowercase letters, digits or hyphens."
}
}
variable "region" {
type = string
default = "ord"
description = "Vultr region ID. Independent of the Spinifex region the cluster reports."
}
variable "plan" {
type = string
default = "vx1-g-16c-64g"
description = "VX plan, 16 vCPU at least, or a vbm- bare-metal plan such as vbm-8c-64gb-amd."
}
variable "os_id" {
type = number
default = 2284
description = "Vultr OS ID. 2284 is Ubuntu 24.04 LTS x64, the image Phase 1 was proved on."
}
variable "boot_disk_gb" {
type = number
default = 50
description = "Size of each VM's bootable block volume. VX plans have no local root disk; bare metal ignores it."
}
variable "node_count" {
type = number
default = 1
validation {
condition = contains([1, 3], var.node_count)
error_message = "node_count must be 1 or 3."
}
}
variable "vpc_subnet" {
type = string
default = "10.10.8.0"
}
variable "vpc_subnet_mask" {
type = number
default = 24
}
variable "ssh_public_key_path" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
+4
View File
@@ -0,0 +1,4 @@
terraform {
required_version = ">= 1.5.0"
required_providers { vultr = { source = "vultr/vultr", version = "~> 2.33" } }
}
+5 -2
View File
@@ -29,8 +29,9 @@
#
# /usr/local/bin/spx, the nbdkit plugin, /usr/local/{share,lib}/spinifex,
# /usr/share/spinifex, every spinifex systemd unit and slice, the eight
# spinifex-* service users, the sudoers grant, the sysctl/logrotate/chrony/udev
# drop-ins, the host firewall policy and its loaded nft table, the OVS bridges
# spinifex-* service users, the sudoers grant, the sysctl, logrotate, chrony,
# needrestart and udev drop-ins, the host firewall policy and its loaded nft
# table, the OVS bridges
# and OVN databases, the Spinifex resolved drop-in, and the Spinifex CA from the
# host trust store. Where setup.sh turned on systemd-resolved, it restores
# /etc/resolv.conf and turns resolved off.
@@ -457,6 +458,7 @@ rm_path /etc/sysctl.d/*spinifex* \
/etc/sudoers.d/spinifex-network \
/etc/chrony/conf.d/spinifex.conf \
/etc/chrony.d/spinifex.conf \
/etc/needrestart/conf.d/90-spinifex.conf \
/etc/logrotate.d/spinifex \
/etc/tmpfiles.d/spinifex.conf \
/etc/udev/rules.d/*spinifex* \
@@ -615,6 +617,7 @@ if ! $DRY_RUN; then
/usr/share/spinifex "$ETC_DIR" "$LOG_DIR" \
/etc/sudoers.d/spinifex-network /etc/logrotate.d/spinifex /etc/tmpfiles.d/spinifex.conf \
/etc/chrony/conf.d/spinifex.conf /etc/chrony.d/spinifex.conf "$RESOLV_CONF_SAVED" \
/etc/needrestart/conf.d/90-spinifex.conf \
"$RESOLVED_DROPIN"; do
exists "$p" && LEFT+=("$p")
done
+63 -2
View File
@@ -110,7 +110,7 @@ type AWSConfig struct {
// ExternalPool defines a range of routable IPs that Spinifex manages for public subnets.
type ExternalPool struct {
Name string `mapstructure:"name"` // Pool identifier (e.g., "wan", "dc1-primary")
Source string `mapstructure:"source"` // IP source: "static" (default), "dhcp", "oci" or "exoscale"
Source string `mapstructure:"source"` // IP source: "static" (default), "dhcp", "oci", "exoscale" or "vultr"
BindBridge string `mapstructure:"bind_bridge"` // Linux bridge for DHCP DORA (source=dhcp only)
DHCPMAC string `mapstructure:"dhcp_mac"` // DHCP client MAC strategy: "derived" (default) or "interface" (source=dhcp only)
RangeStart string `mapstructure:"range_start"` // First IP in range (static source only)
@@ -147,6 +147,19 @@ type ExternalPool struct {
ExoscaleConfigFile string `mapstructure:"exoscale_config_file"` // exo CLI config holding the API key (optional; see exoscale.DefaultConfigFile)
ExoscaleAccount string `mapstructure:"exoscale_account"` // Account name within that file (optional; the file's default)
ExoscaleBinary string `mapstructure:"exoscale_binary"` // Absolute path to exo (optional; see exoscale.DefaultBinary)
// Vultr names the region the reserved IPs are created in and the API key
// file (source=vultr only). Each node's instance ID is provider_host_id
// on the node, since the address moves between nodes.
VultrRegion string `mapstructure:"vultr_region"` // Vultr region ID, e.g. "ord"; never derived from the Spinifex region
VultrKeyFile string `mapstructure:"vultr_key_file"` // API key file (optional; see vultr.DefaultKeyFile)
LabelPrefix string `mapstructure:"label_prefix"` // Marks this deployment's addresses, e.g. "spinifex-prod"
}
// hasVultrKeys reports whether any vultr_* key, or the label prefix only a
// floating pool reads, is set.
func (p ExternalPool) hasVultrKeys() bool {
return p.VultrRegion != "" || p.VultrKeyFile != "" || p.LabelPrefix != ""
}
// hasExoscaleKeys reports whether any exoscale_* key is set.
@@ -235,6 +248,11 @@ type Config struct {
DataDir string `json:"DataDir" mapstructure:"data_dir"`
Services []string `json:"Services" mapstructure:"services"` // Which services this node runs locally
// ProviderHostID is this node's instance ID at its cloud provider, which a
// floating public address is attached to. Required on every node when a
// source="vultr" pool exists.
ProviderHostID string `json:"ProviderHostID,omitempty" mapstructure:"provider_host_id"`
Daemon DaemonConfig `json:"Daemon" mapstructure:"daemon"`
NATS NATSConfig `json:"NATS" mapstructure:"nats"`
Predastore PredastoreConfig `json:"Predastore" mapstructure:"predastore"`
@@ -720,6 +738,41 @@ func validateNorthstarCluster(cc *ClusterConfig) error {
}
// validateClusterConfig rejects legacy DHCP config keys and validates external pool ranges.
// validateVultrPool checks a source="vultr" pool. Vultr picks each address
// and routes it to the holder's MAC, so the range, gateway and DHCP keys mean
// nothing, and every node must name the instance the address attaches to.
func validateVultrPool(cc *ClusterConfig, p ExternalPool) error {
if p.VultrRegion == "" || p.LabelPrefix == "" {
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"vultr\" requires vultr_region and label_prefix", p.Name)
}
if strings.Contains(p.LabelPrefix, ":") {
return fmt.Errorf("config: [[network.external_pools]] %q: label_prefix %q must not contain ':', which separates it from the owner in each label", p.Name, p.LabelPrefix)
}
if p.VultrKeyFile != "" && !filepath.IsAbs(p.VultrKeyFile) {
return fmt.Errorf("config: [[network.external_pools]] %q: vultr_key_file must be an absolute path, got %q", p.Name, p.VultrKeyFile)
}
if p.BindBridge != "" || p.DHCPMAC != "" {
return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name)
}
if p.RangeStart != "" || p.RangeEnd != "" || p.Gateway != "" || p.GatewayIP != "" {
return fmt.Errorf("config: [[network.external_pools]] %q: range_start/range_end/gateway/gateway_ip not allowed with source=\"vultr\" (Vultr picks the address)", p.Name)
}
if p.GwLrpRangeStart != "" || p.GwLrpRangeEnd != "" {
return fmt.Errorf("config: [[network.external_pools]] %q: gw_lrp_range_start/gw_lrp_range_end not allowed with source=\"vultr\" (a reserved IP per VPC gateway would exhaust the account quota)", p.Name)
}
// Vultr delivers a reserved IP to the holder's own MAC, so pool mode's
// per-VPC gateway MACs never reach the wire.
if cc.Network.ExternalMode != "nat" {
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"vultr\" requires [network] external_mode = \"nat\" (routed); Vultr routes a reserved IP to the instance's own MAC, so pool mode's per-VPC gateway MACs are dropped", p.Name)
}
for name, n := range cc.Nodes {
if n.ProviderHostID == "" {
return fmt.Errorf("config: [nodes.%s] provider_host_id is required with a source=\"vultr\" pool (%q): it is the Vultr instance ID the node's reserved IPs attach to", name, p.Name)
}
}
return nil
}
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
@@ -775,6 +828,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
if p.Source != "exoscale" && p.hasExoscaleKeys() {
return fmt.Errorf("config: [[network.external_pools]] %q: exoscale_* keys are only valid with source=\"exoscale\"", p.Name)
}
if p.Source != "vultr" && p.hasVultrKeys() {
return fmt.Errorf("config: [[network.external_pools]] %q: vultr_* and label_prefix keys are only valid with source=\"vultr\"", p.Name)
}
switch p.DHCPMAC {
case "", "derived", "interface":
default:
@@ -866,8 +922,13 @@ func validateClusterConfig(cc *ClusterConfig) error {
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"exoscale\" requires [network] external_mode = \"nat\" (routed); Exoscale instances accept only their own MAC, so pool mode's per-VPC gateway MACs are dropped by the provider", p.Name)
}
continue
case "vultr":
if err := validateVultrPool(cc, p); err != nil {
return err
}
continue
default:
return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\", \"dhcp\", \"oci\" or \"exoscale\"", p.Name, p.Source)
return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\", \"dhcp\", \"oci\", \"exoscale\" or \"vultr\"", p.Name, p.Source)
}
if p.RangeStart == "" || p.RangeEnd == "" {
continue
+63
View File
@@ -1448,6 +1448,69 @@ exoscale_instance_id = "0f1e2d3c-0000-4000-8000-000000000001"
}
}
// loadVultrPool loads a two-node cluster with one pool; nodes is the per-node
// provider_host_id, empty to omit the key.
func loadVultrPool(t *testing.T, externalMode, poolBody string, nodes [2]string) (*ClusterConfig, error) {
t.Helper()
resetViper(t)
body := "node = \"n1\"\n\n[network]\nexternal_mode = \"" + externalMode + "\"\n\n" +
"[[network.external_pools]]\nname = \"public\"\n" + poolBody + "\n"
for i, id := range nodes {
body += fmt.Sprintf("\n[nodes.n%d]\nregion = \"us-east-1\"\n", i+1)
if id != "" {
body += fmt.Sprintf("provider_host_id = %q\n", id)
}
}
path := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
return LoadConfig(path)
}
const vultrPoolBase = `
source = "vultr"
vultr_region = "ord"
label_prefix = "spinifex-v1"
`
func TestLoadConfig_NetworkPoolVultrSourceAccepted(t *testing.T) {
cfg, err := loadVultrPool(t, "nat", vultrPoolBase+"vultr_key_file = \"/etc/spinifex/vultr/api-key\"\n",
[2]string{"e7d5eb7e-ca76-443c-8a59-fc450b1ed475", "0f1e2d3c-0000-4000-8000-000000000002"})
require.NoError(t, err)
p := cfg.Network.ExternalPools[0]
assert.Equal(t, "vultr", p.Source)
assert.Equal(t, "ord", p.VultrRegion)
assert.Equal(t, "spinifex-v1", p.LabelPrefix)
assert.Equal(t, "/etc/spinifex/vultr/api-key", p.VultrKeyFile)
assert.Equal(t, "e7d5eb7e-ca76-443c-8a59-fc450b1ed475", cfg.Nodes["n1"].ProviderHostID)
}
func TestLoadConfig_NetworkPoolVultrRejections(t *testing.T) {
both := [2]string{"a", "b"}
cases := []struct {
name, mode, body string
nodes [2]string
want string
}{
{"pool mode", "pool", vultrPoolBase, both, `requires [network] external_mode = "nat"`},
{"no region", "nat", "source = \"vultr\"\nlabel_prefix = \"p\"\n", both, "requires vultr_region and label_prefix"},
{"no prefix", "nat", "source = \"vultr\"\nvultr_region = \"ord\"\n", both, "requires vultr_region and label_prefix"},
{"colon in prefix", "nat", "source = \"vultr\"\nvultr_region = \"ord\"\nlabel_prefix = \"a:b\"\n", both, "must not contain ':'"},
{"relative key file", "nat", vultrPoolBase + "vultr_key_file = \"api-key\"\n", both, "absolute path"},
{"range", "nat", vultrPoolBase + "range_start = \"1.2.3.4\"\nrange_end = \"1.2.3.4\"\n", both, "Vultr picks the address"},
{"gw lrp range", "nat", vultrPoolBase + "gw_lrp_range_start = \"1.2.3.4\"\ngw_lrp_range_end = \"1.2.3.5\"\n", both, "account quota"},
{"a node without its host id", "nat", vultrPoolBase, [2]string{"a", ""}, "[nodes.n2] provider_host_id is required"},
{"vultr keys on static", "nat", "vultr_region = \"ord\"\n", both, `only valid with source="vultr"`},
{"exoscale keys", "nat", vultrPoolBase + "exoscale_zone = \"de-fra-1\"\n", both, `exoscale_* keys are only valid`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := loadVultrPool(t, tc.mode, tc.body, tc.nodes)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
})
}
}
func TestLoadConfig_NorthstarNameservers(t *testing.T) {
load := func(t *testing.T, block string) (*ClusterConfig, error) {
t.Helper()
@@ -219,3 +219,71 @@ func TestEIP_DisassociateAfterAssociate(t *testing.T) {
assert.Nil(t, addr.AssociationId)
assert.Equal(t, publicIP, *addr.PublicIp)
}
// autoAssignPublicIP gives eni an auto-assigned address the way launch does:
// allocated from the pool under its ENI, then stamped on the record.
func autoAssignPublicIP(t *testing.T, ipam *ec2vpc.ExternalIPAM, vpcSvc *ec2vpc.VPCServiceImpl, eni *ec2.NetworkInterface) string {
t.Helper()
ip, err := ipam.AllocateFromPool(t.Context(), testPool().Name, ec2vpc.PurposeENIPublic, "", *eni.NetworkInterfaceId, "")
require.NoError(t, err)
require.NoError(t, vpcSvc.UpdateENIPublicIP(testAccountID, *eni.NetworkInterfaceId, ip, testPool().Name))
return ip
}
func poolHolds(t *testing.T, ipam *ec2vpc.ExternalIPAM, ip string) bool {
t.Helper()
rec, err := ipam.GetPoolRecord(testPool().Name)
require.NoError(t, err)
_, ok := rec.Allocated[ip]
return ok
}
// AWS releases an instance's auto-assigned public IP when an Elastic IP is
// associated with it. Keeping it leaves an address nothing refers to, which a
// cloud-backed pool keeps billing for.
func TestEIP_AssociateReleasesAutoAssignedPublicIP(t *testing.T) {
svc, ipam, vpcSvc := setupTestEIP(t)
eni := createAssocENI(t, vpcSvc)
autoIP := autoAssignPublicIP(t, ipam, vpcSvc, eni)
allocID, eipIP := allocateEIP(t, svc)
sub, err := svc.natsConn.SubscribeSync("vpc.delete-nat")
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
_, err = svc.AssociateAddress(context.Background(), &ec2.AssociateAddressInput{
AllocationId: aws.String(allocID),
NetworkInterfaceId: eni.NetworkInterfaceId,
}, testAccountID)
require.NoError(t, err)
assert.False(t, poolHolds(t, ipam, autoIP), "the displaced auto-assigned address must go back to the pool")
assert.True(t, poolHolds(t, ipam, eipIP))
msg, err := sub.NextMsg(2 * time.Second)
require.NoError(t, err)
var got networkv1.NATEvent
require.NoError(t, json.Unmarshal(msg.Data, &got))
assert.Equal(t, autoIP, got.ExternalIP, "the displaced address's NAT rule must be withdrawn")
assert.Equal(t, *eni.PrivateIpAddress, got.LogicalIP)
}
// Associating a second Elastic IP to an interface that already holds one must
// not free the first: it is the customer's allocation, not an auto address.
func TestEIP_AssociateKeepsAnotherElasticIP(t *testing.T) {
svc, ipam, vpcSvc := setupTestEIP(t)
eni := createAssocENI(t, vpcSvc)
firstID, firstIP := allocateEIP(t, svc)
secondID, _ := allocateEIP(t, svc)
for _, id := range []string{firstID, secondID} {
_, err := svc.AssociateAddress(context.Background(), &ec2.AssociateAddressInput{
AllocationId: aws.String(id),
NetworkInterfaceId: eni.NetworkInterfaceId,
}, testAccountID)
require.NoError(t, err)
}
assert.True(t, poolHolds(t, ipam, firstIP), "an Elastic IP is freed only by ReleaseAddress")
assert.Equal(t, firstIP, *describeEIP(t, svc, firstID).PublicIp)
}
+52
View File
@@ -269,6 +269,7 @@ func (s *EIPServiceImpl) AssociateAddress(ctx context.Context, input *ec2.Associ
}
s.bindPoolOwner(ctx, record.PoolName, record.PublicIp, eniID)
s.releaseReplacedPublicIP(ctx, accountID, eniID, record.PublicIp)
s.stampENIPublicIP(ctx, accountID, eniID, record.PublicIp, record.PoolName)
s.announcePublicIP(ctx, instanceID, eniID, record.PublicIp, record.PoolName)
@@ -333,6 +334,55 @@ func (s *EIPServiceImpl) DisassociateAddress(ctx context.Context, input *ec2.Dis
return &ec2.DisassociateAddressOutput{}, nil
}
// eniRecordReader is the ENI read releaseReplacedPublicIP needs: the described
// interface carries no pool name, and a release must name the pool.
type eniRecordReader interface {
GetENIRecord(accountID, eniID string) (ec2vpc.ENIRecord, error)
}
// releaseReplacedPublicIP returns the auto-assigned address an Elastic IP
// displaces to its pool, as AWS releases it on associate. Nothing refers to it
// once the ENI is restamped, so it stayed allocated, and billed by a cloud pool.
//
// An address another associated EIP holds is the customer's and is left alone.
// Logged rather than returned, like bindPoolOwner: the association has landed.
func (s *EIPServiceImpl) releaseReplacedPublicIP(ctx context.Context, accountID, eniID, eipPublicIP string) {
reader, ok := s.vpcService.(eniRecordReader)
if !ok || eniID == "" {
return
}
eni, err := reader.GetENIRecord(accountID, eniID)
if err != nil {
slog.WarnContext(ctx, "could not read the ENI an Elastic IP is replacing the public IP of",
"eniId", eniID, "err", err)
return
}
ip, pool := eni.PublicIpAddress, eni.PublicIpPool
if ip == "" || pool == "" || ip == eipPublicIP {
return
}
matches, err := s.findByENI(ctx, accountID, eniID)
if err != nil {
slog.WarnContext(ctx, "could not check the displaced public IP against Elastic IPs; leaving it allocated",
"eniId", eniID, "publicIp", ip, "err", err)
return
}
for _, m := range matches {
if m.record.PublicIp == ip {
return
}
}
s.publishNATEvent(networkv1.NATDeleteSubject, eni.VpcId, ip, eni.PrivateIpAddress, eniID, eni.MacAddress)
if err := s.externalIPAM.ReleaseIP(ctx, pool, ip, eniID); err != nil {
slog.WarnContext(ctx, "failed to release the public IP an Elastic IP displaced",
"eniId", eniID, "publicIp", ip, "pool", pool, "err", err)
return
}
slog.InfoContext(ctx, "released the auto-assigned public IP an Elastic IP displaced",
"eniId", eniID, "publicIp", ip, "pool", pool)
}
// bindPoolOwner tells the pool which ENI now holds publicIP, empty for none.
// Most pools have nothing to do with it; a pool whose addresses are delivered
// to one node needs it, because an EIP is allocated with no ENI and this is the
@@ -954,3 +1004,5 @@ func (s *EIPServiceImpl) RemoveRecordTags(input *ec2.DeleteTagsInput, accountID
func(r *EIPRecord) *map[string]string { return &r.Tags },
tagmirror.RemoveTagsMut(input))
}
var _ eniRecordReader = (*ec2vpc.VPCServiceImpl)(nil)
+33
View File
@@ -0,0 +1,33 @@
package external
import "context"
// ClaimResult reports what an affinity pass moved, so a caller can log it and a
// test can assert on it without reading a fake's internals.
type ClaimResult struct {
// Claimed is the public addresses this node took over.
Claimed []string
// Local is how many bindings this node already owned, moved or not.
Local int
// Stalled is every address whose guest runs here and which the provider is
// not delivering here, after the pass has tried to move it and re-read the
// answer. Empty is the healthy result.
Stalled []StalledAddress
}
// StalledAddress is one address a pass could not put where its guest is.
//
// It carries the ENI rather than the instance because that is what this layer
// knows: a caller that wants to name the guest has the port-to-instance map.
type StalledAddress struct {
PublicIP string
ENIID string
Reason string
}
// AddressClaimer is a cloud pool whose addresses must be delivered by the
// provider to the node running their owner. Every such pool is driven by the
// same pass, so a guest gets one reachability verdict across all of them.
type AddressClaimer interface {
ClaimLocalAddresses(ctx context.Context) (ClaimResult, error)
}
+291
View File
@@ -0,0 +1,291 @@
package floating
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"strings"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// purposeEIP duplicates domains/ec2/vpc.PurposeEIP so the allocator can tell
// an Elastic IP from an auto-assigned address without importing handlers.
const purposeEIP = "eip"
// Binding is one allocated address and the AWS identity that owns it, keyed in
// the record by the address.
type Binding struct {
AddrID string `json:"addr_id"`
Purpose string `json:"purpose,omitempty"`
AllocationID string `json:"allocation_id,omitempty"`
ENIID string `json:"eni_id,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
// GatewayVPCID names the VPC whose NAT gateway answers on the address. Such
// an address has no ENI, so this is what says which node must hold it.
GatewayVPCID string `json:"gateway_vpc_id,omitempty"`
// Holder is the provider host this node last confirmed the address on. It
// is observed state, written after the provider agreed, never identity.
Holder string `json:"holder,omitempty"`
}
// owned reports whether anything should be answering on the address.
func (b Binding) owned() bool { return b.ENIID != "" || b.GatewayVPCID != "" }
// Record is the per-pool binding set.
type Record struct {
Bindings map[string]Binding `json:"bindings"`
}
// Store persists bindings. JetStream KV in store.go; an interface so the
// allocator's tests need no NATS.
type Store interface {
Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error
Get(ctx context.Context, poolName string) (Record, error)
}
// Config wires one pool to this node.
type Config struct {
Pool external.ExternalPoolConfig
// HostID is this node's provider host ID, which the holder pass attaches to.
HostID string
// LocalPorts lists the OVS ports plugged on this node, keyed by port name.
LocalPorts func(ctx context.Context) (map[string]struct{}, error)
// LocalGateway reports whether vpcID's gateway chassis is this node.
LocalGateway func(ctx context.Context, vpcID string) (bool, error)
}
// PoolAllocator implements external.Allocator over a floating Provider.
type PoolAllocator struct {
provider Provider
store Store
cfg Config
// suspects holds unbound labelled addresses one reconcile pass has seen,
// so the next can collect them. See Reconcile.
suspects map[string]struct{}
}
var (
_ external.Allocator = (*PoolAllocator)(nil)
_ external.OwnerTracker = (*PoolAllocator)(nil)
_ external.AddressClaimer = (*PoolAllocator)(nil)
)
// New constructs an allocator bound to a single pool.
func New(provider Provider, store Store, cfg Config) (*PoolAllocator, error) {
if provider == nil {
return nil, errors.New("floating: nil provider")
}
if store == nil {
return nil, errors.New("floating: nil store")
}
if cfg.HostID == "" {
return nil, fmt.Errorf("floating: pool %q missing provider host id", cfg.Pool.Name)
}
if cfg.Pool.LabelPrefix == "" {
return nil, fmt.Errorf("floating: pool %q missing label prefix", cfg.Pool.Name)
}
return &PoolAllocator{provider: provider, store: store, cfg: cfg, suspects: map[string]struct{}{}}, nil
}
// Pool returns the pool this allocator manages.
func (a *PoolAllocator) Pool() external.ExternalPoolConfig { return a.cfg.Pool }
// Allocate creates a labelled address and records it, unattached.
//
// The address is created before the record is written. A crash in between
// leaves a labelled address with no binding, which Reconcile collects; the
// reverse order could leave a binding naming an address that never existed.
func (a *PoolAllocator) Allocate(ctx context.Context, req external.AllocateRequest) (netip.Addr, error) {
if req.PoolName != "" && req.PoolName != a.cfg.Pool.Name {
return netip.Addr{}, fmt.Errorf("floating: pool mismatch (got %q, bound to %q)", req.PoolName, a.cfg.Pool.Name)
}
addr, err := a.provider.Create(ctx, a.label(req))
if err != nil {
return netip.Addr{}, capacityErr(req.Purpose, fmt.Errorf("floating: create address: %w", err), err)
}
if !addr.IP.IsValid() {
a.undo(ctx, addr)
return netip.Addr{}, fmt.Errorf("floating: provider returned address %s with no IP", addr.ID)
}
key := addr.IP.String()
binding := Binding{
AddrID: addr.ID,
Purpose: req.Purpose,
AllocationID: req.AllocationID,
ENIID: req.ENIID,
InstanceID: req.InstanceID,
}
err = a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) {
rec.Bindings[key] = binding
return true, nil
})
if err != nil {
a.undo(ctx, addr)
return netip.Addr{}, fmt.Errorf("floating: record binding for %s: %w", key, err)
}
slog.InfoContext(ctx, "floating allocated external IP",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", addr.ID,
"purpose", req.Purpose, "eni_id", req.ENIID)
return addr.IP, nil
}
// Release drops the binding and deletes the address. ownerENIID scopes the
// release as StaticPoolAllocator.Release does, so a teardown sweep naming a
// stale owner is a no-op rather than a double-free.
//
// The record is dropped first: if the delete then fails, the address is an
// unbound labelled address that Reconcile collects, not a record that lies.
func (a *PoolAllocator) Release(ctx context.Context, poolName string, ip netip.Addr, ownerENIID string) error {
if poolName != "" && poolName != a.cfg.Pool.Name {
return fmt.Errorf("floating: pool mismatch (got %q, bound to %q)", poolName, a.cfg.Pool.Name)
}
if !ip.IsValid() {
return errors.New("floating: invalid ip")
}
key := ip.String()
var released Binding
var found bool
err := a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) {
released, found = Binding{}, false
b, ok := rec.Bindings[key]
if !ok {
if ownerENIID != "" {
return false, nil
}
return false, fmt.Errorf("floating: %s not allocated in pool %s", key, a.cfg.Pool.Name)
}
if ownerENIID != "" && b.ENIID != "" && b.ENIID != ownerENIID {
slog.InfoContext(ctx, "floating release skip — address reassigned to a different ENI (stale release)",
"pool", a.cfg.Pool.Name, "public_ip", key,
"stale_owner_eni", ownerENIID, "current_owner_eni", b.ENIID)
return false, nil
}
// Only ReleaseAddress, which names no interface, may free an Elastic IP.
// Instance teardown must leave it allocated, as AWS does.
if ownerENIID != "" && (b.Purpose == purposeEIP || b.AllocationID != "") {
slog.InfoContext(ctx, "floating release skip — address belongs to an Elastic IP allocation",
"pool", a.cfg.Pool.Name, "public_ip", key,
"allocation_id", b.AllocationID, "owner_eni", ownerENIID)
return false, nil
}
delete(rec.Bindings, key)
released, found = b, true
return true, nil
})
if err != nil || !found {
return err
}
if err := a.destroy(ctx, released.AddrID); err != nil {
return fmt.Errorf("floating: delete address %s: %w", released.AddrID, err)
}
slog.InfoContext(ctx, "floating released external IP",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", released.AddrID)
return nil
}
// BindOwner records which ENI an allocated address is associated with. An
// Elastic IP is allocated bare and associated later, and without this the
// holder pass could not tell which node should hold it. An empty eniID clears
// the owner, which is what a disassociate leaves.
func (a *PoolAllocator) BindOwner(ctx context.Context, poolName string, ip netip.Addr, eniID string) error {
if poolName != "" && poolName != a.cfg.Pool.Name {
return fmt.Errorf("floating: pool mismatch (got %q, bound to %q)", poolName, a.cfg.Pool.Name)
}
if !ip.IsValid() {
return errors.New("floating: invalid ip")
}
key := ip.String()
return a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) {
b, ok := rec.Bindings[key]
if !ok || b.ENIID == eniID {
return false, nil
}
b.ENIID = eniID
rec.Bindings[key] = b
return true, nil
})
}
// BindingFor returns the binding behind an allocated address.
func (a *PoolAllocator) BindingFor(ctx context.Context, ip netip.Addr) (Binding, bool, error) {
rec, err := a.store.Get(ctx, a.cfg.Pool.Name)
if errors.Is(err, kvstore.ErrNotFound) {
return Binding{}, false, nil
}
if err != nil {
return Binding{}, false, err
}
b, ok := rec.Bindings[ip.String()]
return b, ok, nil
}
// undo deletes an address a failed Allocate made. Failures are logged, not
// returned: the caller is already failing, and the label marks it for Reconcile.
func (a *PoolAllocator) undo(ctx context.Context, addr Address) {
if err := a.destroy(ctx, addr.ID); err != nil {
slog.WarnContext(ctx, "floating: could not roll back address; reconcile will collect it",
"addr_id", addr.ID, "public_ip", addr.IP.String(), "error", err)
}
}
// destroy detaches and then deletes an address; a missing one is already gone.
// Vultr documents delete as detaching implicitly, but has been measured to
// refuse it with "Unable to detach subnet" while the address is attached.
func (a *PoolAllocator) destroy(ctx context.Context, addrID string) error {
if err := a.provider.Detach(ctx, addrID); err != nil && !errors.Is(err, ErrNotFound) {
return fmt.Errorf("detach: %w", err)
}
if err := a.provider.Delete(ctx, addrID); err != nil && !errors.Is(err, ErrNotFound) {
return err
}
return nil
}
// labelPrefix starts the label of every address this deployment makes.
func (a *PoolAllocator) labelPrefix() string { return a.cfg.Pool.LabelPrefix + ":" }
// label is the provider label for an allocation: the deployment prefix plus
// the AWS identity, so the provider's console shows what claimed the address.
// The holder is not in it, since that changes on every move.
func (a *PoolAllocator) label(req external.AllocateRequest) string {
id := "unclaimed"
switch {
case req.AllocationID != "":
id = req.AllocationID
case req.ENIID != "":
id = req.ENIID
case req.InstanceID != "":
id = req.InstanceID
}
return a.labelPrefix() + id
}
// ours reports whether an address carries this deployment's label.
func (a *PoolAllocator) ours(addr Address) bool {
return strings.HasPrefix(addr.Label, a.labelPrefix())
}
// capacityErr maps the provider quota onto the AWS code for the call. An
// Elastic IP allocation that hits a limit is AddressLimitExceeded in AWS; an
// auto-assigned address has no such limit there, so it reports capacity.
func capacityErr(purpose string, wrapped, raw error) error {
if !errors.Is(raw, ErrQuotaExceeded) {
return wrapped
}
code := awserrors.ErrorInsufficientAddressCapacity
if purpose == purposeEIP {
code = awserrors.ErrorAddressLimitExceeded
}
return fmt.Errorf("%w: %w", errors.New(code), wrapped)
}
@@ -0,0 +1,340 @@
package floating
import (
"context"
"errors"
"fmt"
"maps"
"net/netip"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/providers/cloud/vultr"
)
const (
hostA = "11111111-1111-4111-8111-111111111111"
hostB = "22222222-2222-4222-8222-222222222222"
pool = "public"
prefix = "spinifex-test"
)
// memStore mirrors KVStore, including not-found for a pool never written.
type memStore struct {
mu sync.Mutex
records map[string]Record
mutateErr error
}
func newMemStore() *memStore { return &memStore{records: map[string]Record{}} }
func (m *memStore) Mutate(_ context.Context, poolName string, fn func(*Record) (bool, error)) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.mutateErr != nil {
return m.mutateErr
}
rec := Record{Bindings: map[string]Binding{}}
maps.Copy(rec.Bindings, m.records[poolName].Bindings)
changed, err := fn(&rec)
if err != nil {
return err
}
if changed {
m.records[poolName] = rec
}
return nil
}
func (m *memStore) Get(_ context.Context, poolName string) (Record, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.records[poolName]
if !ok {
return Record{}, fmt.Errorf("%w: %s", kvstore.ErrNotFound, poolName)
}
out := Record{Bindings: map[string]Binding{}}
maps.Copy(out.Bindings, r.Bindings)
return out, nil
}
func (m *memStore) binding(t *testing.T, ip netip.Addr) Binding {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.records[pool].Bindings[ip.String()]
require.True(t, ok, "no binding for %s", ip)
return b
}
// node is one allocator on hostID sharing a fake and a store with its peers.
type node struct {
alloc *PoolAllocator
plugged map[string]struct{}
gateway map[string]bool
}
func newNode(t *testing.T, fake *vultr.Fake, store Store, hostID string) *node {
t.Helper()
n := &node{plugged: map[string]struct{}{}, gateway: map[string]bool{}}
alloc, err := New(NewVultrProvider(fake), store, Config{
Pool: external.ExternalPoolConfig{Name: pool, Source: external.SourceVultr, LabelPrefix: prefix},
HostID: hostID,
LocalPorts: func(context.Context) (map[string]struct{}, error) {
return maps.Clone(n.plugged), nil
},
LocalGateway: func(_ context.Context, vpcID string) (bool, error) { return n.gateway[vpcID], nil },
})
require.NoError(t, err)
n.alloc = alloc
return n
}
func (n *node) plug(eniID string) { n.plugged[topology.Port(eniID)] = struct{}{} }
func setup(t *testing.T) (*vultr.Fake, *memStore, *node, *node) {
t.Helper()
fake := vultr.NewFake("ord", 0, hostA, hostB)
store := newMemStore()
return fake, store, newNode(t, fake, store, hostA), newNode(t, fake, store, hostB)
}
func allocENI(t *testing.T, n *node, eniID string) netip.Addr {
t.Helper()
ip, err := n.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: "eni-public", ENIID: eniID})
require.NoError(t, err)
return ip
}
func TestAllocate_RecordsWithoutAttaching(t *testing.T) {
fake, store, a, _ := setup(t)
ip, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: purposeEIP, AllocationID: "eipalloc-1"})
require.NoError(t, err)
b := store.binding(t, ip)
assert.Equal(t, "eipalloc-1", b.AllocationID)
assert.Empty(t, fake.HolderOf(b.AddrID), "the owner's node attaches, never the allocating node")
got, err := fake.GetReservedIP(context.Background(), b.AddrID)
require.NoError(t, err)
assert.Equal(t, prefix+":eipalloc-1", got.Label)
}
func TestAllocate_RecordFailureDeletesTheAddress(t *testing.T) {
fake, store, a, _ := setup(t)
store.mutateErr = errors.New("kv down")
_, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, ENIID: "eni-1"})
require.Error(t, err)
assert.Empty(t, fake.IDs())
}
func TestAllocate_QuotaMapsToTheAWSCodeForTheCall(t *testing.T) {
fake := vultr.NewFake("ord", 1, hostA)
a := newNode(t, fake, newMemStore(), hostA)
allocENI(t, a, "eni-1")
_, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: purposeEIP})
assert.ErrorContains(t, err, awserrors.ErrorAddressLimitExceeded)
_, err = a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: "eni-public"})
assert.ErrorContains(t, err, awserrors.ErrorInsufficientAddressCapacity)
}
func TestRelease_InstanceTeardownKeepsAnElasticIP(t *testing.T) {
fake, store, a, _ := setup(t)
ip, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: purposeEIP, AllocationID: "eipalloc-1"})
require.NoError(t, err)
require.NoError(t, a.alloc.BindOwner(context.Background(), pool, ip, "eni-1"))
require.NoError(t, a.alloc.Release(context.Background(), pool, ip, "eni-1"))
store.binding(t, ip)
assert.Len(t, fake.IDs(), 1)
require.NoError(t, a.alloc.Release(context.Background(), pool, ip, ""))
assert.Empty(t, fake.IDs())
}
func TestRelease_DeletesAnAttachedAddress(t *testing.T) {
fake, _, a, _ := setup(t)
ip := allocENI(t, a, "eni-1")
a.plug("eni-1")
_, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
require.NoError(t, a.alloc.Release(context.Background(), pool, ip, "eni-1"))
assert.Empty(t, fake.IDs())
}
func TestRelease_DetachesBeforeDeleting(t *testing.T) {
fake, _, a, _ := setup(t)
fake.RefuseDeleteAttached = true
ip := allocENI(t, a, "eni-1")
a.plug("eni-1")
_, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
require.NoError(t, a.alloc.Release(context.Background(), pool, ip, "eni-1"))
assert.Empty(t, fake.IDs(), "an attached address must still be deleted when Vultr refuses an implicit detach")
}
func TestRelease_StaleOwnerIsANoop(t *testing.T) {
fake, _, a, _ := setup(t)
ip := allocENI(t, a, "eni-1")
require.NoError(t, a.alloc.Release(context.Background(), pool, ip, "eni-other"))
assert.Len(t, fake.IDs(), 1)
}
func TestClaim_AttachesWhereTheOwnerRuns(t *testing.T) {
fake, store, a, b := setup(t)
// Allocated by whichever node served the request, run on the other.
ip := allocENI(t, a, "eni-1")
b.plug("eni-1")
res, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Zero(t, res.Local, "the allocating node does not run the owner")
res, err = b.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{ip.String()}, res.Claimed)
assert.Empty(t, res.Stalled)
addrID := store.binding(t, ip).AddrID
assert.Equal(t, hostB, fake.HolderOf(addrID))
assert.Equal(t, hostB, store.binding(t, ip).Holder)
fake.Calls = nil
res, err = b.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Empty(t, res.Claimed)
assert.Equal(t, 1, res.Local)
assert.Equal(t, []string{"list"}, fake.Calls, "a settled address costs one listing and no writes")
}
func TestClaim_FollowsTheOwnerToAnotherNode(t *testing.T) {
fake, store, a, b := setup(t)
ip := allocENI(t, a, "eni-1")
a.plug("eni-1")
_, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
// The guest is recovered on B; A is dead and never runs another pass.
b.plug("eni-1")
res, err := b.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{ip.String()}, res.Claimed)
assert.Equal(t, hostB, fake.HolderOf(store.binding(t, ip).AddrID))
}
func TestClaim_AttachFailureIsStalledNotFatal(t *testing.T) {
fake, _, a, _ := setup(t)
ip1 := allocENI(t, a, "eni-1")
ip2 := allocENI(t, a, "eni-2")
a.plug("eni-1")
a.plug("eni-2")
fake.Fail["attach"] = errors.New("throttled")
res, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
require.Len(t, res.Stalled, 1)
require.Len(t, res.Claimed, 1)
assert.ElementsMatch(t, []string{ip1.String(), ip2.String()}, []string{res.Stalled[0].PublicIP, res.Claimed[0]})
assert.NotEmpty(t, res.Stalled[0].ENIID, "the daemon names the guest from the ENI")
}
func TestClaim_DisassociatedAddressIsDetached(t *testing.T) {
fake, store, a, _ := setup(t)
ip, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: purposeEIP, AllocationID: "eipalloc-1"})
require.NoError(t, err)
require.NoError(t, a.alloc.BindOwner(context.Background(), pool, ip, "eni-1"))
a.plug("eni-1")
_, err = a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
require.NoError(t, a.alloc.BindOwner(context.Background(), pool, ip, ""))
_, err = a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Empty(t, fake.HolderOf(store.binding(t, ip).AddrID))
assert.Empty(t, store.binding(t, ip).Holder)
assert.Len(t, fake.IDs(), 1, "a disassociated Elastic IP stays allocated")
}
func TestClaim_NATGatewayAddressFollowsTheChassis(t *testing.T) {
fake, store, a, b := setup(t)
ip, err := a.alloc.Allocate(context.Background(), external.AllocateRequest{PoolName: pool, Purpose: purposeEIP, AllocationID: "eipalloc-nat"})
require.NoError(t, err)
require.NoError(t, NewGatewayBinder(store, []string{pool}).BindGateway(context.Background(), ip.String(), "vpc-1"))
b.gateway["vpc-1"] = true
res, err := b.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{ip.String()}, res.Claimed)
assert.Equal(t, hostB, fake.HolderOf(store.binding(t, ip).AddrID))
}
func TestClaim_UnreadableBindingsMarkNothing(t *testing.T) {
_, _, a, _ := setup(t)
res, err := a.alloc.ClaimLocalAddresses(context.Background())
require.NoError(t, err)
assert.Equal(t, external.ClaimResult{}, res)
}
func TestReconcile_CollectsALeakOnlyOnTheSecondSighting(t *testing.T) {
fake, _, a, _ := setup(t)
leak, err := fake.CreateReservedIP(context.Background(), prefix+":eni-crashed")
require.NoError(t, err)
fake.Seed(vultr.ReservedIP{ID: "operator-own", Label: "website", Address: netip.MustParseAddr("203.0.113.9")})
fake.Seed(vultr.ReservedIP{ID: "other-deployment", Label: prefix + "-two:eni-9", Address: netip.MustParseAddr("203.0.113.10")})
allocENI(t, a, "eni-1")
res, err := a.alloc.Reconcile(context.Background())
require.NoError(t, err)
assert.Empty(t, res.Collected, "first sight could be another node between create and record")
assert.Equal(t, 1, res.Suspect)
assert.Equal(t, 2, res.Skipped)
res, err = a.alloc.Reconcile(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{leak.ID}, res.Collected)
assert.ElementsMatch(t, []string{"operator-own", "other-deployment", "fake-rip-0002"}, fake.IDs())
}
func TestReconcile_AnAddressRecordedBetweenPassesIsKept(t *testing.T) {
fake, store, a, _ := setup(t)
addr, err := fake.CreateReservedIP(context.Background(), prefix+":eni-1")
require.NoError(t, err)
_, err = a.alloc.Reconcile(context.Background())
require.NoError(t, err)
require.NoError(t, store.Mutate(context.Background(), pool, func(r *Record) (bool, error) {
r.Bindings[addr.Address.String()] = Binding{AddrID: addr.ID, ENIID: "eni-1"}
return true, nil
}))
res, err := a.alloc.Reconcile(context.Background())
require.NoError(t, err)
assert.Empty(t, res.Collected)
assert.Len(t, fake.IDs(), 1)
}
func TestReconcile_ReportsAnAddressDeletedBehindOurBack(t *testing.T) {
fake, store, a, _ := setup(t)
ip := allocENI(t, a, "eni-1")
require.NoError(t, fake.DeleteReservedIP(context.Background(), store.binding(t, ip).AddrID))
res, err := a.alloc.Reconcile(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{ip.String()}, res.Missing)
store.binding(t, ip)
}
func TestNew_RefusesAPoolWithoutHostOrPrefix(t *testing.T) {
fake := vultr.NewFake("ord", 0)
_, err := New(NewVultrProvider(fake), newMemStore(), Config{Pool: external.ExternalPoolConfig{Name: pool, LabelPrefix: prefix}})
require.Error(t, err)
_, err = New(NewVultrProvider(fake), newMemStore(), Config{Pool: external.ExternalPoolConfig{Name: pool}, HostID: hostA})
require.Error(t, err)
}
+166
View File
@@ -0,0 +1,166 @@
package floating
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// ClaimLocalAddresses makes the provider deliver each address to the node its
// owner runs on, and takes back any address this node holds that no one owns.
//
// Ownership is decided by local OVS: a plugged tap is a guest running here, and
// a NAT gateway's address belongs on its VPC's gateway chassis. The provider's
// listing is the authority on where an address is now, read once per pass, so
// an address moved in the provider's console is repaired rather than believed.
//
// What the pass could not do is reported per address in Stalled, verified by
// reading the address back rather than trusting the attach's acknowledgement.
func (a *PoolAllocator) ClaimLocalAddresses(ctx context.Context) (external.ClaimResult, error) {
var res external.ClaimResult
rec, err := a.store.Get(ctx, a.cfg.Pool.Name)
if errors.Is(err, kvstore.ErrNotFound) {
return res, nil
}
if err != nil {
return res, fmt.Errorf("floating holder: read bindings: %w", err)
}
if len(rec.Bindings) == 0 {
return res, nil
}
var plugged map[string]struct{}
if a.cfg.LocalPorts != nil {
if plugged, err = a.cfg.LocalPorts(ctx); err != nil {
return res, fmt.Errorf("floating holder: list local OVS ports: %w", err)
}
}
live, err := a.provider.List(ctx)
if err != nil {
return res, fmt.Errorf("floating holder: list provider addresses: %w", err)
}
byID := make(map[string]Address, len(live))
for _, addr := range live {
byID[addr.ID] = addr
}
for key, b := range rec.Bindings {
addr, ok := byID[b.AddrID]
if !ok {
// Reconcile reports an address the provider no longer has.
continue
}
local, err := a.ownsBinding(ctx, b, plugged)
if err != nil {
return res, err
}
switch {
case local:
res.Local++
a.claim(ctx, key, b, addr, &res)
case !b.owned() && addr.HolderID == a.cfg.HostID:
a.surrender(ctx, key, b)
}
}
return res, nil
}
// claim puts one owned address on this node. A failure is recorded as stalled
// rather than returned, because the guests behind the other addresses are as
// unreachable as this one if the pass gives up here.
func (a *PoolAllocator) claim(ctx context.Context, key string, b Binding, addr Address, res *external.ClaimResult) {
host := a.cfg.HostID
if addr.HolderID == host {
a.recordHolder(ctx, key, b.AddrID, host)
return
}
stall := func(reason string) {
res.Stalled = append(res.Stalled, external.StalledAddress{PublicIP: key, ENIID: b.ENIID, Reason: reason})
}
if addr.HolderID != "" {
if err := a.provider.Detach(ctx, b.AddrID); err != nil && !errors.Is(err, ErrNotFound) {
stall(fmt.Sprintf("detach %s from %s: %v", b.AddrID, addr.HolderID, err))
return
}
}
if err := a.provider.Attach(ctx, b.AddrID, host); err != nil {
stall(fmt.Sprintf("attach %s to %s: %v", b.AddrID, host, err))
return
}
got, err := a.provider.Get(ctx, b.AddrID)
if err != nil {
stall(fmt.Sprintf("read back %s after attach: %v", b.AddrID, err))
return
}
if got.HolderID != host {
stall(fmt.Sprintf("provider accepted the attach of %s to %s and reports holder %q", b.AddrID, host, got.HolderID))
return
}
// A move from another host is worth an operator's attention; a first
// attach after allocation is routine.
level := slog.LevelInfo
if addr.HolderID != "" {
level = slog.LevelWarn
}
slog.Log(ctx, level, "floating claimed an address whose owner runs here",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", b.AddrID,
"eni_id", b.ENIID, "gateway_vpc_id", b.GatewayVPCID, "from_host", addr.HolderID, "to_host", host)
a.recordHolder(ctx, key, b.AddrID, host)
res.Claimed = append(res.Claimed, key)
}
// surrender detaches an address this node holds with no owner, which is what a
// disassociate leaves: still allocated and billing, answered by no guest.
func (a *PoolAllocator) surrender(ctx context.Context, key string, b Binding) {
if err := a.provider.Detach(ctx, b.AddrID); err != nil && !errors.Is(err, ErrNotFound) {
slog.WarnContext(ctx, "floating could not detach an unowned address; retrying next pass",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", b.AddrID, "error", err)
return
}
slog.InfoContext(ctx, "floating detached an address with no owner",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", b.AddrID)
a.recordHolder(ctx, key, b.AddrID, "")
}
// ownsBinding answers whether this node is where b's traffic is handled. An
// address with neither an ENI nor a gateway has no node, and is left alone.
func (a *PoolAllocator) ownsBinding(ctx context.Context, b Binding, plugged map[string]struct{}) (bool, error) {
if b.ENIID != "" {
_, local := plugged[topology.Port(b.ENIID)]
return local, nil
}
if b.GatewayVPCID == "" || a.cfg.LocalGateway == nil {
return false, nil
}
local, err := a.cfg.LocalGateway(ctx, b.GatewayVPCID)
if err != nil {
return false, fmt.Errorf("floating holder: gateway chassis for %s: %w", b.GatewayVPCID, err)
}
return local, nil
}
// recordHolder writes the observed holder under CAS, only while the binding
// still names the address this pass acted on. A failed write is logged: the
// provider already agrees, and the next pass rewrites it.
func (a *PoolAllocator) recordHolder(ctx context.Context, key, addrID, holder string) {
err := a.store.Mutate(ctx, a.cfg.Pool.Name, func(r *Record) (bool, error) {
b, ok := r.Bindings[key]
if !ok || b.AddrID != addrID || b.Holder == holder {
return false, nil
}
b.Holder = holder
r.Bindings[key] = b
return true, nil
})
if err != nil {
slog.WarnContext(ctx, "floating could not record the holder; next pass retries",
"pool", a.cfg.Pool.Name, "public_ip", key, "error", err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package floating
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// GatewayBinder records which VPC's NAT gateway answers on a floating address.
// vpcd calls it from the reconcile that decides the gateway chassis, since a
// NAT gateway's address has no ENI to place it by.
type GatewayBinder struct {
store Store
pools []string
}
// NewGatewayBinder covers the named floating pools.
func NewGatewayBinder(store Store, pools []string) *GatewayBinder {
return &GatewayBinder{store: store, pools: pools}
}
// BindGateway records vpcID against externalIP in whichever pool holds it.
// Idempotent: an unchanged VPC is not a write.
func (g *GatewayBinder) BindGateway(ctx context.Context, externalIP, vpcID string) error {
if g == nil || externalIP == "" {
return nil
}
for _, pool := range g.pools {
err := g.store.Mutate(ctx, pool, func(rec *Record) (bool, error) {
b, ok := rec.Bindings[externalIP]
if !ok || b.GatewayVPCID == vpcID {
return false, nil
}
b.GatewayVPCID = vpcID
rec.Bindings[externalIP] = b
return true, nil
})
if errors.Is(err, kvstore.ErrNotFound) {
continue
}
if err != nil {
return fmt.Errorf("floating: record gateway VPC for %s in pool %q: %w", externalIP, pool, err)
}
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
// Package floating implements external.Allocator for clouds whose public
// address is one object attached to one host, and moved by detaching it and
// attaching it elsewhere. Vultr reserved IPs are the first such provider.
//
// The engine owns the binding record, the create-then-record ordering, release,
// reconcile and the holder pass that puts each address on the node running its
// owner. A provider adapter owns only the API calls and their error mapping.
//
// Allocate never attaches. The node an address's owner runs on attaches it, so
// initial placement and a move after recovery are the same code path.
package floating
import (
"context"
"errors"
"net/netip"
)
// Address is one provider address as the engine sees it.
type Address struct {
ID string
IP netip.Addr
Label string
// HolderID is the provider host the address is attached to; empty when
// it is attached nowhere.
HolderID string
}
// Provider is the vendor half of a floating public address. Errors are wrapped
// around the sentinels below so the engine can branch on them.
type Provider interface {
// Create makes an unattached address carrying label.
Create(ctx context.Context, label string) (Address, error)
// Attach attaches addrID to hostID, succeeding if it is already there.
Attach(ctx context.Context, addrID, hostID string) error
// Detach detaches addrID from whichever host holds it, if any.
Detach(ctx context.Context, addrID string) error
// Delete releases addrID, detaching it first if the provider requires.
Delete(ctx context.Context, addrID string) error
// Get reads one address, including its holder.
Get(ctx context.Context, addrID string) (Address, error)
// List returns every address in the provider scope, ours or not.
List(ctx context.Context) ([]Address, error)
}
var (
// ErrNotFound means the address does not exist at the provider.
ErrNotFound = errors.New("floating: address not found")
// ErrQuotaExceeded means the provider will not create another address.
ErrQuotaExceeded = errors.New("floating: provider address quota exceeded")
// ErrHolderBusy means the address is attached to a different host.
ErrHolderBusy = errors.New("floating: address attached to another host")
)
+95
View File
@@ -0,0 +1,95 @@
package floating
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// ReconcileResult reports what a pass found.
type ReconcileResult struct {
// Collected is the IDs of labelled addresses deleted as leaks.
Collected []string
// Suspect counts unbound labelled addresses held for the next pass.
Suspect int
// Missing is the binding keys whose address the provider no longer has.
Missing []string
// Skipped counts addresses without this deployment's label, left alone.
Skipped int
}
// Reconcile squares the record against the provider.
//
// A labelled address with no binding leaked from an interrupted Allocate or
// Release, and bills until something deletes it. It is also exactly what an
// Allocate on another node looks like between its create and its record write,
// so an address is collected only when two consecutive passes find it unbound.
//
// A binding whose address the provider no longer has was deleted behind our
// back. It is reported, not dropped: the address cannot be re-created with the
// same IP, and the customer's allocation must not silently change meaning.
//
// It never touches an address without this deployment's label.
func (a *PoolAllocator) Reconcile(ctx context.Context) (ReconcileResult, error) {
var res ReconcileResult
live, err := a.provider.List(ctx)
if err != nil {
return res, fmt.Errorf("floating reconcile: list addresses: %w", err)
}
rec, err := a.store.Get(ctx, a.cfg.Pool.Name)
if err != nil && !errors.Is(err, kvstore.ErrNotFound) {
return res, fmt.Errorf("floating reconcile: read bindings: %w", err)
}
claimed := make(map[string]struct{}, len(rec.Bindings))
for _, b := range rec.Bindings {
claimed[b.AddrID] = struct{}{}
}
liveIDs := make(map[string]struct{}, len(live))
suspects := make(map[string]struct{})
var errs []error
for _, addr := range live {
liveIDs[addr.ID] = struct{}{}
if !a.ours(addr) {
res.Skipped++
continue
}
if _, ok := claimed[addr.ID]; ok {
continue
}
if _, seen := a.suspects[addr.ID]; !seen {
suspects[addr.ID] = struct{}{}
continue
}
if err := a.destroy(ctx, addr.ID); err != nil {
suspects[addr.ID] = struct{}{}
errs = append(errs, fmt.Errorf("collect leaked %s: %w", addr.ID, err))
continue
}
slog.WarnContext(ctx, "floating reconcile collected a leaked address",
"pool", a.cfg.Pool.Name, "addr_id", addr.ID, "public_ip", addr.IP.String(),
"label", addr.Label, "holder", addr.HolderID)
res.Collected = append(res.Collected, addr.ID)
}
a.suspects = suspects
res.Suspect = len(suspects)
for key, b := range rec.Bindings {
if _, ok := liveIDs[b.AddrID]; ok {
continue
}
slog.ErrorContext(ctx, "floating reconcile: the provider no longer has an allocated address",
"pool", a.cfg.Pool.Name, "public_ip", key, "addr_id", b.AddrID,
"allocation_id", b.AllocationID, "eni_id", b.ENIID)
res.Missing = append(res.Missing, key)
}
if len(errs) > 0 {
return res, fmt.Errorf("floating reconcile: %w", errors.Join(errs...))
}
return res, nil
}
+66
View File
@@ -0,0 +1,66 @@
package floating
import (
"context"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
const (
// KVBucketFloatingBindings holds the address → provider address mapping for
// every floating pool, keyed by pool name.
KVBucketFloatingBindings = "spinifex-floating-bindings"
KVBucketFloatingBindingsHistory = 5
kvCASRetries = 10
)
func bucketConfig() kvstore.Config {
return kvstore.Config{
Name: KVBucketFloatingBindings,
History: KVBucketFloatingBindingsHistory,
Attempts: kvCASRetries,
Missing: "floating: no JetStream client configured",
Exhausted: func(key string, attempts int) error {
return fmt.Errorf("floating: pool %s contended after %d attempts", key, attempts)
},
}
}
// KVStore is the JetStream-backed Store.
type KVStore struct {
store *kvstore.Store[Record]
}
var _ Store = (*KVStore)(nil)
// NewKVStore creates the bindings bucket if it is missing.
func NewKVStore(js jetstream.JetStream) *KVStore {
return &KVStore{store: kvstore.New[Record](js, bucketConfig())}
}
// Mutate implements Store. Upsert, because a pool's first allocation is what
// creates its record, and that allocation has already made a billable address.
func (s *KVStore) Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error {
return s.store.Upsert(ctx, poolName, func(rec *Record) (bool, error) {
if rec.Bindings == nil {
rec.Bindings = map[string]Binding{}
}
return fn(rec)
})
}
// Get implements Store. A pool with no record reports kvstore.ErrNotFound,
// which callers read as an empty binding set.
func (s *KVStore) Get(ctx context.Context, poolName string) (Record, error) {
rec, _, err := s.store.Get(ctx, poolName)
if err != nil {
return Record{}, err
}
if rec == nil || rec.Bindings == nil {
return Record{Bindings: map[string]Binding{}}, nil
}
return *rec, nil
}
+95
View File
@@ -0,0 +1,95 @@
package floating
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/providers/cloud/vultr"
)
// vultrAPI is the slice of the Vultr client the adapter calls, so tests can
// stand the fake in for it.
type vultrAPI interface {
CreateReservedIP(ctx context.Context, label string) (vultr.ReservedIP, error)
GetReservedIP(ctx context.Context, id string) (vultr.ReservedIP, error)
ListReservedIPs(ctx context.Context) ([]vultr.ReservedIP, error)
AttachReservedIP(ctx context.Context, id, instanceID string) error
DetachReservedIP(ctx context.Context, id string) error
DeleteReservedIP(ctx context.Context, id string) error
}
var (
_ vultrAPI = (*vultr.Client)(nil)
_ vultrAPI = (*vultr.Fake)(nil)
)
// VultrProvider adapts Vultr reserved IPs to Provider.
type VultrProvider struct {
api vultrAPI
}
var _ Provider = (*VultrProvider)(nil)
// NewVultrProvider wraps a Vultr client or fake.
func NewVultrProvider(api vultrAPI) *VultrProvider { return &VultrProvider{api: api} }
func fromVultr(r vultr.ReservedIP) Address {
return Address{ID: r.ID, IP: r.Address, Label: r.Label, HolderID: r.InstanceID}
}
// mapVultr wraps a Vultr error around the engine's sentinel for it, keeping
// Vultr's own message for the log.
func mapVultr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, vultr.ErrNotFound):
return fmt.Errorf("%w: %w", ErrNotFound, err)
case errors.Is(err, vultr.ErrQuotaExceeded):
return fmt.Errorf("%w: %w", ErrQuotaExceeded, err)
case errors.Is(err, vultr.ErrHolderBusy):
return fmt.Errorf("%w: %w", ErrHolderBusy, err)
}
return err
}
func (p *VultrProvider) Create(ctx context.Context, label string) (Address, error) {
r, err := p.api.CreateReservedIP(ctx, label)
if err != nil {
return Address{}, mapVultr(err)
}
return fromVultr(r), nil
}
func (p *VultrProvider) Attach(ctx context.Context, addrID, hostID string) error {
return mapVultr(p.api.AttachReservedIP(ctx, addrID, hostID))
}
func (p *VultrProvider) Detach(ctx context.Context, addrID string) error {
return mapVultr(p.api.DetachReservedIP(ctx, addrID))
}
func (p *VultrProvider) Delete(ctx context.Context, addrID string) error {
return mapVultr(p.api.DeleteReservedIP(ctx, addrID))
}
func (p *VultrProvider) Get(ctx context.Context, addrID string) (Address, error) {
r, err := p.api.GetReservedIP(ctx, addrID)
if err != nil {
return Address{}, mapVultr(err)
}
return fromVultr(r), nil
}
func (p *VultrProvider) List(ctx context.Context) ([]Address, error) {
rs, err := p.api.ListReservedIPs(ctx)
if err != nil {
return nil, mapVultr(err)
}
out := make([]Address, 0, len(rs))
for _, r := range rs {
out = append(out, fromVultr(r))
}
return out, nil
}
+51
View File
@@ -0,0 +1,51 @@
package floating
import (
"context"
"fmt"
"log/slog"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/host"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/providers/cloud/vultr"
)
// FromPoolConfig builds a live allocator for one floating pool and proves the
// credential works before anything depends on it. It does not reconcile; the
// caller runs that pass. sbAddr is the OVN Southbound address the
// gateway-chassis question is asked of; empty uses the local socket.
func FromPoolConfig(ctx context.Context, js jetstream.JetStream, pool external.ExternalPoolConfig, sbAddr string) (*PoolAllocator, error) {
if !pool.IsVultr() {
return nil, fmt.Errorf("floating: pool %q has source %q, not a floating provider", pool.Name, pool.Source)
}
client, err := vultr.New(vultr.Config{Region: pool.VultrRegion, KeyFile: pool.VultrKeyFile})
if err != nil {
return nil, fmt.Errorf("floating: pool %q: %w", pool.Name, err)
}
provider := NewVultrProvider(client)
// An allowlisted key refused from this node's address looks ready until the
// first launch, so the refusal is surfaced at start with Vultr's reason.
if _, err := provider.List(ctx); err != nil {
slog.ErrorContext(ctx, "floating: Vultr refused the pool's first call; public addresses will fail until fixed",
"pool", pool.Name, "region", pool.VultrRegion, "error", err)
} else {
slog.InfoContext(ctx, "floating using Vultr reserved IPs", "pool", pool.Name,
"region", pool.VultrRegion, "provider_host_id", pool.ProviderHostID, "label_prefix", pool.LabelPrefix)
}
return New(provider, NewKVStore(js), Config{
Pool: pool,
HostID: pool.ProviderHostID,
LocalPorts: func(ctx context.Context) (map[string]struct{}, error) {
return host.ListLocalPorts(ctx, host.NewExecRunner())
},
LocalGateway: func(ctx context.Context, vpcID string) (bool, error) {
return host.NewGatewayClaimProber(sbAddr).
GatewayPortLocal(ctx, topology.GatewayChassisRedirectPort(vpcID))
},
})
}
+8 -23
View File
@@ -6,34 +6,19 @@ import (
"fmt"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// ClaimResult reports what an affinity pass moved, so a caller can log it and a
// test can assert on it without reading the fake's internals.
type ClaimResult struct {
// Claimed is the public addresses whose private half this node took over.
Claimed []string
// Local is how many bindings this node already owned, moved or not.
Local int
// Stalled is every address whose guest runs here and which OCI is not
// delivering here, after the pass has tried to move it and re-read the
// answer. Empty is the healthy result, and a non-empty entry names a guest
// that is running and unreachable on its public address.
Stalled []StalledAddress
}
// ClaimResult and StalledAddress are shared with every cloud pool, so the
// daemon reaches one reachability verdict across providers.
type (
ClaimResult = external.ClaimResult
StalledAddress = external.StalledAddress
)
// StalledAddress is one address the pass could not put where its guest is.
//
// It carries the ENI rather than the instance because that is what this layer
// knows: a caller that wants to name the guest has the port-to-instance map and
// this package does not.
type StalledAddress struct {
PublicIP string
ENIID string
Reason string
}
var _ external.AddressClaimer = (*PoolAllocator)(nil)
// ClaimLocalAddresses makes OCI agree with where the guests actually are.
//
+20
View File
@@ -45,6 +45,17 @@ type ExternalPoolConfig struct {
ExoscaleConfigFile string
ExoscaleAccount string
ExoscaleBinary string
// Vultr* configure a source="vultr" pool: the Vultr region the reserved
// IPs are created in and the file holding the API key. LabelPrefix marks
// every address this deployment made, so two deployments in one account
// never judge each other's.
VultrRegion string
VultrKeyFile string
LabelPrefix string
// ProviderHostID is this node's instance ID at the provider, which a
// floating address is attached to. Per node, so it is filled from the
// node's own config rather than the shared pool section.
ProviderHostID string
}
// IsDHCP reports whether the pool sources IPs from an upstream DHCP server.
@@ -68,6 +79,11 @@ func (p *ExternalPoolConfig) IsExoscale() bool {
return p != nil && p.Source == SourceExoscale
}
// IsVultr reports whether the pool sources IPs from Vultr reserved IPs.
func (p *ExternalPoolConfig) IsVultr() bool {
return p != nil && p.Source == SourceVultr
}
const (
// SourceStatic is the default pool source (inline range math, KV-backed).
SourceStatic = "static"
@@ -82,6 +98,10 @@ const (
// attached through the exo CLI. A manual EIP arrives addressed to itself,
// so unlike OCI there is no private half to map.
SourceExoscale = "exoscale"
// SourceVultr delegates allocation to Vultr reserved IPs, delivered to the
// holder addressed to itself, so like Exoscale there is no private half.
// A VM is routed it by MAC; bare metal is ARPed for it on the public NIC.
SourceVultr = "vultr"
// OCIAuthConfigFile authenticates from an API-key config file. The default,
// because it is what an operator already has from `oci setup config`.
@@ -9,6 +9,10 @@ import (
const eipIngressComment = "spinifex-eip-ingress"
// InternetRouteProbe is a public address whose route names the interface a
// node reaches the internet through. `ip route get` sends nothing to it.
const InternetRouteProbe = "1.1.1.1"
// DetectUplinkFor resolves which interface (and local source IP) the kernel
// would use to reach gatewayIP by parsing `ip route get`. Re-run per ensure:
// WiFi/cellular uplinks change interface and address across reconnects.
+396
View File
@@ -0,0 +1,396 @@
// Package vultr is the narrow slice of the Vultr v2 API that floating public
// addresses need: reserved IPs and the instance VPC listing formation reads.
//
// It is a direct net/http client rather than govultr or vultr-cli. The surface
// is seven calls with bearer auth, so a client this size costs less than a
// dependency, needs no host binary, and is tested against captured responses.
package vultr
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// DefaultBaseURL is the public v2 API.
const DefaultBaseURL = "https://api.vultr.com/v2"
// DefaultKeyFile holds the node's API key, readable by the daemon's user only.
const DefaultKeyFile = "/etc/spinifex/vultr/api-key"
// DefaultCallTimeout bounds one HTTP round trip. Allocation sits inside the
// AWS client's 60s read timeout, so a call plus its retries stays well under.
const DefaultCallTimeout = 15 * time.Second
// defaultAttempts is how many times a retryable failure is tried in total.
const defaultAttempts = 3
// listPageSize is the largest page the reserved-IP listing accepts.
const listPageSize = 500
var (
// ErrNotFound means the reserved IP or instance does not exist.
ErrNotFound = errors.New("vultr: resource not found")
// ErrQuotaExceeded means the account cannot hold another reserved IP.
ErrQuotaExceeded = errors.New("vultr: reserved IP quota exceeded")
// ErrHolderBusy means the address is attached to a different instance.
ErrHolderBusy = errors.New("vultr: reserved IP attached to another instance")
// ErrUnauthorized means the key was refused, including by its IP allowlist.
ErrUnauthorized = errors.New("vultr: API key refused")
// ErrUnavailable means the API was throttled or failing; retry later.
ErrUnavailable = errors.New("vultr: API unavailable")
)
// errAlreadyAttached is Vultr refusing an attach of an address that is
// attached anywhere, including to the instance named. Attach resolves which.
var errAlreadyAttached = errors.New("vultr: reserved IP already attached")
// errDetachRefused is Vultr's 400 "Unable to detach subnet" on a detach or a
// delete. Measured as transient: the same detach succeeded moments later.
var errDetachRefused = errors.New("vultr: detach refused")
// APIError is a non-2xx response, carrying Vultr's own message.
type APIError struct {
Status int
Message string
kind error
}
func (e *APIError) Error() string {
return fmt.Sprintf("vultr: HTTP %d: %s", e.Status, e.Message)
}
// Unwrap exposes the classified sentinel, if any, to errors.Is.
func (e *APIError) Unwrap() error { return e.kind }
// ReservedIP is one Vultr reserved IPv4 address.
type ReservedIP struct {
ID string
Region string
Label string
Address netip.Addr
InstanceID string // empty when unattached
}
// VPCAttachment is one VPC an instance is attached to, with its address there.
type VPCAttachment struct {
VPCID string
MAC string
Address netip.Addr
}
// Config selects the endpoint, credential and region.
type Config struct {
BaseURL string
KeyFile string
Region string
CallTimeout time.Duration
HTTPClient *http.Client
// Attempts is the total tries for a retryable failure; zero means the default.
Attempts int
// Backoff is the first retry delay; it doubles with jitter. Zero means 500ms.
Backoff time.Duration
}
// Client implements the reserved-IP calls against one region.
type Client struct {
cfg Config
http *http.Client
}
// New builds a client. It makes no call; ListReservedIPs proves the key works.
func New(cfg Config) (*Client, error) {
if cfg.Region == "" {
return nil, errors.New("vultr: region is required")
}
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
if cfg.KeyFile == "" {
cfg.KeyFile = DefaultKeyFile
}
if cfg.CallTimeout <= 0 {
cfg.CallTimeout = DefaultCallTimeout
}
if cfg.Attempts <= 0 {
cfg.Attempts = defaultAttempts
}
if cfg.Backoff <= 0 {
cfg.Backoff = 500 * time.Millisecond
}
hc := cfg.HTTPClient
if hc == nil {
hc = &http.Client{}
}
return &Client{cfg: cfg, http: hc}, nil
}
// Region is the Vultr region every address is created in.
func (c *Client) Region() string { return c.cfg.Region }
type reservedIPJSON struct {
ID string `json:"id"`
Region string `json:"region"`
IPType string `json:"ip_type"`
Subnet string `json:"subnet"`
SubnetSize int `json:"subnet_size"`
Label string `json:"label"`
InstanceID string `json:"instance_id"`
}
func (r reservedIPJSON) toReservedIP() (ReservedIP, error) {
if r.ID == "" {
return ReservedIP{}, errors.New("vultr: reserved IP with no id in response")
}
addr, err := netip.ParseAddr(r.Subnet)
if err != nil {
return ReservedIP{}, fmt.Errorf("vultr: reserved IP %s has unparseable address %q: %w", r.ID, r.Subnet, err)
}
return ReservedIP{ID: r.ID, Region: r.Region, Label: r.Label, Address: addr, InstanceID: r.InstanceID}, nil
}
// CreateReservedIP creates an unattached IPv4 address carrying label.
//
// Not retried on a 5xx: Vultr may have created the address before failing,
// and a retry would make a second. The label lets reconcile collect either.
func (c *Client) CreateReservedIP(ctx context.Context, label string) (ReservedIP, error) {
body := map[string]string{"region": c.cfg.Region, "ip_type": "v4", "label": label}
var out struct {
ReservedIP reservedIPJSON `json:"reserved_ip"`
}
if err := c.do(ctx, http.MethodPost, "/reserved-ips", body, &out, false); err != nil {
return ReservedIP{}, err
}
return out.ReservedIP.toReservedIP()
}
// GetReservedIP reads one address, including which instance holds it.
func (c *Client) GetReservedIP(ctx context.Context, id string) (ReservedIP, error) {
var out struct {
ReservedIP reservedIPJSON `json:"reserved_ip"`
}
if err := c.do(ctx, http.MethodGet, "/reserved-ips/"+url.PathEscape(id), nil, &out, true); err != nil {
return ReservedIP{}, err
}
return out.ReservedIP.toReservedIP()
}
// ListReservedIPs returns every address in this client's region, ours or not.
func (c *Client) ListReservedIPs(ctx context.Context) ([]ReservedIP, error) {
var all []ReservedIP
cursor := ""
for {
q := url.Values{"per_page": {strconv.Itoa(listPageSize)}}
if cursor != "" {
q.Set("cursor", cursor)
}
var out struct {
ReservedIPs []reservedIPJSON `json:"reserved_ips"`
Meta struct {
Links struct {
Next string `json:"next"`
} `json:"links"`
} `json:"meta"`
}
if err := c.do(ctx, http.MethodGet, "/reserved-ips?"+q.Encode(), nil, &out, true); err != nil {
return nil, err
}
for _, r := range out.ReservedIPs {
if r.Region != c.cfg.Region || r.IPType != "v4" {
continue
}
ip, err := r.toReservedIP()
if err != nil {
return nil, err
}
all = append(all, ip)
}
if out.Meta.Links.Next == "" || out.Meta.Links.Next == cursor {
return all, nil
}
cursor = out.Meta.Links.Next
}
}
// AttachReservedIP attaches id to instanceID. It succeeds when the address is
// already attached there, which Vultr itself refuses as a 400, and reports
// ErrHolderBusy when another instance holds it.
func (c *Client) AttachReservedIP(ctx context.Context, id, instanceID string) error {
body := map[string]string{"instance_id": instanceID}
err := c.do(ctx, http.MethodPost, "/reserved-ips/"+url.PathEscape(id)+"/attach", body, nil, true)
if !errors.Is(err, errAlreadyAttached) {
return err
}
cur, gerr := c.GetReservedIP(ctx, id)
if gerr != nil {
return errors.Join(err, gerr)
}
if cur.InstanceID == instanceID {
return nil
}
return fmt.Errorf("%w: %s is attached to %s", ErrHolderBusy, id, cur.InstanceID)
}
// DetachReservedIP detaches id from whichever instance holds it. Vultr answers
// 204 for an address that is not attached, so this is idempotent.
func (c *Client) DetachReservedIP(ctx context.Context, id string) error {
return c.do(ctx, http.MethodPost, "/reserved-ips/"+url.PathEscape(id)+"/detach", nil, nil, true)
}
// DeleteReservedIP releases id. Vultr detaches an attached address itself.
func (c *Client) DeleteReservedIP(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/reserved-ips/"+url.PathEscape(id), nil, nil, true)
}
// ListInstanceVPCs returns the VPCs instanceID is attached to and its address
// in each. Terraform does not report the address, so formation reads it here.
func (c *Client) ListInstanceVPCs(ctx context.Context, instanceID string) ([]VPCAttachment, error) {
var out struct {
VPCs []struct {
ID string `json:"id"`
MACAddress string `json:"mac_address"`
IPAddress string `json:"ip_address"`
} `json:"vpcs"`
}
if err := c.do(ctx, http.MethodGet, "/instances/"+url.PathEscape(instanceID)+"/vpcs?per_page=100", nil, &out, true); err != nil {
return nil, err
}
vpcs := make([]VPCAttachment, 0, len(out.VPCs))
for _, v := range out.VPCs {
addr, err := netip.ParseAddr(v.IPAddress)
if err != nil {
return nil, fmt.Errorf("vultr: VPC %s on %s has unparseable address %q: %w", v.ID, instanceID, v.IPAddress, err)
}
vpcs = append(vpcs, VPCAttachment{VPCID: v.ID, MAC: v.MACAddress, Address: addr})
}
return vpcs, nil
}
// do runs one call, retrying throttles, server failures and a refused detach
// when retry5xx is set. A 429 is always retried, since Vultr refused it before acting.
func (c *Client) do(ctx context.Context, method, path string, in, out any, retry5xx bool) error {
var payload []byte
if in != nil {
var err error
if payload, err = json.Marshal(in); err != nil {
return fmt.Errorf("vultr: encode %s %s: %w", method, path, err)
}
}
delay := c.cfg.Backoff
var err error
for attempt := 1; ; attempt++ {
var status int
status, err = c.once(ctx, method, path, payload, out)
retryable := (errors.Is(err, ErrUnavailable) || errors.Is(err, errDetachRefused)) &&
(retry5xx || status == http.StatusTooManyRequests)
if err == nil || !retryable || attempt >= c.cfg.Attempts {
return err
}
wait := delay/2 + rand.N(delay) //#nosec G404 - backoff jitter, not a secret
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err())
case <-time.After(wait):
}
delay *= 2
}
}
// once makes a single round trip. The key is read on every call, so rotating
// it in place needs no restart. A transport failure reports status zero.
func (c *Client) once(ctx context.Context, method, path string, payload []byte, out any) (int, error) {
key, err := c.key()
if err != nil {
return 0, err
}
ctx, cancel := context.WithTimeout(ctx, c.cfg.CallTimeout)
defer cancel()
var body io.Reader
if payload != nil {
body = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+path, body)
if err != nil {
return 0, fmt.Errorf("vultr: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return 0, fmt.Errorf("%w: %s %s: %w", ErrUnavailable, method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return resp.StatusCode, fmt.Errorf("%w: read %s %s: %w", ErrUnavailable, method, path, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return resp.StatusCode, classify(resp.StatusCode, raw)
}
if out == nil || len(raw) == 0 {
return resp.StatusCode, nil
}
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("vultr: decode %s %s: %w", method, path, err)
}
return resp.StatusCode, nil
}
func (c *Client) key() (string, error) {
raw, err := os.ReadFile(c.cfg.KeyFile)
if err != nil {
return "", fmt.Errorf("vultr: read API key: %w", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
return "", fmt.Errorf("vultr: API key file %s is empty", c.cfg.KeyFile)
}
return key, nil
}
// classify maps a non-2xx response onto the sentinels callers branch on.
// Every case except the quota one is pinned by a captured response.
func classify(status int, raw []byte) error {
var body struct {
Error string `json:"error"`
}
_ = json.Unmarshal(raw, &body)
msg := body.Error
if msg == "" {
msg = strings.TrimSpace(string(raw))
}
e := &APIError{Status: status, Message: msg}
lower := strings.ToLower(msg)
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
e.kind = ErrUnauthorized
case status == http.StatusNotFound:
e.kind = ErrNotFound
case status == http.StatusTooManyRequests || status >= 500:
e.kind = ErrUnavailable
case strings.Contains(lower, "already attached"):
e.kind = errAlreadyAttached
case strings.Contains(lower, "unable to detach"):
e.kind = errDetachRefused
case strings.Contains(lower, "limit") || strings.Contains(lower, "quota") || strings.Contains(lower, "maximum"):
e.kind = ErrQuotaExceeded
}
return e
}
@@ -0,0 +1,245 @@
package vultr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// reply is one canned response, served in order for a method and path.
type reply struct {
status int
fixture string // testdata file name; empty for no body
}
type fakeAPI struct {
t *testing.T
mu sync.Mutex
replies map[string][]reply
seen []string
bodies []map[string]string
auth []string
}
func newFakeAPI(t *testing.T, replies map[string][]reply) (*fakeAPI, *Client) {
t.Helper()
f := &fakeAPI{t: t, replies: replies}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
keyFile := filepath.Join(t.TempDir(), "api-key")
require.NoError(t, os.WriteFile(keyFile, []byte("test-key\n"), 0o600))
c, err := New(Config{BaseURL: srv.URL, KeyFile: keyFile, Region: "ord", Backoff: time.Millisecond})
require.NoError(t, err)
return f, c
}
func (f *fakeAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
key := r.Method + " " + r.URL.Path
f.seen = append(f.seen, key)
f.auth = append(f.auth, r.Header.Get("Authorization"))
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
var body map[string]string
_ = json.Unmarshal(raw, &body)
f.bodies = append(f.bodies, body)
}
queue := f.replies[key]
if len(queue) == 0 {
f.t.Errorf("unexpected call %s", key)
w.WriteHeader(http.StatusTeapot)
return
}
next := queue[0]
f.replies[key] = queue[1:]
if next.fixture == "" {
w.WriteHeader(next.status)
return
}
raw, err := os.ReadFile(filepath.Join("testdata", next.fixture))
if err != nil {
f.t.Errorf("fixture %s: %v", next.fixture, err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(next.status)
_, _ = w.Write(raw)
}
const capturedID = "309a6acd-2042-4b31-883b-fbee5b64272a"
const capturedInstance = "e7d5eb7e-ca76-443c-8a59-fc450b1ed475"
func TestCreateReservedIP_SendsRegionAndLabel(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips": {{http.StatusCreated, "create.json"}},
})
ip, err := c.CreateReservedIP(context.Background(), "spinifex-capture:eipalloc-test")
require.NoError(t, err)
assert.Equal(t, capturedID, ip.ID)
assert.Equal(t, "45.76.17.211", ip.Address.String())
assert.Empty(t, ip.InstanceID)
assert.Equal(t, map[string]string{"region": "ord", "ip_type": "v4", "label": "spinifex-capture:eipalloc-test"}, f.bodies[0])
assert.Equal(t, "Bearer test-key", f.auth[0], "the key file is trimmed of its newline")
}
func TestCreateReservedIP_ServerErrorIsNotRetried(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips": {{http.StatusBadGateway, ""}},
})
_, err := c.CreateReservedIP(context.Background(), "x")
require.ErrorIs(t, err, ErrUnavailable)
assert.Len(t, f.seen, 1, "a create that may have succeeded must not be repeated")
}
func TestCreateReservedIP_ThrottleIsRetried(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips": {{http.StatusTooManyRequests, ""}, {http.StatusCreated, "create.json"}},
})
_, err := c.CreateReservedIP(context.Background(), "x")
require.NoError(t, err)
assert.Len(t, f.seen, 2)
}
func TestGetReservedIP_ReportsHolder(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"GET /reserved-ips/" + capturedID: {{http.StatusOK, "get_attached.json"}},
})
ip, err := c.GetReservedIP(context.Background(), capturedID)
require.NoError(t, err)
assert.Equal(t, capturedInstance, ip.InstanceID)
}
func TestGetReservedIP_ServerErrorRetriesThenSucceeds(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"GET /reserved-ips/" + capturedID: {{http.StatusServiceUnavailable, ""}, {http.StatusOK, "get.json"}},
})
_, err := c.GetReservedIP(context.Background(), capturedID)
require.NoError(t, err)
assert.Len(t, f.seen, 2)
}
func TestListReservedIPs_FiltersOtherRegions(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"GET /reserved-ips": {{http.StatusOK, "list.json"}},
})
ips, err := c.ListReservedIPs(context.Background())
require.NoError(t, err)
require.Len(t, ips, 1)
_, c2 := newFakeAPI(t, map[string][]reply{
"GET /reserved-ips": {{http.StatusOK, "list.json"}},
})
c2.cfg.Region = "ewr"
ips, err = c2.ListReservedIPs(context.Background())
require.NoError(t, err)
assert.Empty(t, ips, "an address in another region is not this pool's")
}
func TestAttachReservedIP_AlreadyAttachedHereIsSuccess(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/attach": {{http.StatusBadRequest, "attach_already_attached.json"}},
"GET /reserved-ips/" + capturedID: {{http.StatusOK, "get_attached.json"}},
})
require.NoError(t, c.AttachReservedIP(context.Background(), capturedID, capturedInstance))
}
func TestAttachReservedIP_AttachedElsewhereIsHolderBusy(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/attach": {{http.StatusBadRequest, "attach_already_attached.json"}},
"GET /reserved-ips/" + capturedID: {{http.StatusOK, "get_attached.json"}},
})
err := c.AttachReservedIP(context.Background(), capturedID, "another-instance")
require.ErrorIs(t, err, ErrHolderBusy)
}
func TestClassify_CapturedResponses(t *testing.T) {
cases := []struct {
fixture string
status int
want error
}{
{"get_missing.json", http.StatusNotFound, ErrNotFound},
{"delete_missing.json", http.StatusNotFound, ErrNotFound},
{"attach_missing_instance.json", http.StatusNotFound, ErrNotFound},
{"unauthorized.json", http.StatusUnauthorized, ErrUnauthorized},
{"attach_already_attached.json", http.StatusBadRequest, errAlreadyAttached},
{"delete_attached_refused.json", http.StatusBadRequest, errDetachRefused},
}
for _, tc := range cases {
t.Run(tc.fixture, func(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", tc.fixture))
require.NoError(t, err)
err = classify(tc.status, raw)
require.ErrorIs(t, err, tc.want)
var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.NotEmpty(t, apiErr.Message)
})
}
}
func TestClassify_IPAllowlistRefusalIsUnauthorized(t *testing.T) {
err := classify(http.StatusUnauthorized, []byte(`{"error":"Unauthorized IP address: 107.191.49.7","status":401}`))
require.ErrorIs(t, err, ErrUnauthorized)
assert.Contains(t, err.Error(), "Unauthorized IP address", "the operator needs Vultr's reason to fix the allowlist")
}
func TestClassify_BadRegionIsNotQuota(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "create_bad_region.json"))
require.NoError(t, err)
err = classify(http.StatusBadRequest, raw)
assert.NotErrorIs(t, err, ErrQuotaExceeded)
assert.NotErrorIs(t, err, ErrUnavailable)
}
func TestListInstanceVPCs(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"GET /instances/" + capturedInstance + "/vpcs": {{http.StatusOK, "vpcs.json"}},
})
vpcs, err := c.ListInstanceVPCs(context.Background(), capturedInstance)
require.NoError(t, err)
require.Len(t, vpcs, 1)
assert.Equal(t, "10.10.8.3", vpcs[0].Address.String())
assert.Equal(t, "5a:00:06:f6:13:d0", vpcs[0].MAC)
}
func TestKeyRereadOnEveryCall(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/detach": {{http.StatusNoContent, ""}, {http.StatusNoContent, ""}},
})
require.NoError(t, c.DetachReservedIP(context.Background(), capturedID))
require.NoError(t, os.WriteFile(c.cfg.KeyFile, []byte("rotated"), 0o600))
require.NoError(t, c.DetachReservedIP(context.Background(), capturedID))
assert.Equal(t, []string{"Bearer test-key", "Bearer rotated"}, f.auth)
}
func TestEmptyKeyFileFailsBeforeCalling(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{})
require.NoError(t, os.WriteFile(c.cfg.KeyFile, []byte("\n"), 0o600))
err := c.DeleteReservedIP(context.Background(), capturedID)
require.Error(t, err)
assert.Empty(t, f.seen)
}
// Vultr has refused a detach with "Unable to detach subnet" and accepted the
// same detach moments later, so the refusal is retried rather than surfaced.
func TestDetachAndDeleteRetryARefusedDetach(t *testing.T) {
f, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/detach": {{http.StatusBadRequest, "delete_attached_refused.json"}, {http.StatusNoContent, ""}},
"DELETE /reserved-ips/" + capturedID: {{http.StatusBadRequest, "delete_attached_refused.json"}, {http.StatusNoContent, ""}},
})
require.NoError(t, c.DetachReservedIP(context.Background(), capturedID))
require.NoError(t, c.DeleteReservedIP(context.Background(), capturedID))
assert.Len(t, f.seen, 4)
}
+196
View File
@@ -0,0 +1,196 @@
package vultr
import (
"context"
"fmt"
"net/netip"
"slices"
"sync"
)
// Fake is an in-memory stand-in for Client with the semantics measured against
// the live API: Vultr picks the address, an address has at most one holder,
// re-attaching to the holder succeeds, detaching an unattached address
// succeeds, and deleting an attached address detaches it unless
// RefuseDeleteAttached is set.
type Fake struct {
mu sync.Mutex
region string
quota int
next netip.Addr
seq int
ips map[string]ReservedIP
instances map[string]struct{}
// Fail, when set for an operation name ("create", "get", "list", "attach",
// "detach", "delete"), makes that operation return the error once.
Fail map[string]error
// Calls records operation names in order.
Calls []string
// RefuseDeleteAttached makes deleting an attached address fail with 400
// and detach it, as the live API has been measured to do intermittently.
RefuseDeleteAttached bool
}
// NewFake returns a fake for region with the given quota; zero is unlimited.
// instances are the hosts an address may be attached to.
func NewFake(region string, quota int, instances ...string) *Fake {
f := &Fake{
region: region,
quota: quota,
next: netip.MustParseAddr("45.76.17.1"),
ips: map[string]ReservedIP{},
instances: map[string]struct{}{},
Fail: map[string]error{},
}
for _, id := range instances {
f.instances[id] = struct{}{}
}
return f
}
// Seed adds an address the caller did not create, such as an operator's own.
func (f *Fake) Seed(r ReservedIP) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Region == "" {
r.Region = f.region
}
f.ips[r.ID] = r
}
// IDs returns every address ID, sorted.
func (f *Fake) IDs() []string {
f.mu.Lock()
defer f.mu.Unlock()
ids := make([]string, 0, len(f.ips))
for id := range f.ips {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
// HolderOf reports which instance holds id; empty when unattached or missing.
func (f *Fake) HolderOf(id string) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.ips[id].InstanceID
}
// fail must be called with f.mu held.
func (f *Fake) fail(op string) error {
f.Calls = append(f.Calls, op)
if err, ok := f.Fail[op]; ok {
delete(f.Fail, op)
return err
}
return nil
}
func notFound(what, id string) error {
return &APIError{Status: 404, Message: fmt.Sprintf("invalid %s %s", what, id), kind: ErrNotFound}
}
func (f *Fake) CreateReservedIP(_ context.Context, label string) (ReservedIP, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("create"); err != nil {
return ReservedIP{}, err
}
if f.quota > 0 && len(f.ips) >= f.quota {
return ReservedIP{}, &APIError{Status: 400, Message: "reserved IP limit reached", kind: ErrQuotaExceeded}
}
f.seq++
r := ReservedIP{ID: fmt.Sprintf("fake-rip-%04d", f.seq), Region: f.region, Label: label, Address: f.next}
f.next = f.next.Next()
f.ips[r.ID] = r
return r, nil
}
func (f *Fake) GetReservedIP(_ context.Context, id string) (ReservedIP, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("get"); err != nil {
return ReservedIP{}, err
}
r, ok := f.ips[id]
if !ok {
return ReservedIP{}, notFound("reserved-ip", id)
}
return r, nil
}
func (f *Fake) ListReservedIPs(_ context.Context) ([]ReservedIP, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("list"); err != nil {
return nil, err
}
out := make([]ReservedIP, 0, len(f.ips))
for _, r := range f.ips {
if r.Region == f.region {
out = append(out, r)
}
}
slices.SortFunc(out, func(a, b ReservedIP) int { return a.Address.Compare(b.Address) })
return out, nil
}
func (f *Fake) AttachReservedIP(_ context.Context, id, instanceID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("attach"); err != nil {
return err
}
r, ok := f.ips[id]
if !ok {
return notFound("reserved-ip", id)
}
if _, ok := f.instances[instanceID]; !ok {
return notFound("instance-id", instanceID)
}
switch r.InstanceID {
case instanceID:
return nil
case "":
r.InstanceID = instanceID
f.ips[id] = r
return nil
}
return fmt.Errorf("%w: %s is attached to %s", ErrHolderBusy, id, r.InstanceID)
}
func (f *Fake) DetachReservedIP(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("detach"); err != nil {
return err
}
r, ok := f.ips[id]
if !ok {
return notFound("reserved-ip", id)
}
r.InstanceID = ""
f.ips[id] = r
return nil
}
func (f *Fake) DeleteReservedIP(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.fail("delete"); err != nil {
return err
}
r, ok := f.ips[id]
if !ok {
return notFound("reserved-ip", id)
}
if f.RefuseDeleteAttached && r.InstanceID != "" {
r.InstanceID = ""
f.ips[id] = r
return &APIError{Status: 400, Message: "Unable to detach subnet, please restart your server from the control panel"}
}
delete(f.ips, id)
return nil
}
+117
View File
@@ -0,0 +1,117 @@
package vultr
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The fake must agree with the client on every outcome the client's captured
// responses pin, or engine tests built on it prove nothing about Vultr.
func TestFakeFidelity_ReattachToHolderSucceeds(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/attach": {{http.StatusBadRequest, "attach_already_attached.json"}},
"GET /reserved-ips/" + capturedID: {{http.StatusOK, "get_attached.json"}},
})
require.NoError(t, c.AttachReservedIP(context.Background(), capturedID, capturedInstance))
f := NewFake("ord", 0, capturedInstance)
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
require.NoError(t, f.AttachReservedIP(context.Background(), r.ID, capturedInstance))
require.NoError(t, f.AttachReservedIP(context.Background(), r.ID, capturedInstance))
}
func TestFakeFidelity_AttachElsewhereIsHolderBusy(t *testing.T) {
f := NewFake("ord", 0, "a", "b")
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
require.NoError(t, f.AttachReservedIP(context.Background(), r.ID, "a"))
require.ErrorIs(t, f.AttachReservedIP(context.Background(), r.ID, "b"), ErrHolderBusy)
}
func TestFakeFidelity_DeleteAttachedSucceeds(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"DELETE /reserved-ips/" + capturedID: {{http.StatusNoContent, ""}},
})
require.NoError(t, c.DeleteReservedIP(context.Background(), capturedID))
f := NewFake("ord", 0, "a")
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
require.NoError(t, f.AttachReservedIP(context.Background(), r.ID, "a"))
require.NoError(t, f.DeleteReservedIP(context.Background(), r.ID))
assert.Empty(t, f.IDs())
}
func TestFakeFidelity_DeleteAttachedRefusedDetaches(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"DELETE /reserved-ips/" + capturedID: {
{http.StatusBadRequest, "delete_attached_refused.json"},
{http.StatusBadRequest, "delete_attached_refused.json"},
{http.StatusBadRequest, "delete_attached_refused.json"},
},
})
err := c.DeleteReservedIP(context.Background(), capturedID)
require.ErrorIs(t, err, errDetachRefused, "a refusal that outlasts the retries is still an error")
f := NewFake("ord", 0, "a")
f.RefuseDeleteAttached = true
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
require.NoError(t, f.AttachReservedIP(context.Background(), r.ID, "a"))
require.Error(t, f.DeleteReservedIP(context.Background(), r.ID))
assert.Empty(t, f.HolderOf(r.ID), "the refused delete still detached the address")
require.NoError(t, f.DeleteReservedIP(context.Background(), r.ID))
}
func TestFakeFidelity_DetachUnattachedSucceeds(t *testing.T) {
_, c := newFakeAPI(t, map[string][]reply{
"POST /reserved-ips/" + capturedID + "/detach": {{http.StatusNoContent, ""}},
})
require.NoError(t, c.DetachReservedIP(context.Background(), capturedID))
f := NewFake("ord", 0)
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
require.NoError(t, f.DetachReservedIP(context.Background(), r.ID))
}
func TestFakeFidelity_MissingIsNotFound(t *testing.T) {
const missing = "00000000-0000-0000-0000-000000000000"
_, c := newFakeAPI(t, map[string][]reply{
"GET /reserved-ips/" + missing: {{http.StatusNotFound, "get_missing.json"}},
"DELETE /reserved-ips/" + missing: {{http.StatusNotFound, "delete_missing.json"}},
"POST /reserved-ips/" + missing + "/detach": {{http.StatusNotFound, "delete_missing.json"}},
"POST /reserved-ips/" + capturedID + "/attach": {{http.StatusNotFound, "attach_missing_instance.json"}},
})
f := NewFake("ord", 0)
r, err := f.CreateReservedIP(context.Background(), "l")
require.NoError(t, err)
_, err = c.GetReservedIP(context.Background(), missing)
require.ErrorIs(t, err, ErrNotFound)
_, err = f.GetReservedIP(context.Background(), missing)
require.ErrorIs(t, err, ErrNotFound)
require.ErrorIs(t, c.DeleteReservedIP(context.Background(), missing), ErrNotFound)
require.ErrorIs(t, f.DeleteReservedIP(context.Background(), missing), ErrNotFound)
require.ErrorIs(t, c.DetachReservedIP(context.Background(), missing), ErrNotFound)
require.ErrorIs(t, f.DetachReservedIP(context.Background(), missing), ErrNotFound)
require.ErrorIs(t, c.AttachReservedIP(context.Background(), capturedID, missing), ErrNotFound)
require.ErrorIs(t, f.AttachReservedIP(context.Background(), r.ID, missing), ErrNotFound)
}
func TestFake_QuotaIsQuotaExceeded(t *testing.T) {
f := NewFake("ord", 1)
_, err := f.CreateReservedIP(context.Background(), "a")
require.NoError(t, err)
_, err = f.CreateReservedIP(context.Background(), "b")
require.ErrorIs(t, err, ErrQuotaExceeded)
}
@@ -0,0 +1 @@
{"error":"IP is already attached to a server","status":400}
@@ -0,0 +1 @@
{"error":"Invalid instance-id.","status":404}
+1
View File
@@ -0,0 +1 @@
{"reserved_ip":{"id":"309a6acd-2042-4b31-883b-fbee5b64272a","region":"ord","ip_type":"v4","subnet":"45.76.17.211","subnet_size":32,"label":"spinifex-capture:eipalloc-test","instance_id":""}}
@@ -0,0 +1 @@
{"error":"Invalid region","status":400}
@@ -0,0 +1 @@
{"error":"Unable to detach subnet, please restart your server from the control panel","status":400}
@@ -0,0 +1 @@
{"error":"Invalid reserved-ip ID.","status":404}
+1
View File
@@ -0,0 +1 @@
{"reserved_ip":{"id":"309a6acd-2042-4b31-883b-fbee5b64272a","region":"ord","ip_type":"v4","subnet":"45.76.17.211","subnet_size":32,"label":"spinifex-capture:eipalloc-test","instance_id":"","instance_label":"","pending_charges":0.11}}
@@ -0,0 +1 @@
{"reserved_ip":{"id":"309a6acd-2042-4b31-883b-fbee5b64272a","region":"ord","ip_type":"v4","subnet":"45.76.17.211","subnet_size":32,"label":"spinifex-capture:eipalloc-test","instance_id":"e7d5eb7e-ca76-443c-8a59-fc450b1ed475","instance_label":"spinifex-node1","pending_charges":0.11}}
@@ -0,0 +1 @@
{"is_external_error":false,"error":"reserved ip not found","status":404}
+1
View File
@@ -0,0 +1 @@
{"reserved_ips":[{"id":"309a6acd-2042-4b31-883b-fbee5b64272a","region":"ord","ip_type":"v4","subnet":"45.76.17.211","subnet_size":32,"label":"spinifex-capture:eipalloc-test","instance_id":"","instance_label":""}],"meta":{"total":1,"links":{"next":"","prev":""}}}
@@ -0,0 +1 @@
{"error":"Invalid API token.","status":401}
+1
View File
@@ -0,0 +1 @@
{"vpcs":[{"id":"d92516f0-5ea1-41b6-bd6d-54f17bb57900","mac_address":"5a:00:06:f6:13:d0","ip_address":"10.10.8.3"}],"meta":{"total":1,"links":{"next":"","prev":""}}}
+127 -24
View File
@@ -70,6 +70,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/exonet"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/floating"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/ocinet"
"github.com/mulgadc/spinifex/spinifex/domains/network/host"
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
@@ -186,6 +187,9 @@ type Daemon struct {
routeTableService *ec2routetable.RouteTableServiceImpl
natGatewayService *ec2natgw.NatGatewayServiceImpl
externalIPAM *ec2vpc.ExternalIPAM
// floatingAllocators is every floating-address pool (source="vultr") on
// this node, written once during startup like ociAllocators.
floatingAllocators []floatingPool
// ociAllocators is every source="oci" pool on this node. Written once during
// startup, before anything reads it.
ociAllocators []ociPool
@@ -1514,6 +1518,11 @@ func (d *Daemon) externalPoolConfigs() (pools []external.ExternalPoolConfig, any
ExoscaleConfigFile: p.ExoscaleConfigFile,
ExoscaleAccount: p.ExoscaleAccount,
ExoscaleBinary: p.ExoscaleBinary,
VultrRegion: p.VultrRegion,
VultrKeyFile: p.VultrKeyFile,
LabelPrefix: p.LabelPrefix,
ProviderHostID: d.clusterConfig.Nodes[d.clusterConfig.Node].ProviderHostID,
})
if p.Source == "dhcp" {
anyDHCP = true
@@ -1559,7 +1568,6 @@ func (d *Daemon) installOCIAllocators(ipam *ec2vpc.ExternalIPAM, js jetstream.Je
}
}
if len(d.ociAllocators) > 0 {
go d.runOCIAffinityLoop()
go d.runOCIReconcileLoop()
}
return nil
@@ -1590,6 +1598,67 @@ func (d *Daemon) installExoscaleAllocators(ipam *ec2vpc.ExternalIPAM, js jetstre
return nil
}
// installFloatingAllocators builds one allocator per floating-address pool and
// runs its first reconcile. That pass only marks unbound addresses; the
// periodic one collects them, since another node may be mid-allocation.
func (d *Daemon) installFloatingAllocators(ipam *ec2vpc.ExternalIPAM, js jetstream.JetStream) error {
for _, p := range ipam.PoolsWithSource(external.SourceVultr) {
alloc, err := floating.FromPoolConfig(d.ctx, js, p, d.ovnSBAddr())
if err != nil {
return fmt.Errorf("build floating allocator for pool %q: %w", p.Name, err)
}
if err := ipam.InstallAllocator(p.Name, alloc); err != nil {
return err
}
pool := floatingPool{name: p.Name, alloc: alloc}
d.floatingAllocators = append(d.floatingAllocators, pool)
pool.reconcile(d.ctx)
}
if len(d.floatingAllocators) > 0 {
go d.runFloatingReconcileLoop()
}
return nil
}
// floatingPool pairs a floating pool's allocator with its name.
type floatingPool struct {
name string
alloc *floating.PoolAllocator
}
// reconcile runs one pass and logs its outcome. A failure leaks money and
// quota, not reachability, so it is logged and the next pass retries.
func (p floatingPool) reconcile(ctx context.Context) {
res, err := p.alloc.Reconcile(ctx)
switch {
case err != nil:
slog.Error("floating allocator reconcile failed; leaked addresses may be billing",
"pool", p.name, "collected", len(res.Collected), "err", err)
case len(res.Collected) > 0 || len(res.Missing) > 0:
slog.Info("floating allocator reconcile", "pool", p.name,
"collected", len(res.Collected), "suspect", res.Suspect, "missing", len(res.Missing), "skipped", res.Skipped)
default:
slog.Debug("floating allocator reconcile clean", "pool", p.name, "suspect", res.Suspect, "skipped", res.Skipped)
}
}
// runFloatingReconcileLoop repeats the reconcile on the OCI cadence, for the
// same reason: leaks come from releases and guests long after start.
func (d *Daemon) runFloatingReconcileLoop() {
ticker := time.NewTicker(ociReconcileInterval)
defer ticker.Stop()
for {
select {
case <-d.ctx.Done():
return
case <-ticker.C:
}
for _, p := range d.floatingAllocators {
p.reconcile(d.ctx)
}
}
}
// ociPool pairs a pool's allocator with its name, so one pass can cover every
// pool and reach a single verdict about each guest. Per-pool loops could not: a
// guest is impaired or it is not, and two loops writing that answer from half the
@@ -1610,12 +1679,20 @@ const ociAffinityInterval = 15 * time.Second
// quota, not reachability, and the pass lists a whole compartment.
const ociReconcileInterval = 10 * time.Minute
// runOCIAffinityLoop keeps OCI's idea of where an address lives in step with
// where its guest actually runs. Every node runs its own, like the host EIP
// loop and for the same reason: the question is about this host's guests, and a
// node that never wins the reconcile lease would otherwise never claim the
// addresses of the instances it is running.
func (d *Daemon) runOCIAffinityLoop() {
// startAddressAffinityLoop starts the claim pass when any cloud pool needs
// its addresses delivered to the node running their owner.
func (d *Daemon) startAddressAffinityLoop() {
if len(d.addressClaimers()) > 0 {
go d.runAddressAffinityLoop()
}
}
// runAddressAffinityLoop keeps the cloud's idea of where an address lives in
// step with where its guest actually runs. Every node runs its own, like the
// host EIP loop and for the same reason: the question is about this host's
// guests, and a node that never wins the reconcile lease would otherwise never
// claim the addresses of the instances it is running.
func (d *Daemon) runAddressAffinityLoop() {
ticker := time.NewTicker(ociAffinityInterval)
defer ticker.Stop()
for {
@@ -1624,7 +1701,7 @@ func (d *Daemon) runOCIAffinityLoop() {
return
case <-ticker.C:
}
d.ClaimOCIAddresses(d.ctx)
d.ClaimCloudAddresses(d.ctx)
}
}
@@ -1693,39 +1770,61 @@ func (p ociPool) retryStartupReconcile(ctx context.Context, initialBackoff, maxB
})
}
// ClaimOCIAddresses runs one affinity pass over every OCI pool and records which
// guests OCI is not delivering an address to.
// namedClaimer is one cloud pool the affinity pass drives, with the provider
// it names in logs.
type namedClaimer struct {
name string
provider string
claimer external.AddressClaimer
}
// addressClaimers is every pool whose addresses follow their owner's node.
func (d *Daemon) addressClaimers() []namedClaimer {
out := make([]namedClaimer, 0, len(d.ociAllocators)+len(d.floatingAllocators))
for _, p := range d.ociAllocators {
out = append(out, namedClaimer{name: p.name, provider: "OCI", claimer: p.alloc})
}
for _, p := range d.floatingAllocators {
out = append(out, namedClaimer{name: p.name, provider: p.alloc.Pool().Source, claimer: p.alloc})
}
return out
}
// ClaimCloudAddresses runs one affinity pass over every cloud pool and records
// which guests the cloud is not delivering an address to. One pass over all of
// them, because a guest is impaired or it is not, and per-pool passes writing
// that answer from half the evidence would take turns contradicting each other.
//
// Exported because the recovery path calls it in line: a guest relaunched here is
// reported running the moment it boots, and until this has run OCI still delivers
// its address to the node that died. Waiting for the ticker would mean up to a
// full interval in which the records say recovered and the address says nothing.
// reported running the moment it boots, and until this has run the cloud still
// delivers its address to the node that died.
//
// A pass that could not read its own inputs marks nothing. It learned that no
// guest is reachable, not that every guest is, and clearing on that would turn an
// OCI outage into a clean bill of health.
func (d *Daemon) ClaimOCIAddresses(ctx context.Context) {
if len(d.ociAllocators) == 0 {
// guest is reachable, not that every guest is, and clearing on that would turn a
// cloud outage into a clean bill of health.
func (d *Daemon) ClaimCloudAddresses(ctx context.Context) {
claimers := d.addressClaimers()
if len(claimers) == 0 {
return
}
complete := true
stalled := make(map[string]string)
for _, p := range d.ociAllocators {
res, err := p.alloc.ClaimLocalAddresses(ctx)
for _, p := range claimers {
res, err := p.claimer.ClaimLocalAddresses(ctx)
if err != nil {
slog.Error("OCI address affinity pass failed; a guest here may be unreachable on its public address",
"pool", p.name, "err", err)
slog.Error("Cloud address affinity pass failed; a guest here may be unreachable on its public address",
"provider", p.provider, "pool", p.name, "err", err)
complete = false
continue
}
if len(res.Claimed) > 0 {
slog.Info("OCI addresses moved to this node", "pool", p.name,
slog.Info("Cloud addresses moved to this node", "provider", p.provider, "pool", p.name,
"claimed", res.Claimed, "local_bindings", res.Local)
}
for _, s := range res.Stalled {
slog.Error("OCI is not delivering an address to the node running its guest",
"pool", p.name, "public_ip", s.PublicIP, "eni_id", s.ENIID, "reason", s.Reason)
slog.Error("Cloud is not delivering an address to the node running its guest",
"provider", p.provider, "pool", p.name, "public_ip", s.PublicIP, "eni_id", s.ENIID, "reason", s.Reason)
if s.ENIID != "" {
stalled[s.ENIID] = s.Reason
}
@@ -2004,6 +2103,10 @@ func (d *Daemon) startCluster() error {
if exoErr := d.installExoscaleAllocators(ipam, js); exoErr != nil {
return nil, exoErr
}
if fErr := d.installFloatingAllocators(ipam, js); fErr != nil {
return nil, fErr
}
d.startAddressAffinityLoop()
return ipam, nil
})
if err != nil {
@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"github.com/aws/aws-sdk-go/aws"
"github.com/nats-io/nats.go"
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
@@ -34,6 +35,15 @@ func (d *Daemon) handleENIPublicIPChanged(msg *nats.Msg) string {
}
v.PublicIP = evt.PublicIP
v.PublicIPPool = evt.PoolName
// Describe falls back to the launch stamp when PublicIP is empty, and an
// association released that address, so the stamp must follow too.
if v.Instance != nil {
v.Instance.PublicIpAddress = nil
v.Instance.PublicDnsName = nil
if evt.PublicIP != "" {
v.Instance.PublicIpAddress = aws.String(evt.PublicIP)
}
}
return true
})
if err != nil {
@@ -0,0 +1,47 @@
package daemon
import (
"encoding/json"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
)
func publicIPChanged(t *testing.T, d *Daemon, instanceID, publicIP string) {
t.Helper()
data, err := json.Marshal(ec2eip.ENIPublicIPChanged{InstanceID: instanceID, ENIID: "eni-1", PublicIP: publicIP, PoolName: "p"})
require.NoError(t, err)
require.Equal(t, outcomeSuccess, d.handleENIPublicIPChanged(&nats.Msg{Data: data}))
}
func describedPublicIP(t *testing.T, d *Daemon, instanceID string) string {
t.Helper()
v, ok := d.vmMgr.Get(instanceID)
require.True(t, ok)
inst, _ := ec2instance.ProjectInstance(v, ec2instance.InstanceProjection{IncludeRuntimeNetwork: true})
return aws.StringValue(inst.PublicIpAddress)
}
// Associating an EIP releases the launch address, so after a disassociate the
// instance must not go back to reporting it: that address is no longer ours.
func TestENIPublicIPChanged_DisassociateDoesNotRevealReleasedLaunchAddress(t *testing.T) {
d := createDaemonWithJetStream(t)
d.vmMgr.Insert(&vm.VM{
ID: "i-eip", Status: vm.StateRunning, PublicIP: "198.51.100.7",
Instance: &ec2.Instance{InstanceId: aws.String("i-eip"), PublicIpAddress: aws.String("198.51.100.7")},
})
publicIPChanged(t, d, "i-eip", "203.0.113.9")
assert.Equal(t, "203.0.113.9", describedPublicIP(t, d, "i-eip"))
publicIPChanged(t, d, "i-eip", "")
assert.Empty(t, describedPublicIP(t, d, "i-eip"))
}
+1 -1
View File
@@ -474,7 +474,7 @@ func (r *instanceRecovery) attempt(ctx context.Context, candidate recoveryCandid
// recovered instance and a reachable one. It runs in line rather than waiting
// for the affinity ticker, and a guest whose address does not arrive is
// marked impaired by the pass rather than reported healthy.
r.daemon.ClaimOCIAddresses(ctx)
r.daemon.ClaimCloudAddresses(ctx)
slog.InfoContext(ctx, "Recovered an instance onto this node",
"instanceId", id, "from", candidate.from, "node", r.daemon.node)
@@ -42,3 +42,27 @@ func TestSelectExternalPools(t *testing.T) {
assert.Nil(t, public)
})
}
// Vultr bare metal ARPs for a reserved IP, so a Vultr pool must name an
// uplink even though it has neither a gateway nor a bind bridge.
func TestEIPUplinkLocator(t *testing.T) {
cases := []struct {
name string
pool external.ExternalPoolConfig
gateway string
uplinkHint string
}{
{"static pool routes toward its gateway", external.ExternalPoolConfig{Source: external.SourceStatic, Gateway: "192.168.1.1"}, "192.168.1.1", ""},
{"dhcp pool names its bind bridge", external.ExternalPoolConfig{Source: external.SourceDHCP, BindBridge: "br-wan"}, "", "br-wan"},
{"vultr pool follows the internet route", external.ExternalPoolConfig{Source: external.SourceVultr}, host.InternetRouteProbe, ""},
{"exoscale pool gets no proxy-ARP", external.ExternalPoolConfig{Source: external.SourceExoscale}, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gw, hint := eipUplinkLocator(&tc.pool)
if gw != tc.gateway || hint != tc.uplinkHint {
t.Fatalf("eipUplinkLocator() = (%q, %q), want (%q, %q)", gw, hint, tc.gateway, tc.uplinkHint)
}
})
}
}
+53 -3
View File
@@ -25,6 +25,7 @@ import (
ec2igw "github.com/mulgadc/spinifex/spinifex/domains/ec2/igw"
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/floating"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/ocinet"
"github.com/mulgadc/spinifex/spinifex/domains/network/host"
"github.com/mulgadc/spinifex/spinifex/domains/network/ovn"
@@ -567,6 +568,10 @@ func launchService(cfg *Config) error {
slog.Info("vpcd: OCI address pairs in use; NAT rules and host routes follow the private half",
"pools", ociPools)
}
if fPools := floatingPoolNames(cfg.ExternalPools); len(fPools) > 0 {
binder := floating.NewGatewayBinder(floating.NewKVStore(js), fPools)
bindGatewayVPC = chainGatewayBinders(bindGatewayVPC, binder.BindGateway)
}
natMgr, err := policy.NewNATManager(liveClient, natMode, natOpts...)
if err != nil {
return fmt.Errorf("construct NAT manager: %w", err)
@@ -1017,6 +1022,41 @@ func selectExternalPools(externalMode string, pools []external.ExternalPoolConfi
return igwPool, publicPool
}
// floatingPoolNames lists the pools whose addresses are floating objects moved
// between hosts, whose NAT gateway addresses must learn their VPC.
func floatingPoolNames(pools []external.ExternalPoolConfig) []string {
var names []string
for _, p := range pools {
if p.IsVultr() {
names = append(names, p.Name)
}
}
return names
}
// chainGatewayBinders calls each non-nil binder, so OCI and floating pools in
// one cluster both learn a NAT gateway's VPC.
func chainGatewayBinders(binders ...func(ctx context.Context, externalIP, vpcID string) error) func(ctx context.Context, externalIP, vpcID string) error {
var live []func(ctx context.Context, externalIP, vpcID string) error
for _, b := range binders {
if b != nil {
live = append(live, b)
}
}
if len(live) == 0 {
return nil
}
return func(ctx context.Context, externalIP, vpcID string) error {
var errs []error
for _, b := range live {
if err := b(ctx, externalIP, vpcID); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
}
// ociPoolNames lists the pools whose addresses are OCI pairs, and so need the
// public half mapped to a private one before it reaches OVN or a host route.
func ociPoolNames(pools []external.ExternalPoolConfig) []string {
@@ -1029,13 +1069,23 @@ func ociPoolNames(pools []external.ExternalPoolConfig) []string {
return names
}
// eipUplinkLocator returns what EnsureEIPIngress resolves the proxy-ARP uplink
// from. Vultr bare metal ARPs for a reserved IP on the public segment, so a
// Vultr pool uses the interface the node routes the internet through.
func eipUplinkLocator(pool *external.ExternalPoolConfig) (gateway, uplinkHint string) {
if pool.IsVultr() {
return host.InternetRouteProbe, ""
}
return pool.Gateway, pool.BindBridge
}
// hostEIPBinder builds the routed-mode host plumbing hooks for EIPs on the
// public pool: /32 route into OVN plus proxy-ARP on the uplink. Static pools
// locate the uplink via the pool gateway; dhcp pools via their bind bridge.
// public pool: /32 route into OVN plus proxy-ARP on the uplink, which
// eipUplinkLocator says how to find.
func hostEIPBinder(pool *external.ExternalPoolConfig, sbAddr string) policy.HostEIPBinder {
runner := host.NewExecRunner()
prober := host.NewGatewayClaimProber(sbAddr)
gateway, uplinkHint := pool.Gateway, pool.BindBridge
gateway, uplinkHint := eipUplinkLocator(pool)
return policy.HostEIPBinder{
Bind: func(eip policy.EIPSpec, gwLrpIP string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+3 -3
View File
@@ -676,9 +676,9 @@ func phaseEIPIngress(t *testing.T, fix *fixture, def harness.VPCInfo, probe egre
return eipHostPlumbingGone(eip)
}, 2*time.Minute, 3*time.Second)
// The auto-assigned address went back to the pool at associate time, so
// disassociating leaves the instance with no public delivery at all. It does
// not come back — matching EC2, where a stop/start is what issues a new one.
// The auto-assigned address went back to the pool at associate time and does
// not come back. EC2 assigns a new public IPv4 on disassociate; Spinifex does
// not yet, so this asserts the current divergence, not parity.
harness.Step(t, "no public delivery remains after the EIP teardown")
require.NoError(t, eipHostPlumbingGone(probe.publicIP))
}