Files
warmbly/cmd/worker/uuid_test.go
T
Matthew Meszaros 5463e3986e worker: multi-IP support (one systemd unit per Primary IP)
Hetzner CX32 + 16 Primary IPs becomes 16 sending identities with one
install command, without expanding ops complexity.

cmd/worker/main.go: WORKER_ID now resolves via 4-tier precedence:
  1. WORKER_ID env (explicit UUID)
  2. WORKER_BIND_IP env (derive UUIDv5 from the bound IP)
  3. hostname-as-UUID (legacy single-IP VPS)
  4. generated UUID (local dev fallback)

Boot also constructs the chosen Codec + EventBus + EncryptedKeyStore
via the FromEnv factories from earlier commits, so a worker process is
fully configured by its envelope env file plus the runtime config it
pulls from the backend on first boot.

scripts/install-worker.sh gains --ips <ipv4,ipv4,...> which:
  - writes a warmbly-worker@.service systemd template
  - drops a per-instance env file at /etc/warmbly/instances/<dashed-ip>.env
    with WORKER_BIND_IP and WORKER_ID
  - shares one /etc/warmbly/worker.env for the common config
  - --status, --update, --uninstall now multi-IP aware
  - single-IP mode preserved when --ips is absent

5 worker tests pin the UUIDv5 derivation against the installer's
uuidgen --sha1 output so the two never drift.

docs/MULTI_IP_WORKERS.md is the operator runbook with the Hetzner
recipe, OS-level IP attachment, rDNS automation, day-2 ops, and the
25%-of-fleet blast-radius rule.
2026-05-27 14:43:20 +00:00

95 lines
2.6 KiB
Go

// Verifies that the worker derives the same UUIDv5 from an IP as the install
// script does. If these ever drift, every worker installed on a multi-IP box
// would come up with a different ID than the one the installer registered,
// silently breaking topic subscription and assignment lookups.
//
// The expected UUIDs below were generated with the same command the installer
// runs:
//
// uuidgen --sha1 --namespace 6ba7b811-9dad-11d1-80b4-00c04fd430c8 --name <ip>
//
// Equivalently:
//
// python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8'), '<ip>'))"
package main
import (
"os"
"testing"
"github.com/google/uuid"
)
func TestWorkerIDFromIP_Deterministic(t *testing.T) {
cases := []struct {
ip string
want string
}{
// Verified via `uuidgen --sha1 --namespace 6ba7b811-9dad-11d1-80b4-00c04fd430c8 --name <ip>`.
{"1.2.3.4", "47ffd7ee-a509-5b1e-a3d2-993b6a56fe65"},
{"127.0.0.1", "9a4a6d7c-f348-5828-8dfc-db6d3d417ef0"},
{"203.0.113.10", "016a4ab6-01b5-59ed-9fe5-3f4e8254099b"},
{"192.168.1.1", "4f98548b-3e6a-5a9d-bc92-47359ed6e2d5"},
}
for _, tc := range cases {
t.Run(tc.ip, func(t *testing.T) {
got := workerIDFromIP(tc.ip).String()
if got != tc.want {
t.Fatalf("workerIDFromIP(%q) = %s, want %s", tc.ip, got, tc.want)
}
})
}
}
func TestWorkerIDFromIP_StableAcrossCalls(t *testing.T) {
a := workerIDFromIP("10.0.0.1")
b := workerIDFromIP("10.0.0.1")
if a != b {
t.Fatalf("non-deterministic: %s != %s", a, b)
}
}
func TestResolveWorkerID_ExplicitID(t *testing.T) {
want := uuid.MustParse("11111111-2222-3333-4444-555555555555")
t.Setenv("WORKER_ID", want.String())
t.Setenv("WORKER_BIND_IP", "")
got, bind := resolveWorkerID()
if got != want {
t.Fatalf("got id %s, want %s", got, want)
}
if bind != "default route" {
t.Fatalf("got bind %q, want %q", bind, "default route")
}
}
func TestResolveWorkerID_ExplicitIDWithBind(t *testing.T) {
want := uuid.MustParse("11111111-2222-3333-4444-555555555555")
t.Setenv("WORKER_ID", want.String())
t.Setenv("WORKER_BIND_IP", "1.2.3.4")
got, bind := resolveWorkerID()
if got != want {
t.Fatalf("got id %s, want %s", got, want)
}
if bind != "1.2.3.4" {
t.Fatalf("got bind %q, want %q", bind, "1.2.3.4")
}
}
func TestResolveWorkerID_FromBindIP(t *testing.T) {
// Unset WORKER_ID so the bind-IP branch runs.
os.Unsetenv("WORKER_ID")
t.Setenv("WORKER_BIND_IP", "1.2.3.4")
got, bind := resolveWorkerID()
want := uuid.MustParse("47ffd7ee-a509-5b1e-a3d2-993b6a56fe65")
if got != want {
t.Fatalf("got id %s, want %s", got, want)
}
if bind != "1.2.3.4" {
t.Fatalf("got bind %q, want %q", bind, "1.2.3.4")
}
}