Compare commits

...

17 Commits

Author SHA1 Message Date
joshsiv-mulga 076d1793b2 fix(latitude): stage and verify the CA before replacing it, treat a fresh pid-less lock as live 2026-11-03 17:42:10 +10:00
joshsiv-mulga 59dcb27d27 fix(latitude): refuse any existing AWS profile section, lock mutating commands 2026-11-03 17:41:01 +10:00
joshsiv-mulga c2b7d63a6f fix(latitude): sticky uncertain record, per-name profile guard, empty-page pagination, state-bound destroy preview 2026-11-03 17:37:11 +10:00
joshsiv-mulga 18acef43dd fix(latitude): tty console gate, per-up reconfirm, owned-set destroy preview, link pagination 2026-11-03 17:33:14 +10:00
joshsiv-mulga 0995f0a6c0 fix(latitude): console gate, owned-ID destroy, effective ssh rule gate, no secrets in argv 2026-11-03 17:29:02 +10:00
joshsiv-mulga a65116d827 feat(latitude): add deploy-latitude.sh for one-node Spinifex on latitude.sh 2026-11-03 17:22:08 +10:00
joshsiv-mulga 7bb8c79792 fix(latitude): firewall needs existing install state, verify rechecks mode and table last and reports an interval 2026-11-03 16:38:46 +10:00
joshsiv-mulga 4e5ff95027 fix(latitude): read-only firewall pre-checks, recheck baseline after daemon stop, no mtime proof in verify 2026-11-03 16:37:33 +10:00
joshsiv-mulga cbf6307012 fix(latitude): console confirm and stale-table baseline before arming, read-only verify with effective config 2026-11-03 16:35:42 +10:00
joshsiv-mulga a58e7bd918 feat(latitude): firewall and verify phases, gateway_ip on the public pool, git in prep 2026-11-03 16:30:35 +10:00
joshsiv-mulga 061cc96b82 fix: require OVS/OVN units and accept only an absent or false firewall_enabled in the latitude installer 2026-11-03 14:33:35 +10:00
joshsiv-mulga 00bbaa8998 fix(latitude): check nat transit wiring, validate config after edit, require core units active 2026-11-03 14:30:25 +10:00
joshsiv-mulga 40a7bffbe6 fix(latitude): isolate aws calls, parse config on resume, park firewall phases, prove firewall absent by nft inventory 2026-11-03 14:26:35 +10:00
joshsiv-mulga 7abe8e39a6 fix(latitude): verify routes, gateway and OVN state on every ovn run, fail closed on firewall state 2026-11-03 14:25:06 +10:00
joshsiv-mulga 108b27e326 refactor(latitude): run init and config in the start phase, after setup-ovn, as vultr does 2026-11-03 14:19:57 +10:00
joshsiv-mulga 3c578f474f feat(latitude): split install-node.sh into phases, never arm the firewall during install 2026-11-03 14:19:01 +10:00
joshsiv-mulga bd8f148879 feat(latitude): add single-node on-host install script for latitude.sh bare metal 2026-11-03 14:07:06 +10:00
12 changed files with 2328 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.terraform/
terraform.tfstate
terraform.tfstate.*
*.tfplan
*.auto.tfvars
crash.log
terraform.tfvars
*.pem
*.log
.terraform.lock.hcl
+70
View File
@@ -0,0 +1,70 @@
# latitude-spx
Builds a one-node Spinifex deployment on latitude.sh bare metal, from your laptop, with `deploy-latitude.sh`. The shape follows `vultr-spx`.
**Status: not yet run end to end.** The script was written from the plan doc findings (`docs/development/feature/latitude-bm.md`, L1 to L18) and from the provider schema. No latitude.sh API call or host run was made while building it. Treat the first `up` as the test.
## What it creates
| Resource | Notes |
|---|---|
| Project | `spinifex-<name>`, unless `--project` names an existing one (then it is never deleted) |
| SSH key | `spinifex-<name>`, account scoped |
| User data | `spinifex-<name>`, host preparation only (no `{{ }}`, latitude.sh substitutes them) |
| Server | `m4-metal-medium`, site `DAL`, Ubuntu 26.04, **hourly** billing, hostname `spx-<name>-node1` |
| Elastic IP | one, routed to node 1; it is the Spinifex public pool address |
## Requirements
OpenTofu (or Terraform) 1.5 or later, `aws` CLI, `python3`, `curl`, `ssh`, an SSH key pair, and `LATITUDESH_AUTH_TOKEN` exported.
## Usage
```
export LATITUDESH_AUTH_TOKEN=... # from the latitude.sh dashboard
./deploy-latitude.sh up --name demo
./deploy-latitude.sh status --name demo
./deploy-latitude.sh ssh --name demo node1 'sudo systemctl status spinifex.target'
./deploy-latitude.sh credentials --name demo
./deploy-latitude.sh destroy --name demo
```
`./deploy-latitude.sh --help` lists the options. `--nodes` must be 1: three nodes need the VLAN and the Elastic IP move, which are not built.
## What `up` does
`terraform` → `host` (wait for SSH, cloud-init, `/dev/kvm`) → console check → `prep` (`resolv.conf`) → `disk` (`host-data-disk.sh`) → `install` → `ovn` → `firewall` → `start` → `verify` → `access` → AWS profile.
- **Console check.** Before the first step that changes the node, `up` prints the recovery commands, runs `passwd` for the ubuntu user on the node, and waits for you to type `console-ok` after logging in over the serial console from another window. It asks again on every `up` that still has a changing step to run.
- **Data disk.** `host-data-disk.sh` picks the blank NVMe by content and by-id EUI, never by `nvmeXn1` (L1, L11). It needs exactly one candidate of at least 100 GB with no signature, partitions, holders or mounts, or it stops and lists the disks (`--data-disk-id` chooses). It mounts by UUID with `nofail`.
- **Firewall.** `setup.sh --firewall on` runs before Spinifex first starts, and the daemon writes `peers.nft` and loads the policy during the start. The start phase refuses to go on unless the active rules allow ssh: the accept rule in `spinifex.nft`, the defined ssh ports covering every port `sshd -T` reports, and `0.0.0.0/0` trusted in `custom.nft`.
- **Proof.** `verify` and the access check run on every `up`, never skipped by a step marker. The access check opens a new ssh login, asks `https://<ip>:9999` for an answer, and confirms the firewall mode is on and the policy table is loaded.
- **State** is in `~/.spinifex/latitude/<name>/`: Terraform state, `inputs`, `owned` (IDs of what exists), `step.*` markers, logs, the data disk UUID. `up`, `destroy` and `resolve-uncertain` hold a lock in the state directory (`lock.d`, a pid; a dead pid is taken over), so two runs cannot overlap. A failed `up` resumes at the failed step; delete a `step.<name>.nodeN` file to redo it. The AWS profile is `spinifex-latitude-<name>`, refused if either AWS file already has a section for it (any profile type) unless it equals exactly what `up` would write, or `--overwrite-profile` is given; the check runs again right before writing, with the CA at `~/.spinifex/latitude-<name>-ca.pem`. The secret key is written to `~/.aws/credentials` (0600, other profiles kept, the old file and `~/.aws/config` saved beside it) and never appears in a process argument.
## Token handling
The token is read from the environment by the provider and by the script's API calls. It is not a Terraform variable, is on no command line (curl reads it from stdin), is not sent to the node, and is written to no file. `TF_LOG` is unset by the script.
## Recovery
`up` prints the recovery text before the console check and again before the firewall step. With the firewall on, a lost ssh session is recovered from the latitude.sh serial console, `ssh <server_id>@<ip> -p 2222`, using the ubuntu password set in the console check. The printed disarm stops the daemon, deletes the `spinifex_filter` table and moves `peers.nft` aside. Do not reboot the node until `up` has finished or the disarm has been used: the boot unit reloads the policy from `peers.nft`.
## destroy
`destroy` builds the set to delete once, from the `owned` file plus the IDs in the Terraform state, shows it, and asks you to type the deployment name. It deletes exactly that set: `terraform destroy` first, then the API for anything left, in the order servers, Elastic IPs, keys, user data, project. If the set changes after the preview it stops and asks you to run it again.
Objects whose names match the deployment but that were never recorded are listed as `untracked` and never deleted, and `destroy` exits 1 until you remove them by hand. If an `up` failed or was interrupted while creating (`uncertain` marker), an Elastic IP may exist that nothing can attribute: `destroy` lists everything it cannot attribute and gives no all-clear. The record is append-only; after you check the dashboard, `./deploy-latitude.sh resolve-uncertain --name <name>` clears it on a typed confirmation. The preview also lists every ID in the Terraform state that the API listing does not show (`absent`), and the state checksum is rechecked before `terraform destroy` runs. It also fails on any API error, unreadable list, unfollowed pagination or refused DELETE. Elastic IP release is asynchronous and only works once the address is active or in error, so a first sweep round may need to wait.
## Unverified
- Pagination: the API docs give only `page[size]` (default 20) and `page[number]` and no response metadata. The lists follow `links.next` if the body has one, and otherwise read page numbers until an empty page.
- The OS slug `ubuntu_26_04_x64_lts` (the `latitudesh_operating_system` data source fails the plan if it is wrong; list slugs with `GET /plans/operating_systems`).
- The DELETE paths for `/user_data/{id}` and `/projects/{id}`, and the shape of the `server` field on Elastic IP list items used by `leftovers`.
- The `systemd-networkd-wait-online --any` drop-in path and effect (it first applies on the next boot).
- That the Spinifex API certificate names the node's public address, which `write_profile` needs for `aws sts get-caller-identity` to pass.
- Terraform owning the Elastic IP `server_id` fights a Spinifex-managed move; fine for one node, not for three.
## Not exercised
Any real API call, `up`, `destroy`, `host-data-disk.sh` on a host, the console check, the `--armed` start of `install-node.sh`, and the ssh-rule gate against a real `setup.sh` policy. Guest launch, public reachability of the Elastic IP, the Terraform workbooks and failover are not part of `up`; prove them by hand.
@@ -0,0 +1,20 @@
#cloud-config
# Host preparation only. Spinifex is installed and formed over SSH by deploy-latitude.sh.
# latitude.sh substitutes double-brace variables in user data, so this file holds none.
write_files:
- path: /etc/cloud/cloud.cfg.d/99-spinifex-network.cfg
content: |
network: {config: disabled}
# The image's netplan lists eno2 with no address and without optional, so boot waits two minutes
# for it. --any returns once one link, eno1, is online; it changes the wait, not the network.
# UNVERIFIED: the binary path is the Ubuntu one and the drop-in first applies on the next boot.
- path: /etc/systemd/system/systemd-networkd-wait-online.service.d/10-spinifex-any.conf
content: |
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any
# runcmd carries on past a failing command, so each line stands alone.
runcmd:
- [sh, -c, "systemctl disable --now unattended-upgrades.service apt-daily-upgrade.timer apt-daily.timer || true"]
- [sh, -c, "systemctl daemon-reload || true"]
- [sh, -c, "touch /var/lib/cloud/spinifex-host-ready"]
+59
View File
@@ -0,0 +1,59 @@
locals {
hostname_prefix = "spx-${var.deployment_name}-node"
project = var.project != "" ? var.project : latitudesh_project.deploy[0].slug
}
# Both lookups fail the plan when a slug does not exist, which is the only check of the OS slug.
data "latitudesh_operating_system" "os" {
slug = var.operating_system
}
data "latitudesh_plan" "plan" {
slug = var.plan
}
resource "latitudesh_project" "deploy" {
count = var.project == "" ? 1 : 0
name = "spinifex-${var.deployment_name}"
description = "Spinifex deployment ${var.deployment_name}"
environment = "Development"
provisioning_type = "on_demand"
}
# The key is team scoped. The server login user is ubuntu, not root.
resource "latitudesh_ssh_key" "deploy" {
name = "spinifex-${var.deployment_name}"
public_key = trimspace(file(pathexpand(var.ssh_public_key_path)))
}
# Host preparation only. Latitude substitutes double-brace variables inside user data, so the file
# must hold none. Spinifex is installed over SSH by deploy-latitude.sh.
resource "latitudesh_user_data" "host" {
description = "spinifex-${var.deployment_name}"
content = filebase64("${path.module}/cloud-init/user-data.yaml")
}
# billing must be hourly: the default is monthly, which bills upfront with no refund.
resource "latitudesh_server" "node" {
count = var.node_count
project = local.project
hostname = "${local.hostname_prefix}${count.index + 1}"
site = var.site
plan = data.latitudesh_plan.plan.slug
operating_system = data.latitudesh_operating_system.os.slug
billing = "hourly"
ssh_keys = [latitudesh_ssh_key.deploy.id]
user_data = latitudesh_user_data.host.id
timeouts = {
create = "60m"
}
}
# One Elastic IP routed to node 1. Terraform owning server_id fights a Spinifex-managed move of the
# address (scout warning); that is fine for one node, and a multi-node deployment must stop managing
# this attachment here before Spinifex moves it.
resource "latitudesh_elastic_ip" "node1" {
project = local.project
server_id = latitudesh_server.node[0].id
}
+798
View File
@@ -0,0 +1,798 @@
#!/usr/bin/env bash
# deploy-latitude.sh — build a one-node Spinifex deployment on latitude.sh bare metal, or tear it down.
#
# deploy-latitude.sh up one m4.metal.medium with an Elastic IP
# deploy-latitude.sh status nodes, addresses, what the deployment holds, steps done
# deploy-latitude.sh credentials print the AWS access key and secret
# deploy-latitude.sh ssh [nodeN] [command] SSH to a node, node1 by default
# deploy-latitude.sh destroy
# deploy-latitude.sh resolve-uncertain clear the uncertain-create record after checking the dashboard
#
# Run it on your laptop with LATITUDESH_AUTH_TOKEN exported. OpenTofu, or Terraform when OpenTofu is
# not installed, creates the project (unless --project), SSH key, user data, server and Elastic IP.
# This script then works over SSH as the ubuntu user: it formats and mounts the blank data disk,
# and drives install-node.sh (install, ovn, firewall arm, start, verify). Each step is recorded
# under the state directory, so a rerun of up resumes at the step that failed.
#
# The token is read by the Terraform provider and by this script's own API calls from the
# environment. It is never a Terraform variable, never on a command line (curl reads it from stdin),
# and never sent to the node, so it is in no state file, log or user data.
#
# The AWS profile is "spinifex-latitude-<name>" (default name "default"). An existing profile of that
# name that this script did not write is refused unless --overwrite-profile is given.
set -euo pipefail
shopt -s inherit_errexit
# A provider trace log would carry the Authorization header.
unset TF_LOG TF_LOG_PATH TF_LOG_CORE TF_LOG_PROVIDER
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPINIFEX_SRC="$(cd "$SCRIPT_DIR/../../.." && pwd)"
LAT_API="https://api.latitude.sh"
CMD="${1:-}"
if [ $# -gt 0 ]; then shift; fi
NODE_ARG=""
NAME="default"
SITE="DAL"
PLAN="m4-metal-medium"
OS="ubuntu_26_04_x64_lts"
PROJECT=""
DISTRO=""
CHANNEL="latest"
VERSION=""
SPX_REGION="us-east-1"
SPX_AZ="us-east-1a"
SSH_KEY="$HOME/.ssh/id_ed25519"
IMAGE="ubuntu-26.04-x86_64"
DATA_DISK_ID=""
STATE_ROOT="$HOME/.spinifex/latitude"
NODES=1
YES=0
OVERWRITE_PROFILE=0
usage() {
sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'
cat <<EOF
Options:
--name NAME deployment name (default $NAME), 1-16 characters; names every resource
--site SLUG latitude.sh site (default $SITE); needs stock and Elastic IPs, not SAO
--plan SLUG server plan (default $PLAN)
--os SLUG operating system slug (default $OS, unverified)
--project ID|SLUG deploy into this existing project; by default one is created and removed on destroy
--distro TARBALL install this local release tarball
--channel CHANNEL install from a release channel: latest (default) or dev
--version TAG install this release instead of a channel
--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 AMI to import (default $IMAGE)
--no-image import no AMI
--data-disk-id PATH choose the data disk by its /dev/disk/by-id/nvme-eui.* path when several are blank
--nodes N must be 1 for now
--overwrite-profile replace an AWS profile of the same name that this script did not create
--yes destroy without asking
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="$2"; shift ;;
--site) SITE="$2"; shift ;;
--plan) PLAN="$2"; shift ;;
--os) OS="$2"; shift ;;
--project) PROJECT="$2"; shift ;;
--distro) DISTRO="$2"; shift ;;
--channel) CHANNEL="$2"; shift ;;
--version) VERSION="$2"; shift ;;
--spx-region) SPX_REGION="$2"; shift ;;
--spx-az) SPX_AZ="$2"; shift ;;
--ssh-key) SSH_KEY="$2"; shift ;;
--image) IMAGE="$2"; shift ;;
--no-image) IMAGE="" ;;
--data-disk-id) DATA_DISK_ID="$2"; shift ;;
--nodes) NODES="$2"; shift ;;
--overwrite-profile) OVERWRITE_PROFILE=1 ;;
--yes) YES=1 ;;
-h|--help) usage; exit 0 ;;
node[1-3])
case "$CMD" in ssh) ;; *) usage >&2; exit 2 ;; esac
[ -z "$NODE_ARG" ] || { usage >&2; exit 2; }
NODE_ARG="$1" ;;
*) if [ "$CMD" = ssh ]; then break; fi
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,15}$ ]] || die "--name must be 1-16 lowercase letters, digits or hyphens"
[ "$NODES" = 1 ] || die "--nodes must be 1: three nodes need the VLAN and the Elastic IP move, which are not built yet"
[[ "$SITE" =~ ^[A-Za-z0-9]+$ ]] || die "--site must be a site slug such as DAL"
[[ "$SPX_REGION" =~ ^[a-z0-9-]+$ ]] || die "--spx-region must be lowercase letters, digits and hyphens"
[[ "$SPX_AZ" =~ ^[a-z0-9-]+$ ]] || die "--spx-az must be lowercase letters, digits and hyphens"
[[ "$CHANNEL" =~ ^[a-z0-9.-]+$ ]] || die "--channel must be lowercase letters, digits, dots and hyphens"
[ -z "$VERSION" ] || [[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "--version has characters a release tag does not"
[ -z "$IMAGE" ] || [[ "$IMAGE" =~ ^[A-Za-z0-9._-]+$ ]] || die "--image has characters an image name does not"
[ -z "$DATA_DISK_ID" ] || [[ "$DATA_DISK_ID" =~ ^/dev/disk/by-id/nvme-eui\.[A-Za-z0-9]+$ ]] || die "--data-disk-id must look like /dev/disk/by-id/nvme-eui.<hex>"
case "$CMD" in up|destroy|status) [ -n "${LATITUDESH_AUTH_TOKEN:-}" ] || die "LATITUDESH_AUTH_TOKEN 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"
INPUTS="$STATE_DIR/inputs"
OWNED="$STATE_DIR/owned"
# PENDING exists while an apply runs. If the apply fails or is interrupted it is appended to UNCERTAIN, which
# only resolve-uncertain removes: a create that reached latitude.sh but not the state cannot be attributed,
# so destroy gives no all-clear while UNCERTAIN exists.
UNCERTAIN="$STATE_DIR/uncertain"
PENDING="$STATE_DIR/uncertain.pending"
HOST_PREFIX="spx-$NAME-node"
PROFILE="spinifex-latitude-$NAME"
CA_FILE="$HOME/.spinifex/latitude-$NAME-ca.pem"
SSH_OPTS=(-i "$SSH_KEY" -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=10
-o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$KNOWN_HOSTS")
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
# After the first up, destroy, status and ssh use the project choice that up recorded.
if [ -z "$PROJECT" ] && [ -f "$INPUTS" ]; then PROJECT="$(sed -n 's/^project=//p' "$INPUTS")"; fi
# latitude METHOD PATH prints the response body and fails on a non-2xx. The token goes to curl on
# stdin as a config line, so it is on no command line; printf is a shell builtin.
latitude() {
local out code
out="$(printf 'header = "Authorization: Bearer %s"\n' "$LATITUDESH_AUTH_TOKEN" |
curl -sS -g -K - -w '\n%{http_code}' -X "$1" -H 'Accept: application/json' "$LAT_API$2")" || return 1
code="${out##*$'\n'}"
out="${out%$'\n'*}"
case "$code" in
2??) printf '%s' "$out" ;;
*) log "latitude.sh $1 $2 returned $code: $(printf '%s' "$out" | head -c 400)"; return 1 ;;
esac
}
# lat_list PATH [QUERY] prints a JSON array of every item of a paginated list. It follows links.next
# until it is null; where the body gives no next key (the documented API has no pagination metadata, only page[size] and page[number]) it reads page numbers until an empty page, so a server-capped page cannot pass as the last. It fails on
# an API error, on a body that is not {"data": [objects with an id and attributes]}, on a next link to
# another host, on a page that repeats an earlier one, and past 200 pages.
lat_list() {
local path="$1?${2:+$2&}page[size]=100&page[number]=1" out info n next page=1 all='[]'
while :; do
out="$(latitude GET "$path")" || return 1
info="$(python3 -I -c '
import json,sys
d = json.load(sys.stdin)
data = d.get("data") if isinstance(d, dict) else None
if not isinstance(data, list) or not all(isinstance(i, dict) and isinstance(i.get("id"), str) and isinstance(i.get("attributes"), dict) for i in data):
sys.exit("unexpected list schema")
links = d.get("links")
nxt = "?" if not isinstance(links, dict) or "next" not in links else (links["next"] or "-")
print(len(data)); print(nxt)' <<<"$out")" || { log "latitude.sh list $1 returned an unexpected body"; return 1; }
n="$(sed -n 1p <<<"$info")" next="$(sed -n 2p <<<"$info")"
all="$(python3 -I -c '
import json,sys
a = json.loads(sys.argv[1]); new = json.load(sys.stdin)["data"]
if new and any(i["id"] == new[0]["id"] for i in a): sys.exit("page repeats")
print(json.dumps(a + new))' "$all" <<<"$out")" || { log "latitude.sh list $1 does not advance between pages"; return 1; }
page=$((page + 1))
[ "$page" -le 200 ] || { log "latitude.sh list $1 has more than 200 pages"; return 1; }
case "$next" in
-) break ;;
"?") if [ "$n" = 0 ]; then break; fi
path="$1?${2:+$2&}page[size]=100&page[number]=$page" ;;
"$LAT_API"/*) path="${next#"$LAT_API"}" ;;
/*) path="$next" ;;
*) log "latitude.sh list $1 gave a next link to another host"; return 1 ;;
esac
done
printf '%s' "$all"
}
tf() {
"$TF_BIN" -chdir="$SCRIPT_DIR" "$@" -state="$TF_STATE" \
-var "deployment_name=$NAME" -var "site=$SITE" -var "plan=$PLAN" -var "operating_system=$OS" \
-var "project=$PROJECT" -var "ssh_public_key_path=$SSH_KEY.pub" -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_always FN runs FN on every node and records nothing.
for_nodes_always() {
local fn="$1" i
for i in "${!IPS[@]}"; do
IP="${IPS[$i]}" SERVER_ID="${IDS[$i]}" NODE_NAME="node$((i + 1))"
"$fn"
done
}
# for_nodes STEP FN runs FN once per node with IP, SERVER_ID 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]}" SERVER_ID="${IDS[$i]}" NODE_NAME="node$((i + 1))"
if done_step "$step.$NODE_NAME"; then continue; fi
"$fn"
mark_step "$step.$NODE_NAME"
done
}
# on_tty needs -tt before the destination so passwd gets a terminal.
on_tty() { ssh -tt "${SSH_OPTS[@]}" "ubuntu@$IP" "$@"; }
# shellcheck disable=SC2029 # the command is meant to expand here
on() { ssh "${SSH_OPTS[@]}" "ubuntu@$IP" "$@"; }
# acquire_lock stops two runs on one deployment, so resolve-uncertain cannot clear a record while an
# apply is writing it. mkdir is atomic and works on macOS, which has no flock; a lock whose pid is gone is
# stale and is taken over. Holding the lock means no apply is running, so a pending entry left by an
# interrupted one is made permanent here.
acquire_lock() {
local lock="$STATE_DIR/lock.d" pid
if ! mkdir "$lock" 2>/dev/null; then
pid="$(cat "$lock/pid" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then die "another run (pid $pid) is working on deployment '$NAME'"; fi
# A lock with no pid yet may be a run that has just made it: stale only once it is a minute old.
if [ -z "$pid" ] && [ -z "$(find "$lock" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then die "another run is starting on deployment '$NAME'"; fi
rm -rf "$lock"
mkdir "$lock" || die "could not take the lock $lock"
fi
printf '%s\n' "$$" > "$lock/pid"
trap 'rm -rf "$STATE_DIR/lock.d"' EXIT
if [ -f "$PENDING" ]; then cat "$PENDING" >> "$UNCERTAIN"; rm -f "$PENDING"; fi
}
# ── up ───────────────────────────────────────────────────────────────────────
record_inputs() {
local now
now="$(printf 'site=%s\nplan=%s\nos=%s\nproject=%s\nnodes=%s' "$SITE" "$PLAN" "$OS" "$PROJECT" "$NODES")"
if [ -f "$INPUTS" ]; then
[ "$(cat "$INPUTS")" = "$now" ] || die "inputs differ from the first up of '$NAME' (recorded in $INPUTS); use the same inputs, or destroy it first"
else
printf '%s\n' "$now" > "$INPUTS"
fi
}
preflight() {
[ -r "$SSH_KEY" ] || die "SSH key $SSH_KEY not readable"
[ -r "$SSH_KEY.pub" ] || die "SSH key $SSH_KEY.pub not readable"
command -v aws >/dev/null || die "the AWS CLI is not installed"
command -v python3 >/dev/null || die "python3 is not installed"
if [ -n "$DISTRO" ]; then [ -r "$DISTRO" ] || die "--distro $DISTRO not readable"; fi
latitude GET '/projects?page[size]=1' >/dev/null || die "the API refused the token (LATITUDESH_AUTH_TOKEN)"
if [ -n "$PROJECT" ]; then
lat_list /projects "filter[slug]=$PROJECT" | python3 -c '
import json,sys
sys.exit(0 if any(i["attributes"]["slug"] == sys.argv[1] for i in json.load(sys.stdin)) else 1)' "$PROJECT" ||
die "no project with slug $PROJECT; use its slug, or leave --project out to create one"
fi
record_inputs
# A fresh deployment refuses an existing profile now; a rerun is checked again, with the real values,
# right before the profile is written.
if [ ! -f "$TF_STATE" ] && [ "$OVERWRITE_PROFILE" != 1 ] && [ "$(profile_state)" != none ]; then
die "AWS profile $PROFILE already has a section in $(AWS_CRED_FILE) or $(AWS_CONF_FILE); pass --overwrite-profile to replace it (both files are saved first), or use another --name"
fi
}
provision() {
log "terraform apply ($PLAN at $SITE)"
"$TF_BIN" -chdir="$SCRIPT_DIR" init -input=false >/dev/null
# A pending entry left by an interrupted apply becomes a permanent one. Entries are only appended.
if [ -f "$PENDING" ]; then cat "$PENDING" >> "$UNCERTAIN"; rm -f "$PENDING"; fi
if [ -f "$UNCERTAIN" ]; then
log "an earlier create failed or was interrupted ($UNCERTAIN); destroy gives no all-clear until you check the dashboard and run: $0 resolve-uncertain --name $NAME"
fi
date -u +%FT%TZ > "$PENDING"
tf apply -input=false -auto-approve >"$STATE_DIR/apply.log" 2>&1 || {
tail -20 "$STATE_DIR/apply.log" >&2
{ cat "$PENDING" 2>/dev/null || true; date -u +%FT%TZ; } >> "$UNCERTAIN"
rm -f "$PENDING"
record_owned || true
die "terraform apply failed; log in $STATE_DIR/apply.log. What was created is recorded in $OWNED and destroy removes it. A plan with no stock at the site fails here: try another --site"
}
record_owned
rm -f "$PENDING"
}
# record_owned adds the IDs of every latitude.sh object in the Terraform state to $OWNED, as
# "kind id label". Terraform writes state as each object is created, so this also covers a partly
# failed apply. destroy deletes only what is listed here.
record_owned() {
[ -f "$TF_STATE" ] || return 0
local out
out="$("$TF_BIN" -chdir="$SCRIPT_DIR" show -json "$TF_STATE" | python3 -I -c '
import json,sys
kinds = {"latitudesh_server": "server", "latitudesh_elastic_ip": "elastic-ip", "latitudesh_ssh_key": "ssh-key",
"latitudesh_user_data": "user-data", "latitudesh_project": "project"}
st = json.load(sys.stdin)
root = (st.get("values") or {}).get("root_module") or {}
for r in root.get("resources", []):
k = kinds.get(r.get("type"))
v = r.get("values") or {}
if k and v.get("id"):
print(k, v["id"], v.get("hostname") or v.get("address") or v.get("name") or v.get("description") or "-")')" ||
die "could not read the Terraform state to record what exists"
{ [ ! -f "$OWNED" ] || cat "$OWNED"; printf '%s\n' "$out"; } | sed '/^$/d' | sort -u > "$OWNED.new"
chmod 0600 "$OWNED.new"
mv "$OWNED.new" "$OWNED"
}
# wait_host pins the host key only once the marker cloud-init writes exists.
wait_host() {
log "waiting for SSH and cloud-init on $NODE_NAME"
local i
for i in $(seq 1 120); do
if ssh -i "$SSH_KEY" -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "ubuntu@$IP" \
'test -f /var/lib/cloud/spinifex-host-ready' 2>/dev/null; then break; fi
[ "$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 $NODE_NAME"
}
# host_prep is the first change to the node, so it runs after the console gate. The image's resolv.conf
# is not a link to systemd-resolved, so the node would bypass the split DNS Spinifex sets there for its
# load balancer and EKS names.
host_prep() {
on 'if [ ! -L /etc/resolv.conf ] && systemctl is-active -q systemd-resolved; then
sudo ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf; fi' ||
die "could not point /etc/resolv.conf at systemd-resolved on $NODE_NAME"
}
# confirm_if_pending asks for the console only when a step that changes the node is still to run.
confirm_if_pending() {
local st
for st in prep disk install ovn firewall start; do
if ! done_step "$st.$NODE_NAME"; then console_gate; return 0; fi
done
}
# console_gate runs before the first change to the node's disks, network or firewall. The person sets
# the ubuntu password here (typed into passwd on the node, never seen by this script), then proves the
# serial console works from another window, because that console is the only way back in.
console_gate() {
log "console recovery check for $NODE_NAME, before any change to its disks or firewall"
print_recovery >&2
[ -r /dev/tty ] || die "no terminal: the console check needs a typed confirmation"
printf '\nSet the ubuntu password now (typed on the node, not seen by this script):\n' >&2
on_tty 'sudo passwd ubuntu' < /dev/tty || die "setting the ubuntu password failed"
printf '\nIn ANOTHER window run: ssh %s@%s -p 2222 and log in as ubuntu with that password.\n' "$SERVER_ID" "$IP" >&2
printf 'Type "console-ok" once you are logged in over the serial console: ' >&2
local answer
read -r answer < /dev/tty || die "no answer"
[ "$answer" = console-ok ] || die "not confirmed; nothing has been changed on $NODE_NAME. Rerun up once the console works"
}
# data_disk formats the blank data disk, chosen by content on the node, and records its UUID.
data_disk() {
log "preparing the data disk on $NODE_NAME"
local tmp rc=0 uuid
scp -q "${SSH_OPTS[@]}" "$SCRIPT_DIR/host-data-disk.sh" "ubuntu@$IP:/tmp/host-data-disk.sh" || die "copy to $NODE_NAME failed"
tmp="$(mktemp "$STATE_DIR/data-disk.XXXXXX")"
on "sudo bash /tmp/host-data-disk.sh $DATA_DISK_ID" >"$tmp" 2>&1 || rc=$?
cat "$tmp" >&2
cat "$tmp" >> "$STATE_DIR/data-disk.$NODE_NAME.log"
uuid="$(sed -n 's/^DATA_UUID=//p' "$tmp" | tail -n1)"
rm -f "$tmp"
[ "$rc" = 0 ] || die "the data disk step failed (exit $rc); it may already have formatted the disk, so read the output above and $STATE_DIR/data-disk.$NODE_NAME.log before rerunning"
[[ "$uuid" =~ ^[0-9a-fA-F-]{16,36}$ ]] || die "the data disk step gave no UUID"
printf '%s\n' "$uuid" > "$STATE_DIR/data_uuid.$NODE_NAME"
}
# push_installer copies install-node.sh (and a local tarball with its setup.sh) to the node and
# proves the installer arrived intact.
push_installer() {
local want got
scp -q "${SSH_OPTS[@]}" "$SCRIPT_DIR/install-node.sh" "ubuntu@$IP:/tmp/install-node.sh" || die "copy to $NODE_NAME failed"
want="$(sha256sum "$SCRIPT_DIR/install-node.sh" | awk '{print $1}')"
got="$(on 'sha256sum /tmp/install-node.sh' | awk '{print $1}')"
[ "$want" = "$got" ] || die "install-node.sh changed in transit: local $want, node $got"
if [ -n "$DISTRO" ]; then
scp -q "${SSH_OPTS[@]}" "$DISTRO" "$SPINIFEX_SRC/scripts/setup.sh" "ubuntu@$IP:/tmp/" || die "copy of the tarball to $NODE_NAME failed"
fi
}
# install_args is every input install-node.sh records and a later phase must repeat.
install_args() {
local uuid eip
uuid="$(cat "$STATE_DIR/data_uuid.$NODE_NAME")"
eip="$(cat "$STATE_DIR/eip")"
printf -- '--eip %s --data-uuid %s --node-name %s --spx-region %s --spx-az %s --channel %s' \
"$eip" "$uuid" "$NODE_NAME" "$SPX_REGION" "$SPX_AZ" "$CHANNEL"
if [ -n "$VERSION" ]; then printf -- ' --version %s' "$VERSION"; fi
if [ -n "$DISTRO" ]; then printf -- ' --distro /tmp/%s --setup-script /tmp/setup.sh' "$(basename "$DISTRO")"; fi
}
# run_phase PHASE [EXTRA...] runs one install-node.sh phase on the node as root.
run_phase() {
local phase="$1"
shift
push_installer
log "install-node.sh --phase $phase on $NODE_NAME"
on "sudo bash /tmp/install-node.sh --phase $phase $(install_args) $*" 2>&1 | tee -a "$STATE_DIR/install-node.$NODE_NAME.log" >&2 ||
die "install-node.sh --phase $phase failed; the output above and $STATE_DIR/install-node.$NODE_NAME.log have it, and the node has /root/<step>.log"
}
phase_install() { run_phase install; }
phase_ovn() { run_phase ovn; }
# arm_firewall runs after the ovn phase and before Spinifex first starts, so the daemon never runs
# unfiltered. The daemon writes peers.nft and loads the policy during its start. The start phase
# refuses to go on unless the generated policy allows ssh: it reads the active rule, the defined ports
# against sshd -T, and the 0.0.0.0/0 trust.
arm_firewall() {
log "arming the firewall on $NODE_NAME before the first start"
print_recovery >&2
log "Do not reboot $NODE_NAME until this run has finished, or until the console disarm above has been used"
on 'sudo bash -c "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 $NODE_NAME. Spinifex was not started"
}
phase_start() {
if [ -n "$IMAGE" ]; then run_phase start --armed --image "$IMAGE"; else run_phase start --armed; fi
}
phase_verify() { run_phase verify; }
# check_access proves the firewalled node still lets a NEW ssh login and the API through from here.
check_access() {
log "checking a new ssh login and the API through the firewall"
local i code=""
ssh "${SSH_OPTS[@]}" -o ControlMaster=no -o ControlPath=none "ubuntu@$IP" true ||
die "a new ssh login to $NODE_NAME failed after the firewall loaded; recover from the serial console (printed at the end of a good run; also: ssh $SERVER_ID@$IP -p 2222)"
for i in $(seq 1 12); do
code="$(curl -sk -m 10 -o /dev/null -w '%{http_code}' "https://$IP:9999/" || true)"
case "$code" in 2??|3??|4??) break ;; esac
sleep 5
done
case "$code" in
2??|3??|4??) ;;
*) die "the API on https://$IP:9999 did not answer from here (last status '$code'); ssh works, so check the firewall rules for tcp 9999" ;;
esac
on 'sudo bash -c "grep -qx on /etc/spinifex/firewall/mode && nft list table inet spinifex_filter >/dev/null"' ||
die "the firewall is not armed (mode not on or table inet spinifex_filter missing)"
log "access proven with the firewall armed"
}
AWS_CRED_FILE() { printf '%s' "${AWS_SHARED_CREDENTIALS_FILE:-$HOME/.aws/credentials}"; }
AWS_CONF_FILE() { printf '%s' "${AWS_CONFIG_FILE:-$HOME/.aws/config}"; }
# profile_state [KEY SECRET ENDPOINT] prints none when the profile has no section in either AWS file
# (credentials: [name], config: [profile name]) whatever its type (static keys, SSO, credential_process).
# With the values up would write it prints match when the sections equal exactly those, else conflict;
# without them it prints exists. The secret reaches python on stdin.
profile_state() {
printf '%s\n%s\n%s\n' "${1:-}" "${2:-}" "${3:-}" | python3 -I -c '
import configparser, os, sys
cred, conf, name, ca, region = sys.argv[1:6]
key, secret, endpoint = sys.stdin.read().split("\n")[:3]
def sect(path, sec):
cp = configparser.RawConfigParser()
cp.optionxform = str
if os.path.exists(path):
cp.read(path)
return dict(cp.items(sec)) if cp.has_section(sec) else None
c = sect(cred, name)
g = sect(conf, "profile " + name)
if c is None and g is None:
print("none")
elif not key:
print("exists")
else:
want_c = {"aws_access_key_id": key, "aws_secret_access_key": secret}
want_g = {"region": region, "endpoint_url": endpoint, "ca_bundle": ca}
print("match" if c == want_c and g == want_g else "conflict")' "$(AWS_CRED_FILE)" "$(AWS_CONF_FILE)" "$PROFILE" "$CA_FILE" "$SPX_REGION"
}
# write_credentials KEY SECRET sets the profile's keys in the shared credentials file without putting
# the secret in any process argument: it reaches python on stdin. Other profiles are kept (comments in
# the file are not), the old file is saved beside it, and the new one is 0600.
write_credentials() {
local code
code='
import configparser, os, shutil, sys, time
profile = sys.argv[1]
key, secret = sys.stdin.read().split("\n")[:2]
path = os.environ.get("AWS_SHARED_CREDENTIALS_FILE") or os.path.expanduser("~/.aws/credentials")
os.umask(0o077)
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
cp = configparser.RawConfigParser()
cp.optionxform = str
if os.path.exists(path):
cp.read(path)
shutil.copy2(path, "%s.pre-latitude.%d" % (path, int(time.time())))
if not cp.has_section(profile):
cp.add_section(profile)
cp.set(profile, "aws_access_key_id", key)
cp.set(profile, "aws_secret_access_key", secret)
cp.remove_option(profile, "aws_session_token")
tmp = path + ".tmp"
with open(tmp, "w") as f:
cp.write(f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
'
printf '%s\n%s\n' "$1" "$2" | python3 -I -c "$code" "$PROFILE" || die "could not write the credentials file"
}
write_profile() {
log "writing the CA to $CA_FILE and AWS profile $PROFILE"
mkdir -p "$(dirname "$CA_FILE")"
local creds key secret ca_new
# The CA is fetched to a temporary file and checked first; the existing file is replaced only after
# the profile check passes, with the old one kept beside it.
ca_new="$(mktemp "$CA_FILE.new.XXXXXX")"
on 'sudo cat /etc/spinifex/ca.pem' > "$ca_new" || { rm -f "$ca_new"; die "could not read the node's CA"; }
grep -q -- '-----BEGIN CERTIFICATE-----' "$ca_new" || { rm -f "$ca_new"; die "the node's CA is empty or not a PEM certificate"; }
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" ] || die "no access key in the ubuntu user's ~/.aws/credentials on $NODE_NAME"
[ -n "$secret" ] || die "no [spinifex] credentials in the ubuntu user's ~/.aws/credentials on $NODE_NAME"
# Rechecked here, with the real values, immediately before anything is written.
case "$(profile_state "$key" "$secret" "https://$IP:9999")" in
none | match) ;;
*) [ "$OVERWRITE_PROFILE" = 1 ] || { rm -f "$ca_new"; die "AWS profile $PROFILE has a section in $(AWS_CRED_FILE) or $(AWS_CONF_FILE) that differs from what this run would write; pass --overwrite-profile to replace it (both files are saved first), or use another --name"; } ;;
esac
if [ -f "$CA_FILE" ] && ! cmp -s "$CA_FILE" "$ca_new"; then cp -p "$CA_FILE" "$CA_FILE.pre-latitude.$(date +%s)"; fi
chmod 0644 "$ca_new"
mv -f "$ca_new" "$CA_FILE"
write_credentials "$key" "$secret"
local cfg
cfg="$(AWS_CONF_FILE)"
if [ -f "$cfg" ]; then (umask 077; cp -p "$cfg" "$cfg.pre-latitude.$(date +%s)"); fi
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"
# The certificate must name the node's address for this to pass; that is not verified on latitude.sh before this script.
aws --profile "$PROFILE" sts get-caller-identity --query Account --output text >/dev/null ||
die "profile $PROFILE does not work against https://$IP:9999; check the API certificate names $IP"
}
print_recovery() {
printf '\nRecovery if ssh is ever lost (the firewall is on):\n'
printf ' Serial console: ssh %s@%s -p 2222 (needs an account SSH key and an OS user password: set one now with: %s ssh %s sudo passwd ubuntu)\n' \
"$SERVER_ID" "$IP" "$0" "$NODE_NAME"
printf ' Temporary disarm, in this order, from the console:\n'
printf ' sudo systemctl stop spinifex-daemon\n'
printf ' systemctl is-active spinifex-daemon (expect: inactive)\n'
printf ' sudo nft delete table inet spinifex_filter\n'
printf ' sudo mv /etc/spinifex/firewall/peers.nft /root/peers.nft.disarmed\n'
printf ' sudo nft list tables (must not list spinifex_filter)\n'
printf ' Do not reboot or restart Spinifex afterwards until a durable disarm is reviewed.\n'
}
cmd_up() {
preflight
if ! done_step terraform; then provision; mark_step terraform; fi
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[@]}"
local eip
eip="$("$TF_BIN" -chdir="$SCRIPT_DIR" output -state="$TF_STATE" -raw elastic_ip)"
[[ "$eip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "Terraform reported no usable Elastic IP address ('$eip')"
printf '%s\n' "$eip" > "$STATE_DIR/eip"
for_nodes host wait_host
# The console is confirmed on every up that still has a risky step to run, not once per deployment.
for_nodes_always confirm_if_pending
for_nodes prep host_prep
for_nodes disk data_disk
for_nodes install phase_install
for_nodes ovn phase_ovn
for_nodes firewall arm_firewall
for_nodes start phase_start
# Never skipped: the claim of success is always checked live by this run.
for_nodes_always phase_verify
for_nodes_always check_access
IP="${IPS[0]}" SERVER_ID="${IDS[0]}" NODE_NAME=node1
write_profile
log "ready: https://$IP:9999 (profile $PROFILE)"
printf 'endpoint=https://%s:9999\nprofile=%s\nca=%s\nconsole=https://%s:3000/\nelastic_ip=%s\n' "$IP" "$PROFILE" "$CA_FILE" "$IP" "$eip"
local i
for i in "${!IPS[@]}"; do printf 'node%d=%s\n' "$((i + 1))" "${IPS[$i]}"; done
print_recovery
printf '\nVerified by this run: the install phases, the firewall policy loaded, the ssh login and API reachable from this host, and a working AWS profile. Not shown: a guest launch and public reachability of the Elastic IP; test those.\n'
}
# ── destroy ──────────────────────────────────────────────────────────────────
# inventory prints "owned kind id label" for every object recorded in $OWNED that still exists, and
# "untracked kind id label" for every object whose name matches this deployment but is not recorded.
# With UNCERTAIN present it also prints "unattributed kind id label" for everything else in the account.
# It reads full lists, fails on any API or schema error, and never reads a failure as empty.
inventory() {
local d eip="" rc=0 uncertain=0
d="$(mktemp -d "$STATE_DIR/inv.XXXXXX")"
if [ -f "$STATE_DIR/eip" ]; then eip="$(cat "$STATE_DIR/eip")"; fi
if [ -f "$UNCERTAIN" ] || [ -f "$PENDING" ]; then uncertain=1; fi
{
lat_list /servers > "$d/server" &&
lat_list /elastic_ips > "$d/elastic-ip" &&
lat_list /ssh_keys > "$d/ssh-key" &&
lat_list /user_data > "$d/user-data" &&
lat_list /projects > "$d/project"
} || rc=1
if [ "$rc" = 0 ]; then
touch "$OWNED"
python3 -I -c '
import json,sys,os
d, owned_path, prefix, name, eip, own_project, uncertain = sys.argv[1:8]
owned = set()
for line in open(owned_path):
f = line.split()
if len(f) >= 2: owned.add((f[0], f[1]))
seen = set()
def label(a):
return a.get("hostname") or a.get("address") or a.get("name") or a.get("description") or "-"
for kind in ("server", "elastic-ip", "ssh-key", "user-data", "project"):
for it in json.load(open(os.path.join(d, kind))):
a = it["attributes"]
if (kind, it["id"]) in owned:
seen.add((kind, it["id"]))
print("owned", kind, it["id"], label(a)); continue
match = False
if kind == "server": match = str(a.get("hostname", "")).startswith(prefix)
elif kind == "elastic-ip":
host = ((a.get("server") or {}).get("hostname")) or ""
match = bool(eip and a.get("address") == eip) or host.startswith(prefix)
elif kind == "ssh-key": match = a.get("name") == "spinifex-" + name
elif kind == "project": match = own_project == "1" and a.get("name") == "spinifex-" + name
elif kind == "user-data": match = a.get("description") == "spinifex-" + name
if match: print("untracked", kind, it["id"], label(a))
elif uncertain == "1": print("unattributed", kind, it["id"], label(a))
for line in open(owned_path):
f = line.split()
if len(f) >= 2 and (f[0], f[1]) not in seen: print("absent", f[0], f[1], " ".join(f[2:]) or "-")
' "$d" "$OWNED" "$HOST_PREFIX" "$NAME" "$eip" "$([ -z "$PROJECT" ] && echo 1 || echo 0)" "$uncertain" || rc=1
fi
find "$d" -type f -delete
rmdir "$d"
return "$rc"
}
# state_sum identifies the Terraform state that destroy will act on.
state_sum() { if [ -f "$TF_STATE" ]; then sha256sum < "$TF_STATE"; else echo none; fi; }
# owned_sum identifies the recorded set, so a change after the confirmation is noticed.
owned_sum() { if [ -f "$OWNED" ]; then sha256sum < "$OWNED"; else echo none; fi; }
cmd_destroy() {
local inv set before ssum kind id label i answer failed=0
# The deletion set is the recorded IDs plus the IDs in the Terraform state, built once and shown.
record_owned
ssum="$(state_sum)"
set="$(inventory)" || die "could not list the account; nothing was deleted and nothing is proven"
before="$(owned_sum)"
if [ "$YES" != 1 ]; then
log "deployment '$NAME': every recorded object will be deleted ('owned' exists, 'absent' is recorded but not in the listing, and terraform destroy acts on it too). 'untracked' and 'unattributed' ones will NOT be:"
printf '%s\n' "$set" >&2
read -r -p "Type the deployment name to confirm: " answer < /dev/tty
[ "$answer" = "$NAME" ] || die "not confirmed"
fi
record_owned
[ "$(state_sum)" = "$ssum" ] || die "the Terraform state changed after the preview; run destroy again to confirm what it holds now"
[ "$(owned_sum)" = "$before" ] || die "the set to delete changed after the preview; run destroy again to confirm the new set"
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
# What Terraform did not remove: delete only members of the shown set, in dependency order. The paths
# are from the API reference (servers, elastic_ips, ssh_keys, user_data, projects), untested live.
for i in 1 2 3 4 5 6; do
inv="$(inventory)" || die "could not list what remains; treat everything as still billing and check the dashboard"
grep -q '^owned ' <<<"$inv" || break
for kind in server elastic-ip ssh-key user-data project; do
while read -r _ k id label; do
[ "$k" = "$kind" ] || continue
grep -q "^owned $kind $id " <<<"$set" || die "$kind $id is recorded but was not in the confirmed set; run destroy again"
log "deleting $kind $label ($id)"
case "$kind" in
server) latitude DELETE "/servers/$id" >/dev/null || failed=1 ;;
elastic-ip) latitude DELETE "/elastic_ips/$id" >/dev/null || failed=1 ;;
ssh-key) latitude DELETE "/ssh_keys/$id" >/dev/null || failed=1 ;;
user-data) latitude DELETE "/user_data/$id" >/dev/null || failed=1 ;;
project) latitude DELETE "/projects/$id" >/dev/null || failed=1 ;;
esac
done < <(grep '^owned ' <<<"$inv")
done
sleep 15
done
inv="$(inventory)" || die "could not list what remains; treat everything as still billing and check the dashboard"
if grep -q '^owned ' <<<"$inv"; then
log "these recorded objects still exist and may be billing:"
grep '^owned ' <<<"$inv" >&2
exit 1
fi
[ "$failed" = 0 ] || die "a DELETE was refused (see above) although the objects are now absent; check the dashboard, then run destroy again"
if grep -q '^untracked ' <<<"$inv"; then
log "recorded objects are gone, but these name-matched objects are not recorded for this deployment. NOT deleted; check each in the dashboard and remove it by hand if it is yours:"
grep '^untracked ' <<<"$inv" >&2
exit 1
fi
if [ -f "$UNCERTAIN" ] || [ -f "$PENDING" ]; then
log "an earlier create failed or was interrupted before its IDs were recorded ($UNCERTAIN), so an Elastic IP or server may exist that this script cannot attribute. Everything it cannot attribute:"
grep '^unattributed ' <<<"$inv" >&2 || true
log "check the latitude.sh dashboard; delete anything of this deployment by hand, then run: $0 resolve-uncertain --name $NAME. No all-clear is given."
exit 1
fi
rm -f "$STATE_DIR"/step.* "$STATE_DIR"/eip "$STATE_DIR"/data_uuid.* "$INPUTS" "$OWNED" "$TF_STATE" "$TF_STATE.backup"
log "deployment '$NAME' destroyed: every recorded object is absent from complete account listings and none is name-matched. Objects never recorded and named differently are not covered by this check"
}
cmd_status() {
local nodes inv
if [ -f "$TF_STATE" ] && nodes="$("$TF_BIN" -chdir="$SCRIPT_DIR" output -state="$TF_STATE" -json nodes 2>/dev/null)"; then
python3 -I -c 'import json,sys; [print(n["name"], n["public_ip"]) for n in json.load(sys.stdin)]' <<<"$nodes"
fi
if inv="$(inventory)"; then
printf '%s\n' "$inv"
else
echo "UNKNOWN: the account could not be listed"
fi
find "$STATE_DIR" -maxdepth 1 -name 'step.*' | sort | sed 's/.*step\./done: /'
}
cmd_resolve_uncertain() {
local answer
[ -f "$UNCERTAIN" ] || [ -f "$PENDING" ] || { log "nothing uncertain is recorded for '$NAME'"; return 0; }
log "recorded uncertain creates:"
cat "$UNCERTAIN" "$PENDING" 2>/dev/null >&2 || true
[ -r /dev/tty ] || die "no terminal for the confirmation"
read -r -p 'Type "checked" once you have looked in the latitude.sh dashboard (servers, Elastic IPs, keys, user data, projects) and removed anything of this deployment that was not recorded: ' answer < /dev/tty
[ "$answer" = checked ] || die "not confirmed"
rm -f "$UNCERTAIN" "$PENDING"
log "uncertainty cleared for '$NAME'"
}
# 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}")"
# shellcheck disable=SC2029
ssh "${SSH_OPTS[@]}" "ubuntu@$IP" "$@"
}
case "$CMD" in up | destroy | resolve-uncertain) acquire_lock ;; esac
case "$CMD" in
up) cmd_up ;;
destroy) cmd_destroy ;;
status) cmd_status ;;
credentials) cmd_credentials ;;
resolve-uncertain) cmd_resolve_uncertain ;;
ssh) cmd_ssh "$@" ;;
*) usage >&2; exit 2 ;;
esac
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# host-data-disk.sh — format and mount the blank data disk at /var/lib/spinifex. Run as root ON the host.
#
# host-data-disk.sh [/dev/disk/by-id/nvme-eui.<id>]
#
# The disk is chosen by content, never by its nvmeXn1 name, which swaps between boots. A candidate is a
# whole-disk by-id EUI link whose device is not in the root filesystem's ancestry, has no partitions,
# holders, swap or mounts, no filesystem signature and no partition table. Exactly one candidate must
# remain, or the script stops and lists the disks; pass the by-id path to choose between several.
# It never reformats: a rerun on a host already done prints the UUID and exits. The last line of stdout
# is DATA_UUID=<uuid>, which the later install phases need.
set -euo pipefail
MP=/var/lib/spinifex
LABEL=spinifex-data
FSTAB_BAK=/etc/fstab.pre-spinifex
MIN_BYTES="${MIN_DATA_BYTES:-100000000000}"
WANT="${1:-}"
die() { echo "STOP: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root"
# Resume: the mount is already there and is ours.
if mp_uuid="$(findmnt --mountpoint "$MP" -no UUID 2>/dev/null)" && [ -n "$mp_uuid" ]; then
src="$(findmnt --mountpoint "$MP" -no SOURCE)"
[ "$(blkid -s LABEL -o value "$src")" = "$LABEL" ] || die "$MP is mounted from $src, which is not labelled $LABEL; not touching it"
grep -q "UUID=${mp_uuid}[[:space:]]" /etc/fstab || die "$MP is mounted but /etc/fstab has no UUID=$mp_uuid entry"
echo "already done: $MP is mounted from $src (UUID $mp_uuid)"
echo "DATA_UUID=$mp_uuid"
exit 0
fi
if grep -q "$MP" /etc/fstab; then die "/etc/fstab mentions $MP but nothing is mounted there; fix that by hand first"; fi
[ ! -e "$MP" ] || [ -z "$(ls -A "$MP")" ] || die "$MP exists and is not empty"
root_src="$(findmnt -no SOURCE /)" || die "cannot find the root source"
root_anc="$(lsblk -s -nro NAME "$root_src")" || die "cannot read the root ancestry"
[ -n "$root_anc" ] || die "empty root ancestry"
# blank_reason ID prints why the disk is not a candidate and returns 1, or returns 0.
blank_reason() {
local id="$1" dev name size kids hold sw mnt rc w
dev="$(readlink -e "$id")" || { echo "link does not resolve"; return 1; }
[ -b "$dev" ] || { echo "not a block device"; return 1; }
name="$(basename "$dev")"
if grep -qx "$name" <<<"$root_anc"; then echo "in the root ancestry"; return 1; fi
size="$(blockdev --getsize64 "$id")" || { echo "size unreadable"; return 1; }
[ "$size" -ge "$MIN_BYTES" ] || { echo "only $size bytes"; return 1; }
kids="$(lsblk -nro NAME "$id")" || { echo "children unreadable"; return 1; }
[ "$kids" = "$name" ] || { echo "has children"; return 1; }
hold="$(ls -A "/sys/block/$name/holders/")" || { echo "holders unreadable"; return 1; }
[ -z "$hold" ] || { echo "has holders"; return 1; }
sw="$(swapon --show=NAME --noheadings)" || { echo "swapon failed"; return 1; }
if grep -q "$name" <<<"$sw"; then echo "is swap"; return 1; fi
mnt="$(lsblk -nro MOUNTPOINTS "$id")" || { echo "mounts unreadable"; return 1; }
[ -z "${mnt//[[:space:]]/}" ] || { echo "is mounted"; return 1; }
set +e; blkid -p "$id" >/dev/null 2>&1; rc=$?; set -e
[ "$rc" -eq 2 ] || { echo "blkid -p says it has a signature (rc $rc)"; return 1; }
w="$(wipefs -n "$id")" || { echo "wipefs failed"; return 1; }
[ -z "$w" ] || { echo "wipefs finds signatures"; return 1; }
return 0
}
candidates=()
shopt -s nullglob
for id in /dev/disk/by-id/nvme-eui.*; do
case "$id" in *-part*) continue ;; esac
if [ -n "$WANT" ] && [ "$id" != "$WANT" ]; then continue; fi
if why="$(blank_reason "$id")"; then
candidates+=("$id")
else
echo "skip $id: $why"
fi
done
shopt -u nullglob
if [ "${#candidates[@]}" -ne 1 ]; then
lsblk -o NAME,SIZE,TYPE,MODEL,SERIAL,FSTYPE,MOUNTPOINTS >&2 || true
die "expected exactly one blank data disk among the by-id EUI disks, found ${#candidates[@]}: ${candidates[*]:-none}"
fi
ID="${candidates[0]}"
DEV="$(readlink -e "$ID")"
echo "CHECKS PASSED: $ID -> $DEV"
mkdir -p "$MP"
mkfs.ext4 -q -L "$LABEL" "$ID"
U="$(blkid -p -o value -s UUID "$ID")" || die "blkid UUID failed"
[ -n "$U" ] || die "no UUID after mkfs"
[ "$(blkid -p -o value -s TYPE "$ID")" = ext4 ] || die "not ext4 after mkfs"
NEW="$(mktemp /etc/fstab.spinifex.XXXXXX)"
cp /etc/fstab "$NEW"
echo "UUID=$U $MP ext4 defaults,nofail,x-systemd.device-timeout=30s 0 2" >> "$NEW"
findmnt --verify --tab-file "$NEW" || { rm -f "$NEW"; die "staged fstab failed verify, /etc/fstab untouched"; }
BAK="$FSTAB_BAK.$(date +%s)"
cp -a /etc/fstab "$BAK"
trap 'cp -a "$BAK" /etc/fstab; systemctl daemon-reload; echo "ROLLED BACK /etc/fstab from $BAK" >&2' EXIT
chmod 644 "$NEW"
mv "$NEW" /etc/fstab
systemctl daemon-reload
mount "$MP"
[ "$(findmnt --mountpoint "$MP" -no UUID)" = "$U" ] || die "wrong UUID at $MP"
[ "$(findmnt --mountpoint "$MP" -no FSTYPE)" = ext4 ] || die "wrong fstype at $MP"
[ "$(findmnt --mountpoint "$MP" -no MAJ:MIN | tr -d ' ')" = "$(lsblk -dno MAJ:MIN "$ID" | tr -d ' ')" ] || die "wrong device at $MP"
trap - EXIT
findmnt --mountpoint "$MP" -o TARGET,SOURCE,MAJ:MIN,UUID,FSTYPE,OPTIONS
echo "STORAGE OK"
echo "DATA_UUID=$U"
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
output "deployment_name" {
value = var.deployment_name
}
output "site" {
value = var.site
}
output "project" {
value = local.project
}
# provider_host_id is the server ID. public_ip is the server's own /31 address, which SSH and the
# Spinifex API use; the Elastic IP is separate.
output "nodes" {
value = [for n in latitudesh_server.node : {
name = trimprefix(n.hostname, "spx-${var.deployment_name}-")
provider_host_id = n.id
public_ip = n.primary_ipv4
}]
}
output "elastic_ip" {
value = latitudesh_elastic_ip.node1.address
}
@@ -0,0 +1,4 @@
# The token comes from LATITUDESH_AUTH_TOKEN in the environment, which the provider reads
# itself (its auth_token argument documents that variable). It is never a variable here, so it
# is not written to the state or to a tfvars file. The default project is set per resource.
provider "latitudesh" {}
@@ -0,0 +1,5 @@
deployment_name = "l1"
site = "DAL"
plan = "m4-metal-medium"
node_count = 1
ssh_public_key_path = "~/.ssh/id_ed25519.pub"
@@ -0,0 +1,48 @@
variable "deployment_name" {
type = string
description = "Names every resource and the server hostnames, so two deployments in one account never collide."
validation {
# A hostname is at most 32 characters: "spx-" + name + "-node1".
condition = can(regex("^[a-z0-9][a-z0-9-]{0,15}$", var.deployment_name))
error_message = "deployment_name must be 1-16 lowercase letters, digits or hyphens."
}
}
variable "project" {
type = string
default = ""
description = "ID or slug of an existing latitude.sh project to deploy into. Empty creates a project spinifex-<deployment_name> and removes it on destroy."
}
variable "site" {
type = string
default = "DAL"
description = "latitude.sh site slug. Pick one with stock for the plan and Elastic IP support; not SAO."
}
variable "plan" {
type = string
default = "m4-metal-medium"
description = "Server plan slug (the pricing page calls it m4.metal.medium)."
}
variable "operating_system" {
type = string
default = "ubuntu_26_04_x64_lts"
description = "OS slug. UNVERIFIED: the 26.04 slug follows the 24.04 pattern (ubuntu_24_04_x64_lts) and is checked by a data source at plan time."
}
variable "node_count" {
type = number
default = 1
validation {
# Three nodes need the VLAN and the Elastic IP move, which are not built yet.
condition = var.node_count == 1
error_message = "node_count must be 1."
}
}
variable "ssh_public_key_path" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
@@ -0,0 +1,7 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
# Pinned exactly: 4.8.0 is the version whose schema these files were written against.
latitudesh = { source = "latitudesh/latitudesh", version = "= 4.8.0" }
}
}