@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/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
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]
|