mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-30 16:06:22 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd9bea1ca7 | |||
| f810b07e1e | |||
| 37ecadea6c | |||
| 1a6d5a8e2a | |||
| b5d16a0173 | |||
| e5b901cf11 | |||
| 27ae2e5868 | |||
| 81cd67b12d | |||
| d4eb7e3029 | |||
| f799f9137a | |||
| 11230b2702 | |||
| 30d6767339 | |||
| bf78b0e9ac | |||
| c6a9d46a26 | |||
| d623f0eb0e | |||
| 6d35140f75 | |||
| 00ee6eb244 | |||
| 941c7c3fb0 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+484
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers { vultr = { source = "vultr/vultr", version = "~> 2.33" } }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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":""}}}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user