OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / 0e29520

feat: add exact-target fleet pin repair
0e2952033d9f64d09640b5f6b2cf541ede3539db
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-29T20:02:54.000Z

Changed files (8) +456 -0

CHANGELOG.md +3 -0
……@@ -55,6 +55,9 @@ deployment.**
5555- Fleet updater config supports a validated per-host `PINNED_TAG`, allowing a
5656 remigrated box to restore its timer without following an older shared channel
5757 target. Signature verification now also precedes up-to-date and dry-run exits.
58- The exact-target updater repair command defaults to a read-only probe and
59 requires repeated relay confirmation before atomically applying a pin; it
60 refuses application-version changes and restores updater state on failure.
5861- StartOS and Umbrel metadata, release-evidence tests, and operator docs are
5962 synchronized to the current prerelease baseline and distinguish configured
6063 defaults from signed runtime proof.
fleet/README.md +19 -0
……@@ -120,6 +120,25 @@ operator-owned lanes. To **roll a whole channel back**, set its shared tag back
120120— boxes check out the older tag exactly like a forward update (health-gated the
121121same way).
122122
123For a remotely operated box, use the exact-target repair command. It is a
124read-only probe unless both `--apply` and a matching `--confirm-relay` are
125present. The apply path verifies the transferred updater hash, verifies the
126signed pin, requires the current checkout and healthy runtime to already match
127that pin, atomically installs only the updater/config, restores prior state on
128failure, and refuses any application-version mutation:
129
130```bash
131HIVERELAY_FLEET_INVENTORY=fleet/relays.local.json \
132 npm run fleet:repair-updater-pin -- \
133 --relay utah-8gb --channel stable --pin v0.25.0-rc.4
134
135# Production authority boundary:
136HIVERELAY_FLEET_INVENTORY=fleet/relays.local.json \
137 npm run fleet:repair-updater-pin -- \
138 --relay utah-8gb --channel stable --pin v0.25.0-rc.4 \
139 --apply --confirm-relay utah-8gb
140```
141
123142## Safety properties
124143
125144- **Health-gated:** after restart the agent polls `/health` for
package.json +1 -0
……@@ -51,6 +51,7 @@
5151 "release:check-image-manifest": "node scripts/check-release-image-manifest.mjs",
5252 "release:smoke-image": "node scripts/smoke-release-image.mjs",
5353 "fleet:check-rollout": "node scripts/check-fleet-rollout.mjs",
54 "fleet:repair-updater-pin": "node scripts/repair-fleet-updater-pin.mjs",
5455 "dashboard:admin-operator": "node dashboard-admin-operator/server.cjs",
5556 "dashboard:admin-operator:dev": "PORT=3458 FLEET_INTERVAL=30 node dashboard-admin-operator/server.cjs",
5657 "fleet:control": "node dashboard-admin-operator/server.cjs",
scripts/audit-workspace-alignment.mjs +21 -0
……@@ -203,6 +203,9 @@ const umbrelSmokePackage = readText(hiverelayRoot, 'scripts', 'smoke-umbrel-pack
203203const fleetRolloutCheck = readText(hiverelayRoot, 'scripts', 'check-fleet-rollout.mjs')
204204const fleetStatusScript = readText(hiverelayRoot, 'fleet', 'fleet-status.sh')
205205const fleetUpdaterScript = readText(hiverelayRoot, 'fleet', 'updater.sh')
206const fleetUpdaterPinRepair = readText(hiverelayRoot, 'scripts', 'repair-fleet-updater-pin.mjs')
207const fleetUpdaterPinRepairApply = readText(hiverelayRoot, 'scripts', 'lib', 'repair-fleet-updater-apply.sh')
208const fleetUpdaterPinRepairTest = readText(hiverelayRoot, 'test', 'unit', 'fleet-updater-pin-repair.test.js')
206209const deployVpsScript = readText(hiverelayRoot, 'scripts', 'deploy-vps.sh')
207210const relayJanitorScript = readText(hiverelayRoot, 'scripts', 'relay-janitor.js')
208211const rootDockerCompose = readText(hiverelayRoot, 'docker-compose.yml')
……@@ -9198,6 +9201,24 @@ if (
91989201 fail('fleet updater can accept an unverified pin, exit before rollback, continue after failed rollback checkout, or accept a stale runtime version')
91999202}
92009203
9204if (
9205 fleetUpdaterPinRepair.includes('if (apply && confirmation !== relayName)') &&
9206 fleetUpdaterPinRepair.includes('inventory.relays.filter(relay => relay?.name === relayName)') &&
9207 fleetUpdaterPinRepair.includes('mode: \'dry-run\'') &&
9208 fleetUpdaterPinRepair.includes('mutation: false') &&
9209 fleetUpdaterPinRepairApply.includes('bash "$candidate" --verify-only "$pin"') &&
9210 fleetUpdaterPinRepairApply.includes('git status --porcelain=v1 --untracked-files=normal') &&
9211 fleetUpdaterPinRepairApply.includes('[ "$current_sha" = "$target_sha" ]') &&
9212 fleetUpdaterPinRepairApply.includes('[ "$(git rev-parse HEAD)" = "$current_sha" ]') &&
9213 !fleetUpdaterPinRepairApply.includes('systemctl restart hiverelay') &&
9214 fleetUpdaterPinRepairTest.includes('Sydney is never contacted') &&
9215 fleetUpdaterPinRepairTest.includes('Dallas is never contacted')
9216) {
9217 pass('fleet updater pin repair defaults read-only, requires exact relay confirmation, verifies immutable application state, and excludes unrelated relays')
9218} else {
9219 fail('fleet updater pin repair can mutate without exact confirmation, contact unrelated relays, or change the relay application')
9220}
9221
92019222if (
92029223 cliIndex.includes('const interactiveStatus = process.stdout.isTTY === true') &&
92039224 cliIndex.includes('const statusIntervalMs = interactiveStatus ? 5000 : 60000') &&
scripts/lib/repair-fleet-updater-apply.sh +101 -0
……@@ -0,0 +1,101 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4candidate="$1"
5expected_hash="$2"
6channel="$3"
7pin="$4"
8repo="${HIVERELAY_REPO_DIR:-/root/hiverelay}"
9conf="${HIVERELAY_UPDATER_CONF:-/etc/hiverelay-updater.conf}"
10updater="/usr/local/bin/hiverelay-updater"
11allowed="${HIVERELAY_ALLOWED_SIGNERS:-/etc/hiverelay/allowed-signers}"
12backup="/run/hiverelay-updater.repair.$$"
13config_backup="/run/hiverelay-updater.conf.repair.$$"
14config_existed=0
15success=0
16timer_enabled="$(systemctl is-enabled hiverelay-updater.timer 2>/dev/null || true)"
17timer_active="$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)"
18
19cleanup() {
20 status="${1:-1}"
21 trap - EXIT HUP INT TERM
22 if [ "$success" != 1 ]; then
23 if [ -f "$backup" ]; then install -m 0755 "$backup" "$updater"; fi
24 if [ "$config_existed" = 1 ] && [ -f "$config_backup" ]; then
25 install -m 0644 "$config_backup" "$conf"
26 elif [ "$config_existed" = 0 ]; then
27 rm -f "$conf"
28 fi
29 if [ "$timer_enabled" = enabled ]; then
30 systemctl enable hiverelay-updater.timer >/dev/null 2>&1 || true
31 if [ "$timer_active" = active ]; then
32 systemctl start hiverelay-updater.timer >/dev/null 2>&1 || true
33 else
34 systemctl stop hiverelay-updater.timer >/dev/null 2>&1 || true
35 fi
36 else
37 systemctl disable --now hiverelay-updater.timer >/dev/null 2>&1 || true
38 fi
39 fi
40 rm -f "$candidate" "$backup" "$config_backup" "${conf}.next" "${updater}.next"
41 exit "$status"
42}
43trap 'cleanup $?' EXIT
44trap 'cleanup 129' HUP
45trap 'cleanup 130' INT
46trap 'cleanup 143' TERM
47
48[ "$(id -u)" = 0 ] || { echo 'repair requires root' >&2; exit 1; }
49[[ "$channel" =~ ^[A-Za-z0-9._-]{1,32}$ ]] || { echo 'invalid channel' >&2; exit 1; }
50[[ "$pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'invalid pin' >&2; exit 1; }
51[ -f "$candidate" ] || { echo 'candidate updater missing' >&2; exit 1; }
52[ "$(sha256sum "$candidate" | awk '{print $1}')" = "$expected_hash" ] || { echo 'candidate updater checksum mismatch' >&2; exit 1; }
53bash -n "$candidate"
54
55cd "$repo"
56[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ] || { echo 'relay repo is dirty' >&2; exit 1; }
57git fetch --tags --quiet origin
58HIVERELAY_REPO_DIR="$repo" HIVERELAY_ALLOWED_SIGNERS="$allowed" \
59 bash "$candidate" --verify-only "$pin"
60target_sha="$(git rev-parse -q --verify "refs/tags/$pin^{}")"
61current_sha="$(git rev-parse HEAD)"
62[ "$current_sha" = "$target_sha" ] || {
63 echo 'pin is not the currently installed commit; refusing application mutation' >&2
64 exit 1
65}
66version="v$(node -p 'require(process.argv[1]).version' "$repo/package.json")"
67[ "$version" = "$pin" ] || { echo 'runtime package version does not match pin' >&2; exit 1; }
68
69health_body="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health)"
70HEALTH="$health_body" EXPECTED="${pin#v}" python3 -c \
71 'import json,os; h=json.loads(os.environ["HEALTH"]); assert h.get("running") is True and h.get("version") == os.environ["EXPECTED"]'
72
73systemctl stop hiverelay-updater.timer >/dev/null 2>&1 || true
74[ "$(systemctl is-active hiverelay-updater.service 2>/dev/null || true)" != active ] || {
75 echo 'updater service is already active; retry after it exits' >&2
76 exit 1
77}
78if [ -f "$updater" ]; then cp -p "$updater" "$backup"; fi
79if [ -f "$conf" ]; then cp -p "$conf" "$config_backup"; config_existed=1; fi
80install -m 0755 "$candidate" "${updater}.next"
81mv -f "${updater}.next" "$updater"
82printf 'CHANNEL=%s\nPINNED_TAG=%s\n' "$channel" "$pin" > "${conf}.next"
83chmod 0644 "${conf}.next"
84mv -f "${conf}.next" "$conf"
85
86HIVERELAY_REPO_DIR="$repo" HIVERELAY_UPDATER_CONF="$conf" \
87 HIVERELAY_ALLOWED_SIGNERS="$allowed" "$updater" --dry-run
88systemctl enable --now hiverelay-updater.timer >/dev/null
89[ "$(systemctl is-enabled hiverelay-updater.timer)" = enabled ]
90[ "$(systemctl is-active hiverelay-updater.timer)" = active ]
91
92[ "$(git rev-parse HEAD)" = "$current_sha" ] || {
93 echo 'application commit changed during no-op repair' >&2
94 exit 1
95}
96health_body="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health)"
97HEALTH="$health_body" EXPECTED="${pin#v}" python3 -c \
98 'import json,os; h=json.loads(os.environ["HEALTH"]); assert h.get("running") is True and h.get("version") == os.environ["EXPECTED"]'
99
100success=1
101printf 'HIVERELAY_REPAIR_OK|%s|%s|enabled|active\n' "$version" "$pin"
scripts/lib/repair-fleet-updater-probe.sh +53 -0
……@@ -0,0 +1,53 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4repo="${HIVERELAY_REPO_DIR:-/root/hiverelay}"
5conf="${HIVERELAY_UPDATER_CONF:-/etc/hiverelay-updater.conf}"
6allowed="${HIVERELAY_ALLOWED_SIGNERS:-/etc/hiverelay/allowed-signers}"
7requested_pin="${1:-}"
8[[ "$requested_pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
9 echo 'invalid requested pin' >&2
10 exit 1
11}
12
13version="v$(node -p 'require(process.argv[1]).version' "$repo/package.json" 2>/dev/null || printf '?')"
14health="$(curl -fsS --max-time 8 http://127.0.0.1:9100/health 2>/dev/null || true)"
15health_fields="$(HEALTH="$health" python3 -c 'import json,os; h=json.loads(os.environ["HEALTH"]); print("{}|{}".format(str(h.get("running", "?")).lower(), h.get("version", "?")))' 2>/dev/null || printf '?|?')"
16IFS='|' read -r running health_version <<< "$health_fields"
17enabled="$(systemctl is-enabled hiverelay-updater.timer 2>/dev/null || true)"
18active="$(systemctl is-active hiverelay-updater.timer 2>/dev/null || true)"
19channel="$(awk -F= '/^[[:space:]]*CHANNEL[[:space:]]*=/ {sub(/^[^=]*=/,"");sub(/^[[:space:]]*/,"");sub(/[[:space:]]*$/,"");print;exit}' "$conf" 2>/dev/null || true)"
20pin="$(awk -F= '/^[[:space:]]*PINNED_TAG[[:space:]]*=/ {sub(/^[^=]*=/,"");sub(/^[[:space:]]*/,"");sub(/[[:space:]]*$/,"");print;exit}' "$conf" 2>/dev/null || true)"
21
22tag_verified=false
23head_matches=false
24repo_clean=false
25cd "$repo"
26[ -z "$(git status --porcelain=v1 --untracked-files=normal 2>/dev/null || printf '?')" ] && repo_clean=true
27target_sha="$(git rev-parse -q --verify "refs/tags/$requested_pin^{}" 2>/dev/null || true)"
28current_sha="$(git rev-parse HEAD 2>/dev/null || true)"
29if [ -n "$target_sha" ] && [ "$target_sha" = "$current_sha" ]; then head_matches=true; fi
30if [ -n "$target_sha" ] && [ -r "$allowed" ] &&
31 [ "$(git cat-file -t "refs/tags/$requested_pin" 2>/dev/null || true)" = tag ]; then
32 if verify_output="$(git -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$allowed" \
33 verify-tag --raw "$requested_pin" 2>&1)"; then
34 if printf '%s' "$verify_output" | grep -Eq 'GOODSIG|TRUST_(FULLY|ULTIMATE)' ||
35 printf '%s' "$verify_output" | grep -qi 'Good.*signature'; then
36 tag_verified=true
37 fi
38 fi
39fi
40
41ready=false
42if [ "$tag_verified" = true ] && [ "$head_matches" = true ] &&
43 [ "$repo_clean" = true ] &&
44 [ "$version" = "$requested_pin" ] && [ "$running" = true ] &&
45 [ "$health_version" = "${requested_pin#v}" ]; then
46 ready=true
47fi
48
49printf 'HIVERELAY_REPAIR_PROBE|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \
50 "${version:-?}" "${running:-?}" "${enabled:-missing}" \
51 "${active:-inactive}" "${channel:-?}" "${pin:--}" \
52 "${tag_verified}" "${head_matches}" "${health_version:-?}" \
53 "${repo_clean}" "${ready}"
scripts/repair-fleet-updater-pin.mjs +157 -0
……@@ -0,0 +1,157 @@
1#!/usr/bin/env node
2
3import { createHash } from 'node:crypto'
4import { readFileSync } from 'node:fs'
5import { spawnSync } from 'node:child_process'
6import { resolve } from 'node:path'
7
8const ROOT = resolve(import.meta.dirname, '..')
9const NAME_RE = /^[A-Za-z0-9._-]{1,64}$/
10const HOST_RE = /^(?!-)[A-Za-z0-9._:[\]-]{1,253}$/
11const TAG_RE = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
12const PROBE_SCRIPT = readFileSync(resolve(import.meta.dirname, 'lib', 'repair-fleet-updater-probe.sh'), 'utf8')
13const APPLY_SCRIPT = readFileSync(resolve(import.meta.dirname, 'lib', 'repair-fleet-updater-apply.sh'), 'utf8')
14
15function valueAfter (args, name) {
16 const index = args.indexOf(name)
17 if (index === -1) return null
18 if (index + 1 >= args.length) throw new Error(`${name} requires a value`)
19 return args[index + 1]
20}
21
22function safeField (value) {
23 return String(value ?? '?').replace(/[^\x20-\x7e]/g, '?').replace(/\|/g, '?').slice(0, 100) || '?'
24}
25
26function run (command, args, input) {
27 const result = spawnSync(command, args, {
28 encoding: 'utf8',
29 input,
30 env: process.env,
31 maxBuffer: 1024 * 1024
32 })
33 if (result.error || result.status !== 0) {
34 throw new Error(`${command} failed with status ${result.status ?? 'unavailable'}`)
35 }
36 return result.stdout || ''
37}
38
39function sshArgs (relay) {
40 const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes']
41 if (relay.sshKey && relay.sshKey !== 'default') args.push('-i', relay.sshKey)
42 args.push(`root@${relay.tailnet || relay.publicIp}`)
43 return args
44}
45
46function loadRelay (inventoryPath, relayName) {
47 let inventory
48 try {
49 inventory = JSON.parse(readFileSync(inventoryPath, 'utf8'))
50 } catch {
51 throw new Error('fleet inventory is unreadable or invalid')
52 }
53 if (!Array.isArray(inventory.relays)) throw new Error('inventory has no relays array')
54 const matches = inventory.relays.filter(relay => relay?.name === relayName)
55 if (matches.length !== 1) throw new Error(`inventory must contain exactly one relay named ${relayName}`)
56 const relay = matches[0]
57 const host = relay.tailnet || relay.publicIp
58 if (!HOST_RE.test(host || '')) throw new Error('selected relay has an invalid host')
59 if (relay.sshKey && relay.sshKey !== 'default' &&
60 (typeof relay.sshKey !== 'string' || relay.sshKey.length > 1024)) {
61 throw new Error('selected relay has an invalid SSH key path')
62 }
63 const unsafeKeyPath = typeof relay.sshKey === 'string' && [...relay.sshKey].some(char => {
64 const code = char.charCodeAt(0)
65 return code < 32 || code === 127
66 })
67 if (relay.sshKey !== 'default' && unsafeKeyPath) {
68 throw new Error('selected relay has an invalid SSH key path')
69 }
70 return relay
71}
72
73function printProbe (relayName, stdout, hash) {
74 const line = stdout.split(/\r?\n/).find(entry => entry.startsWith('HIVERELAY_REPAIR_PROBE|'))
75 if (!line) throw new Error('relay probe returned no structured result')
76 const [, version, running, enabled, active, channel, pin, tagVerified, headMatches, healthVersion, repoClean, ready] = line.split('|')
77 process.stdout.write(`${JSON.stringify({
78 mode: 'dry-run',
79 relay: relayName,
80 updaterSha256: hash,
81 current: {
82 version: safeField(version),
83 running: safeField(running),
84 timer: `${safeField(enabled)}/${safeField(active)}`,
85 channel: safeField(channel),
86 pin: safeField(pin),
87 healthVersion: safeField(healthVersion)
88 },
89 verification: {
90 tagVerified: tagVerified === 'true',
91 headMatches: headMatches === 'true',
92 repoClean: repoClean === 'true',
93 readyToApply: ready === 'true'
94 },
95 mutation: false
96 }, null, 2)}\n`)
97}
98
99function main () {
100 const args = process.argv.slice(2)
101 if (args.includes('--help')) {
102 process.stdout.write('Usage: node scripts/repair-fleet-updater-pin.mjs --relay <name> --pin <vX.Y.Z> [--channel stable] [--inventory path] [--apply --confirm-relay <name>]\n')
103 return
104 }
105
106 const relayName = valueAfter(args, '--relay')
107 const pin = valueAfter(args, '--pin')
108 const channel = valueAfter(args, '--channel') || 'stable'
109 const inventoryPath = resolve(valueAfter(args, '--inventory') || process.env.HIVERELAY_FLEET_INVENTORY || resolve(ROOT, 'fleet', 'relays.local.json'))
110 const updaterPath = resolve(valueAfter(args, '--updater') || resolve(ROOT, 'fleet', 'updater.sh'))
111 const apply = args.includes('--apply')
112 const confirmation = valueAfter(args, '--confirm-relay')
113
114 if (!NAME_RE.test(relayName || '')) throw new Error('--relay must be one exact inventory name')
115 if (!TAG_RE.test(pin || '')) throw new Error('--pin must be one exact release tag')
116 if (!NAME_RE.test(channel)) throw new Error('--channel is invalid')
117 if (apply && confirmation !== relayName) throw new Error('--apply requires --confirm-relay to exactly repeat the relay name')
118
119 const relay = loadRelay(inventoryPath, relayName)
120 let updater
121 try {
122 updater = readFileSync(updaterPath)
123 } catch {
124 throw new Error('updater source is unreadable')
125 }
126 const hash = createHash('sha256').update(updater).digest('hex')
127 const base = sshArgs(relay)
128
129 if (!apply) {
130 const stdout = run('ssh', [...base, 'bash', '-s', '--', pin], PROBE_SCRIPT)
131 printProbe(relayName, stdout, hash)
132 return
133 }
134
135 const remotePath = `/tmp/hiverelay-updater.${hash}.repair`
136 const scpArgs = base.slice(0, -1)
137 run('scp', [...scpArgs, updaterPath, `${base.at(-1)}:${remotePath}`])
138 const stdout = run('ssh', [...base, 'bash', '-s', '--', remotePath, hash, channel, pin], APPLY_SCRIPT)
139 const line = stdout.split(/\r?\n/).find(entry => entry.startsWith('HIVERELAY_REPAIR_OK|'))
140 if (!line) throw new Error('relay repair returned no success proof')
141 process.stdout.write(`${JSON.stringify({
142 mode: 'apply',
143 relay: relayName,
144 updaterSha256: hash,
145 pin,
146 channel,
147 result: safeField(line),
148 applicationRestarted: false
149 }, null, 2)}\n`)
150}
151
152try {
153 main()
154} catch (err) {
155 process.stderr.write(`fleet updater pin repair: ${safeField(err?.message)}\n`)
156 process.exitCode = 1
157}
test/unit/fleet-updater-pin-repair.test.js +101 -0
……@@ -0,0 +1,101 @@
1import test from 'brittle'
2import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { spawnSync } from 'node:child_process'
4import { tmpdir } from 'node:os'
5import path from 'node:path'
6
7const root = path.resolve('.')
8const repair = path.join(root, 'scripts', 'repair-fleet-updater-pin.mjs')
9const updater = path.join(root, 'fleet', 'updater.sh')
10const applyHelper = readFileSync(path.join(root, 'scripts', 'lib', 'repair-fleet-updater-apply.sh'), 'utf8')
11
12test('fleet updater pin repair is exact-target, dry-run-first, and application-immutable', (t) => {
13 const work = mkdtempSync(path.join(tmpdir(), 'hr-fleet-pin-repair-'))
14 t.teardown(() => rmSync(work, { recursive: true, force: true }))
15 const bin = path.join(work, 'bin')
16 const inventory = path.join(work, 'relays.json')
17 const log = path.join(work, 'commands.log')
18 mkdirSync(bin)
19
20 writeFileSync(inventory, JSON.stringify({
21 relays: [
22 { name: 'utah-8gb', publicIp: '10.0.0.8', sshKey: 'default' },
23 { name: 'sydney', publicIp: '10.0.0.9', sshKey: 'default' },
24 { name: 'dallas', publicIp: '10.0.0.10', sshKey: 'default' }
25 ]
26 }))
27
28 const ssh = path.join(bin, 'ssh')
29 const scp = path.join(bin, 'scp')
30 writeFileSync(ssh, `#!/bin/sh
31printf 'ssh %s\\n' "$*" >> "$FLEET_REPAIR_TEST_LOG"
32payload="$(cat)"
33printf '%s\\n' "$payload" >> "$FLEET_REPAIR_TEST_LOG"
34case "$payload" in
35 *HIVERELAY_REPAIR_PROBE*) printf 'HIVERELAY_REPAIR_PROBE|v0.25.0-rc.4|true|disabled|inactive|stable|-|true|true|0.25.0-rc.4|true|true\\n' ;;
36 *HIVERELAY_REPAIR_OK*) printf 'HIVERELAY_REPAIR_OK|v0.25.0-rc.4|v0.25.0-rc.4|enabled|active\\n' ;;
37esac
38`)
39 writeFileSync(scp, `#!/bin/sh
40printf 'scp %s\\n' "$*" >> "$FLEET_REPAIR_TEST_LOG"
41`)
42 chmodSync(ssh, 0o755)
43 chmodSync(scp, 0o755)
44
45 const run = (args) => spawnSync(process.execPath, [repair, '--inventory', inventory, '--updater', updater, ...args], {
46 encoding: 'utf8',
47 env: {
48 ...process.env,
49 PATH: `${bin}:${process.env.PATH}`,
50 FLEET_REPAIR_TEST_LOG: log
51 }
52 })
53
54 const dryRun = run(['--relay', 'utah-8gb', '--pin', 'v0.25.0-rc.4'])
55 t.is(dryRun.status, 0, 'default probe succeeds')
56 const proof = JSON.parse(dryRun.stdout)
57 t.is(proof.relay, 'utah-8gb')
58 t.is(proof.mutation, false, 'default mode is read-only')
59 t.is(proof.verification.readyToApply, true, 'probe proves signature, HEAD, and health alignment')
60 let calls = readFileSync(log, 'utf8')
61 t.ok(calls.includes('root@10.0.0.8'))
62 t.absent(calls.includes('10.0.0.9'), 'Sydney is never contacted')
63 t.absent(calls.includes('10.0.0.10'), 'Dallas is never contacted')
64 t.absent(calls.includes('\nscp '), 'dry run transfers nothing')
65
66 writeFileSync(log, '')
67 const unconfirmed = run(['--relay', 'utah-8gb', '--pin', 'v0.25.0-rc.4', '--apply'])
68 t.not(unconfirmed.status, 0, 'apply fails without repeated exact relay confirmation')
69 t.is(readFileSync(log, 'utf8'), '', 'failed confirmation opens no connection')
70
71 const applied = run([
72 '--relay', 'utah-8gb',
73 '--pin', 'v0.25.0-rc.4',
74 '--channel', 'stable',
75 '--apply',
76 '--confirm-relay', 'utah-8gb'
77 ])
78 t.is(applied.status, 0, 'confirmed exact-target apply reaches the remote transaction')
79 calls = readFileSync(log, 'utf8')
80 t.ok(calls.includes('scp '), 'apply transfers the audited updater')
81 t.ok(calls.includes('root@10.0.0.8'))
82 t.absent(calls.includes('10.0.0.9'))
83 t.absent(calls.includes('10.0.0.10'))
84 t.ok(calls.includes('refusing application mutation'))
85 t.ok(calls.includes('git rev-parse HEAD'))
86 t.ok(calls.includes('systemctl enable --now hiverelay-updater.timer'))
87})
88
89test('remote updater pin repair rolls back control-plane state and never restarts the application', (t) => {
90 t.ok(applyHelper.includes("trap 'cleanup $?' EXIT"))
91 t.ok(applyHelper.includes('install -m 0755 "$backup" "$updater"'))
92 t.ok(applyHelper.includes('install -m 0644 "$config_backup" "$conf"'))
93 t.ok(applyHelper.includes('systemctl disable --now hiverelay-updater.timer'))
94 t.ok(applyHelper.includes('HIVERELAY_ALLOWED_SIGNERS="$allowed"'))
95 t.ok(applyHelper.includes('bash "$candidate" --verify-only "$pin"'))
96 t.ok(applyHelper.includes('git status --porcelain=v1 --untracked-files=normal'))
97 t.ok(applyHelper.includes('[ "$current_sha" = "$target_sha" ]'))
98 t.ok(applyHelper.includes('[ "$(git rev-parse HEAD)" = "$current_sha" ]'))
99 t.absent(applyHelper.includes('systemctl restart hiverelay'))
100 t.absent(applyHelper.includes('systemctl restart "$SERVICE"'))
101})