@curia-sh/cli 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stage.mjs ADDED
@@ -0,0 +1,141 @@
1
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { Refusal } from './exit.mjs'
6
+ import { readArchive } from './archive.mjs'
7
+ import { operatorConfigPath } from './config.mjs'
8
+ import { verifyStagedRelease } from './manifest.mjs'
9
+ import { versionPaths } from './root.mjs'
10
+
11
+ // How one release lands under versions/<version>/ (#873, lifted for #883).
12
+ //
13
+ // A stage is a directory that holds the seven files a complete version is
14
+ // made of: the unpacked runtime, the unpacked package, and the three retained
15
+ // artifacts the doctor re-verifies. The bootstrap builds one in a temporary
16
+ // directory for `curia install`; `curia update` builds one itself from the
17
+ // network. Either way `placeVersion` is the one door from a stage to an
18
+ // installed version: the artifacts are verified against the release
19
+ // manifest, copied into a sibling of the version directory, made read-only,
20
+ // and renamed into place, so `versions/<version>/` is either absent, the
21
+ // previous complete one, or the new complete one, never half of one.
22
+
23
+ export const STAGE_FILES = Object.freeze(['node/bin/node', 'cli/bin/curia.mjs', 'cli/package.json', 'cli/manifest.json', 'cli.tgz', 'bundle.tar.gz', 'bundle.tar.gz.sha256'])
24
+
25
+ // Whether `dir` holds every file of a complete version.
26
+ export function isCompleteStage(dir) {
27
+ return STAGE_FILES.every((f) => existsSync(join(dir, f)))
28
+ }
29
+
30
+ // The version the staged package names.
31
+ export function stagedVersion(stage) {
32
+ return JSON.parse(readFileSync(join(stage, 'cli', 'package.json'), 'utf8')).version
33
+ }
34
+
35
+ // Verifies the stage and lands it as versions/<version>/, replacing that
36
+ // directory if it was there. Returns the version's paths.
37
+ export async function placeVersion({ root, version, stage, stdout }, release) {
38
+ for (const f of STAGE_FILES) {
39
+ if (!existsSync(join(stage, f))) throw new Refusal(`the stage ${stage} lacks ${f}. Run the command again; it downloads a complete stage.`)
40
+ }
41
+ const staged = stagedVersion(stage)
42
+ if (staged !== version) {
43
+ throw new Refusal(`the stage holds @curia-sh/cli ${staged}, but ${version} was selected. Run the command again so it stages one version end to end.`)
44
+ }
45
+ stdout.write(`verifying the staged release ${version}\n`)
46
+ await verifyRetained({ version, dir: stage, stdout }, release)
47
+
48
+ const paths = versionPaths(root, version)
49
+ const compose = composeFrom(readFileSync(join(stage, 'bundle.tar.gz')), version)
50
+ const staging = join(root, 'versions', `.${version}.${process.pid}.staging`)
51
+ rmSync(staging, { recursive: true, force: true })
52
+ mkdirSync(staging, { mode: 0o700 })
53
+ try {
54
+ for (const name of ['node', 'cli']) cpSync(join(stage, name), join(staging, name), { recursive: true, verbatimSymlinks: true })
55
+ for (const name of ['cli.tgz', 'bundle.tar.gz', 'bundle.tar.gz.sha256']) cpSync(join(stage, name), join(staging, name))
56
+ mkdirSync(join(staging, 'bundle'), { mode: 0o700 })
57
+ writeFileSync(join(staging, 'bundle', 'compose.yaml'), compose)
58
+ makeReadOnly(staging)
59
+ if (existsSync(paths.dir)) {
60
+ stdout.write(`replacing ${paths.dir}\n`)
61
+ rmSync(paths.dir, { recursive: true, force: true })
62
+ }
63
+ renameSync(staging, paths.dir)
64
+ } catch (e) {
65
+ rmSync(staging, { recursive: true, force: true })
66
+ throw e
67
+ }
68
+ stdout.write(`installed ${version} under ${paths.dir}\n`)
69
+ return paths
70
+ }
71
+
72
+ // The retained artifacts of one directory (a stage or an installed version)
73
+ // through the release door. Throws the refusal when a check fails.
74
+ export async function verifyRetained({ version, dir, stdout }, release) {
75
+ const report = await verifyStagedRelease({
76
+ version,
77
+ tarball: readFileSync(join(dir, 'cli.tgz')),
78
+ archive: readFileSync(join(dir, 'bundle.tar.gz')),
79
+ checksum: readFileSync(join(dir, 'bundle.tar.gz.sha256'), 'utf8'),
80
+ }, { stdout }, release)
81
+ if (!report.ok) throw report.refusal
82
+ }
83
+
84
+ // A release validates the current operator configuration with its own
85
+ // reader (#883, lifted for #885): the package's `src/config.mjs` under
86
+ // versions/<version>/, imported from there, and its `readOperatorConfig` on
87
+ // `config/config.yaml`. That is how `curia update` learns that the target
88
+ // accepts the configuration before anything switches, and how `curia
89
+ // rollback` learns that the rollback release still reads it. Nothing is
90
+ // written. A release that refuses the file throws an `IncompatibleRelease`
91
+ // whose `reason` is `configuration`, with the contract's own sentence; a
92
+ // release that carries no reader cannot validate and throws one whose
93
+ // `reason` is `reader`. The caller adds the action that fits its command.
94
+ export class IncompatibleRelease extends Error {
95
+ constructor(message, reason) {
96
+ super(message)
97
+ this.name = 'IncompatibleRelease'
98
+ this.reason = reason
99
+ }
100
+ }
101
+
102
+ export async function validateWithRelease({ root, version, dir = versionPaths(root, version).dir }) {
103
+ const reader = join(dir, 'cli', 'src', 'config.mjs')
104
+ if (!existsSync(reader)) {
105
+ throw new IncompatibleRelease(`${version} carries no operator configuration reader (cli/src/config.mjs), so it cannot validate the current configuration.`, 'reader')
106
+ }
107
+ const release = await import(pathToFileURL(reader).href)
108
+ if (typeof release.readOperatorConfig !== 'function') {
109
+ throw new IncompatibleRelease(`${version}'s configuration reader has no readOperatorConfig, so it cannot validate the current configuration.`, 'reader')
110
+ }
111
+ try {
112
+ release.readOperatorConfig(operatorConfigPath(root))
113
+ } catch (e) {
114
+ if (e?.name !== 'ConfigError') throw e
115
+ throw new IncompatibleRelease(`${version} refuses the current operator configuration: ${e.message}.`, 'configuration')
116
+ }
117
+ }
118
+
119
+ // The one file the verified bundle archive holds.
120
+ function composeFrom(archive, version) {
121
+ const files = readArchive(archive)
122
+ const compose = files.get(`curia-bundle-${version}/compose.yaml`)
123
+ if (!compose) throw new Error(`the bundle archive holds no curia-bundle-${version}/compose.yaml`)
124
+ return compose.toString('utf8')
125
+ }
126
+
127
+ // Verified artifacts become read-only: every file loses its write bits and
128
+ // keeps its execute bits, so the runtime still runs. Directories stay 0700,
129
+ // which is what lets a reinstall or an update replace the version as a whole.
130
+ function makeReadOnly(dir) {
131
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
132
+ const path = join(dir, entry.name)
133
+ if (entry.isSymbolicLink()) continue
134
+ if (entry.isDirectory()) {
135
+ makeReadOnly(path)
136
+ chmodSync(path, 0o700)
137
+ } else {
138
+ chmodSync(path, (statSync(path).mode & 0o555) | 0o400)
139
+ }
140
+ }
141
+ }
package/src/steps.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { Refusal } from './exit.mjs'
2
+
3
+ // The named steps of one lifecycle command (#873's shape, lifted for #885):
4
+ // each step is printed as `[n/N] <name>` when it begins, and an error that
5
+ // escapes a step is turned into one that names the step. A `Refusal` keeps
6
+ // its class and exit code and gains the step's name; any other error becomes
7
+ // `<step> failed: <cause>` followed by the line that says how to run the
8
+ // step again. Every command that has steps says the same things the same
9
+ // way, so an operator reads one shape.
10
+ export function namedSteps({ steps, stdout, rerun }) {
11
+ let current = null
12
+ return {
13
+ begin(name) {
14
+ current = name
15
+ stdout.write(`[${steps.indexOf(name) + 1}/${steps.length}] ${name}\n`)
16
+ },
17
+ wrap(e) {
18
+ if (current === null) return e
19
+ if (e instanceof Refusal) return new Refusal(`${current}: ${e.message}`)
20
+ const wrapped = new Error(`${current} failed: ${e.message}\n${rerun(current)}`)
21
+ wrapped.cause = e
22
+ return wrapped
23
+ },
24
+ }
25
+ }
package/src/switch.mjs ADDED
@@ -0,0 +1,199 @@
1
+ import { readdirSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { HEALTH_POLL_MS, compose, composeProject, dockerRunner, waitForHealth, writeComposeEnvironment } from './compose.mjs'
5
+ import { APP_PORT, SERVICE_PORT } from './doctor.mjs'
6
+ import { writeInstallationRecord } from './root.mjs'
7
+
8
+ // The switch of a live installation from one installed release to another
9
+ // (#884, implementing #854), the one door `curia update` and `curia
10
+ // rollback` (#885) share.
11
+ //
12
+ // Both releases are complete under versions/<version>/, the target is
13
+ // validated, and the caller holds the lifecycle lock. What happens here:
14
+ //
15
+ // 1. The live sessions are read from the running service (`GET /overview`
16
+ // on loopback): every agent whose tmux pane is live. That is the list
17
+ // the recreated service must adopt back.
18
+ // 2. `run/compose.env` is rewritten with the same values, the target's
19
+ // images are pulled by digest, and the core services (the service, the
20
+ // app, and the overseer) are recreated from the target's bundle with
21
+ // `up --detach --no-deps`. The tmux runtime, the attach surface, and
22
+ // every agent container keep running: they are not named, `--no-deps`
23
+ // keeps Compose off the runtime the service depends on, and nothing
24
+ // removes orphans. An agent keeps the image it started on; the
25
+ // recreated service builds the target's agent image for the next
26
+ // spawn. The overseer comes back beside the service, which is the
27
+ // shape its replay of an interrupted turn was built for (ADR-0015).
28
+ // 3. Acceptance: every declared service reports healthy, the service and
29
+ // the app report the target version on their `/ping` routes, and every
30
+ // live session from step 1 is adopted by the recreated service's
31
+ // reconcile, read from `/overview` until it settles or the deadline
32
+ // passes. A session that vanished from tmux meanwhile ended on its
33
+ // own and is reported, not failed.
34
+ // 4. Activation: the installation record names the target, through one
35
+ // atomic write, and every directory under versions/ other than the
36
+ // target and the release that was active is removed. The release that
37
+ // was active is the one rollback release.
38
+ //
39
+ // A failure anywhere after the recreate switches the core services back to
40
+ // the release that was active, once, and proves that release the same way:
41
+ // its health, its version on both `/ping` routes, and the same live
42
+ // sessions adopted back. Then it reports both outcomes. Nothing runs the
43
+ // target a second time, and nothing runs the switch back a second time: a
44
+ // switch back that fails its own proof is reported as failed too, with the
45
+ // reinstall as the way out. The record is untouched by a failure: it names
46
+ // the target only after acceptance, so the launcher and the service's own
47
+ // reads follow the switch and never a half of one. The staged target stays
48
+ // under versions/ for the rerun.
49
+ //
50
+ // Docker is reached through `compose.mjs` and its injectable runner; the
51
+ // service and the app through an injectable `fetch`. Nothing here prints or
52
+ // reads a secret.
53
+
54
+ export const CORE_SERVICES = Object.freeze(['daemon', 'dashboard', 'overseer'])
55
+
56
+
57
+ // How long the recreated service may take to report the target version and
58
+ // adopt every live session, counted after its health check passed. The boot
59
+ // reconcile reads GitHub once per live ticket, so a fleet of a dozen agents
60
+ // on a slow origin needs a minute; two is the bound.
61
+ export const READOPTION_TIMEOUT_MS = 120_000
62
+ const READ_TIMEOUT_MS = 10_000
63
+
64
+ export class SwitchError extends Error {
65
+ constructor(message) {
66
+ super(message)
67
+ this.name = 'SwitchError'
68
+ }
69
+ }
70
+
71
+ // Switches the core services from `from` to `to` and records `to` active.
72
+ // `record` is the installation record as it stands; `environment` is what
73
+ // `run/compose.env` carries (`uid`, `gid`, `dockerGid`, `installationId`).
74
+ // Returns what was re-adopted. Throws a `SwitchError` that says what failed
75
+ // and where the installation stands.
76
+ export async function switchRelease(
77
+ { root, from, to, record, environment, stdout },
78
+ { docker = dockerRunner, fetch: fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = {},
79
+ ) {
80
+ const say = (text) => stdout.write(`${text}\n`)
81
+ const read = reader(fetchImpl)
82
+ const target = composeProject({ root, version: to })
83
+ const previous = composeProject({ root, version: from })
84
+
85
+ // 1. What is live now, from the service that is about to be recreated.
86
+ const live = await liveSessions(read)
87
+ if (live === null) say('the service did not answer before the switch, so no live session is expected back')
88
+ else if (live.length === 0) say('no live session to re-adopt')
89
+ else say(`${live.length} live ${live.length === 1 ? 'session' : 'sessions'} to re-adopt after the switch: ${live.join(', ')}`)
90
+
91
+ // 2. The recreate.
92
+ writeComposeEnvironment(target, environment)
93
+ say(`pulling the images of ${to} by digest`)
94
+ await compose(target, ['pull', ...CORE_SERVICES], { docker })
95
+ say(`recreating ${CORE_SERVICES.join(', ')} from ${to}; tmux, ttyd, and the agent containers keep running`)
96
+ say('an agent keeps the image it started on; the next agent uses the image of the target release')
97
+ say('the overseer replays a turn the switch interrupted, as it does after any restart')
98
+ await compose(target, ['up', '--detach', '--no-deps', ...CORE_SERVICES], { docker })
99
+
100
+ // 3. Acceptance, then 4. activation. A failure past the recreate goes back.
101
+ let adopted
102
+ try {
103
+ await waitForHealth(target, { docker, sleep, now, stdout })
104
+ adopted = await acceptRecreated({ to, live, read, sleep, now, say })
105
+ writeInstallationRecord(root, { ...record, activeVersion: to })
106
+ } catch (e) {
107
+ throw await switchBack({ previous, from, live, read, docker, sleep, now, stdout, cause: e })
108
+ }
109
+ say(`${to} is the active version; ${from} is kept for 'curia rollback'`)
110
+ for (const other of readdirSync(join(root, 'versions'))) {
111
+ if (other === to || other === from) continue
112
+ rmSync(join(root, 'versions', other), { recursive: true, force: true })
113
+ if (!other.startsWith('.')) say(`removed ${other}, which is no longer a rollback release`)
114
+ }
115
+ return adopted
116
+ }
117
+
118
+ // The sessions whose tmux pane is live, as the running service reports
119
+ // them, or null when the service does not answer.
120
+ export async function liveSessions(read) {
121
+ const overview = await read.json(`http://127.0.0.1:${SERVICE_PORT}/overview`)
122
+ if (!overview.ok || !Array.isArray(overview.body?.agents)) return null
123
+ return overview.body.agents.filter((a) => a?.tmux_live === true).map((a) => String(a.session)).sort()
124
+ }
125
+
126
+ // The recreated service must say the target version, the app must say it
127
+ // too, and every live session must be adopted. The version reads settle at
128
+ // once (a healthy service answers its version on the first read); the
129
+ // adoption settles when the boot reconcile has run, which the listener
130
+ // answers before. So the loop polls until the fleet is determinate and
131
+ // every session is either adopted or gone, and fails at the deadline on the
132
+ // first one still unadopted.
133
+ async function acceptRecreated({ to, live, read, sleep, now, say }) {
134
+ const service = await read.json(`http://127.0.0.1:${SERVICE_PORT}/ping`)
135
+ const app = await read.json(`http://127.0.0.1:${APP_PORT}/ping`)
136
+ const reported = (answer) => (answer.ok ? String(answer.body?.version ?? 'no version') : `no answer (${answer.error})`)
137
+ if (!service.ok || service.body?.version !== to || !app.ok || app.body?.version !== to) {
138
+ throw new SwitchError(`the service reports ${reported(service)} and the Curia app reports ${reported(app)}, not ${to}.`)
139
+ }
140
+ say(`the service reports ${to} and the Curia app reports ${to}`)
141
+
142
+ if (live === null || live.length === 0) return { adopted: [], ended: [] }
143
+ const started = now()
144
+ for (;;) {
145
+ const overview = await read.json(`http://127.0.0.1:${SERVICE_PORT}/overview`)
146
+ const agents = overview.ok && Array.isArray(overview.body?.agents) ? overview.body.agents : null
147
+ if (agents !== null) {
148
+ const untracked = new Set(Array.isArray(overview.body.untracked) ? overview.body.untracked.map(String) : [])
149
+ const adopted = live.filter((s) => agents.some((a) => String(a?.session) === s && a.tmux_live === true))
150
+ const ended = live.filter((s) => !adopted.includes(s) && !untracked.has(s))
151
+ const pending = live.filter((s) => !adopted.includes(s) && !ended.includes(s))
152
+ if (pending.length === 0) {
153
+ if (adopted.length > 0) say(`re-adopted ${adopted.length} live ${adopted.length === 1 ? 'session' : 'sessions'}: ${adopted.join(', ')}`)
154
+ for (const s of ended) say(`${s} ended during the switch; nothing to re-adopt`)
155
+ return { adopted, ended }
156
+ }
157
+ if (now() - started >= READOPTION_TIMEOUT_MS) {
158
+ throw new SwitchError(`${to} did not re-adopt ${pending[0]} within ${Math.round(READOPTION_TIMEOUT_MS / 1000)} seconds: its pane is live and the service does not track it.`)
159
+ }
160
+ } else if (now() - started >= READOPTION_TIMEOUT_MS) {
161
+ throw new SwitchError(`${to} did not report its agents within ${Math.round(READOPTION_TIMEOUT_MS / 1000)} seconds, so the ${live.length} live ${live.length === 1 ? 'session was' : 'sessions were'} not proven re-adopted.`)
162
+ }
163
+ await sleep(HEALTH_POLL_MS)
164
+ }
165
+ }
166
+
167
+ // One switch back to the release that was active, proven the way the target
168
+ // was (health, version, re-adoption of the same live sessions), then the
169
+ // failure that names both outcomes. The record was never changed.
170
+ async function switchBack({ previous, from, live, read, docker, sleep, now, stdout, cause }) {
171
+ const say = (text) => stdout.write(`${text}\n`)
172
+ say(`switching back to ${from}`)
173
+ let adopted
174
+ try {
175
+ await compose(previous, ['up', '--detach', '--no-deps', ...CORE_SERVICES], { docker })
176
+ await waitForHealth(previous, { docker, sleep, now, stdout })
177
+ adopted = await acceptRecreated({ to: from, live, read, sleep, now, say })
178
+ } catch (e) {
179
+ return new SwitchError(`${cause.message} The switch back to ${from} failed too: ${e.message} The record still names ${from}; run 'curia reinstall' to start it again, or read the logs first.`)
180
+ }
181
+ const n = adopted.adopted.length
182
+ const readopted = n === 0 ? '' : ` and re-adopted ${n} live ${n === 1 ? 'session' : 'sessions'}`
183
+ return new SwitchError(`${cause.message} Switched back to ${from}, which is healthy${readopted}. The record still names ${from}.`)
184
+ }
185
+
186
+ // A JSON read that answers instead of throwing.
187
+ function reader(fetchImpl) {
188
+ return {
189
+ async json(url) {
190
+ try {
191
+ const response = await fetchImpl(url, { redirect: 'manual', signal: AbortSignal.timeout(READ_TIMEOUT_MS) })
192
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }
193
+ return { ok: true, body: await response.json() }
194
+ } catch (e) {
195
+ return { ok: false, error: String(e.cause?.message ?? e.message).split('\n')[0] }
196
+ }
197
+ },
198
+ }
199
+ }
@@ -0,0 +1,156 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { lstatSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { writeAtomically } from './atomic.mjs'
6
+
7
+ // The Tailscale record of an installation and Curia's own Serve routes
8
+ // (#877 wrote the record; #886 withdraws the routes).
9
+ //
10
+ // `state/tailscale.json` holds the allowed operator, the machine name the
11
+ // operator expects, and the Serve routes Curia created. Nothing in it is a
12
+ // secret. The service (`daemon/src/tailscalesetup.mjs`) writes it during
13
+ // integration setup and reads it at boot; the lifecycle interface reads it
14
+ // at uninstall and purge to withdraw exactly the routes Curia created and no
15
+ // other. One reader and one writer live here, dependency-free, and the
16
+ // service imports them by relative path the way it imports `config.mjs`.
17
+ //
18
+ // CURIA DETECTS TAILSCALE AND NEVER CHANGES IT, except for its own routes.
19
+ // `withdrawServeRoutes` reads what the node serves, turns off only a recorded
20
+ // route that is standing, and leaves every other route alone. A route that
21
+ // is no longer standing is nothing to do, so a rerun is quiet.
22
+
23
+ export const TAILSCALE_FILE = 'tailscale.json'
24
+ export const tailscalePath = (stateDir) => join(stateDir, TAILSCALE_FILE)
25
+
26
+ // A Tailscale login as Serve stamps it: an email-shaped identity, or a
27
+ // GitHub-style `name@github` handle. Bounded, and never whitespace.
28
+ export const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}@[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/
29
+ // The machine name the operator types, the same shape `state/setup.json` keeps.
30
+ export const MACHINE_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i
31
+
32
+ const EMPTY = () => ({ operator: null, machine_name: null, serve: [] })
33
+
34
+ function checked(data, source) {
35
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error(`${source}: not a mapping`)
36
+ for (const key of Object.keys(data)) {
37
+ if (!['format', 'operator', 'machine_name', 'serve'].includes(key)) throw new Error(`${source}: unknown key ${key}`)
38
+ }
39
+ let operator = null
40
+ if (data.operator !== null && data.operator !== undefined) {
41
+ const op = data.operator
42
+ if (!op || typeof op !== 'object' || typeof op.login !== 'string' || !LOGIN_RE.test(op.login)) {
43
+ throw new Error(`${source}: operator.login must be a Tailscale login`)
44
+ }
45
+ operator = { login: op.login.toLowerCase(), confirmed_at: typeof op.confirmed_at === 'string' ? op.confirmed_at : null }
46
+ }
47
+ const machine = data.machine_name ?? null
48
+ if (machine !== null && (typeof machine !== 'string' || !MACHINE_NAME_RE.test(machine))) {
49
+ throw new Error(`${source}: machine_name must be a machine name or absent`)
50
+ }
51
+ const serve = data.serve ?? []
52
+ if (!Array.isArray(serve) || serve.some((r) => !r || !Number.isInteger(r.https) || typeof r.target !== 'string')) {
53
+ throw new Error(`${source}: serve must be a list of { https, target } routes`)
54
+ }
55
+ return { operator, machine_name: machine, serve: serve.map((r) => ({ https: r.https, target: r.target })) }
56
+ }
57
+
58
+ // The record, or the empty answer when there is no file: no operator is
59
+ // recorded, which is what a fresh installation runs until setup writes it.
60
+ export function readTailscaleRecord(stateDir) {
61
+ const file = tailscalePath(stateDir)
62
+ let text
63
+ try {
64
+ if (lstatSync(file).isSymbolicLink()) throw new Error(`${file} is a symbolic link. Replace the link with the real file.`)
65
+ text = readFileSync(file, 'utf8')
66
+ } catch (e) {
67
+ if (e.code === 'ENOENT') return EMPTY()
68
+ throw e
69
+ }
70
+ let data
71
+ try {
72
+ data = JSON.parse(text)
73
+ } catch {
74
+ throw new Error(`${file}: not JSON`)
75
+ }
76
+ return checked(data, file)
77
+ }
78
+
79
+ export function writeTailscaleRecord(stateDir, data) {
80
+ const record = checked(data, tailscalePath(stateDir))
81
+ writeAtomically(tailscalePath(stateDir), `${JSON.stringify({ format: 1, ...record }, null, 2)}\n`, { mode: 0o600 })
82
+ return record
83
+ }
84
+
85
+ // The routes in a `tailscale serve status --json` answer, which is the
86
+ // node's whole serve config: `{ Web: { "<host>:<port>": { Handlers: { "/":
87
+ // { Proxy } } } } }`.
88
+ export function serveRoutes(config) {
89
+ const out = []
90
+ for (const [hostPort, site] of Object.entries(config?.Web ?? {})) {
91
+ const port = Number(hostPort.split(':').pop())
92
+ for (const [mount, handler] of Object.entries(site?.Handlers ?? {})) {
93
+ if (handler?.Proxy) out.push({ https: port, mount, target: String(handler.Proxy) })
94
+ }
95
+ }
96
+ return out
97
+ }
98
+
99
+ // The real `tailscale` CLI, the same shape as `dockerRunner` in compose.mjs:
100
+ // one invocation, output captured, never a throw.
101
+ export const tailscaleRunner = (args, { timeoutMs = 60_000 } = {}) => new Promise((resolve) => {
102
+ execFile('tailscale', args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
103
+ if (!error) return resolve({ ok: true, stdout, stderr, code: 0 })
104
+ resolve({ ok: false, stdout, stderr: stderr || error.message, code: error.code, missing: error.code === 'ENOENT', timedOut: Boolean(error.killed) })
105
+ })
106
+ })
107
+
108
+ export class TailscaleError extends Error {
109
+ constructor(message) {
110
+ super(message)
111
+ this.name = 'TailscaleError'
112
+ }
113
+ }
114
+
115
+ // Withdraws the Serve routes the record under `stateDir` names, and no
116
+ // other. Returns `{ recorded, withdrawn, absent, unreachable }`: the
117
+ // recorded routes, the ones turned off on this call, the ones that were not
118
+ // standing, and whether the node could be asked at all. The `tailscale`
119
+ // command missing from the path counts every route as absent and the node
120
+ // as unreachable: with no CLI there is no node this operator can reach, and
121
+ // the record is kept for the next run that can. A node that answers with an
122
+ // error fails, because a route may still stand.
123
+ export async function withdrawServeRoutes({ stateDir, stdout }, { tailscale = tailscaleRunner } = {}) {
124
+ const recorded = readTailscaleRecord(stateDir).serve
125
+ if (recorded.length === 0) return { recorded, withdrawn: [], absent: [], unreachable: false }
126
+
127
+ const status = await tailscale(['serve', 'status', '--json'])
128
+ if (!status.ok && status.missing) {
129
+ stdout?.write(`the tailscale command is not on the path, so no Serve route is withdrawn; the record keeps ${recorded.length === 1 ? 'the route' : 'the routes'} for a host that runs Tailscale\n`)
130
+ return { recorded, withdrawn: [], absent: recorded, unreachable: true }
131
+ }
132
+ if (!status.ok) throw new TailscaleError(`tailscale serve status failed: ${firstLine(status.stderr || status.stdout) || `exit ${status.code}`}`)
133
+ let standing
134
+ try {
135
+ standing = serveRoutes(JSON.parse(status.stdout.trim() || '{}'))
136
+ } catch {
137
+ throw new TailscaleError('tailscale serve status --json did not answer JSON')
138
+ }
139
+
140
+ const withdrawn = []
141
+ const absent = []
142
+ for (const route of recorded) {
143
+ const stands = standing.some((s) => s.https === route.https && s.target === route.target)
144
+ if (!stands) {
145
+ absent.push(route)
146
+ continue
147
+ }
148
+ const off = await tailscale(['serve', `--https=${route.https}`, 'off'])
149
+ if (!off.ok) throw new TailscaleError(`tailscale serve --https=${route.https} off failed: ${firstLine(off.stderr || off.stdout) || `exit ${off.code}`}`)
150
+ stdout?.write(`withdrew the Serve route https://:${route.https} -> ${route.target}\n`)
151
+ withdrawn.push(route)
152
+ }
153
+ return { recorded, withdrawn, absent, unreachable: false }
154
+ }
155
+
156
+ const firstLine = (text) => String(text ?? '').trim().split('\n')[0]