@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/archive.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { gunzipSync } from 'node:zlib'
|
|
2
|
+
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, normalize, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// A reader and an extractor for the gzipped tar archives a release is made
|
|
6
|
+
// of: the npm tarball of `@curia-sh/cli`, the Compose bundle archive, and the
|
|
7
|
+
// Node.js runtime distribution. The questions asked of an archive are "which
|
|
8
|
+
// files are in it", "what do they hold", and, for `curia update` (#883),
|
|
9
|
+
// "put it on disk as the runtime and the package". A reader for that is
|
|
10
|
+
// short, and it keeps the package free of dependencies and the tests free of
|
|
11
|
+
// a system `tar`.
|
|
12
|
+
//
|
|
13
|
+
// It reads ustar and the pax and GNU variants that `npm pack`, GNU tar, and
|
|
14
|
+
// the Node.js release build produce: regular files by their full path, with
|
|
15
|
+
// the ustar prefix field and a pax `path` header honored, directories,
|
|
16
|
+
// symbolic links, and file modes. It refuses bytes it cannot read as an
|
|
17
|
+
// archive rather than returning a partial result, and an extraction refuses
|
|
18
|
+
// an entry or a link target that would land outside the destination.
|
|
19
|
+
|
|
20
|
+
export class ArchiveError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'ArchiveError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BLOCK = 512
|
|
28
|
+
|
|
29
|
+
// Returns a Map from each regular file's path to its bytes.
|
|
30
|
+
export function readArchive(bytes) {
|
|
31
|
+
const files = new Map()
|
|
32
|
+
for (const entry of entries(bytes)) {
|
|
33
|
+
if (entry.type === '0') files.set(entry.name, Buffer.from(entry.data))
|
|
34
|
+
}
|
|
35
|
+
return files
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Lands the archive under `dir`, which must exist, with the first `strip`
|
|
39
|
+
// path segments of every entry removed, so `node-v24.19.0-linux-x64/bin/node`
|
|
40
|
+
// becomes `<dir>/bin/node`. Regular files keep their mode bits, directories
|
|
41
|
+
// are created as needed, symbolic links are recreated with their targets.
|
|
42
|
+
// An entry that is not a file, a directory, or a symbolic link is skipped.
|
|
43
|
+
export function extractArchive(bytes, dir, { strip = 0 } = {}) {
|
|
44
|
+
const base = resolve(dir)
|
|
45
|
+
const inside = (name) => {
|
|
46
|
+
const parts = name.split('/').filter((p) => p !== '' && p !== '.')
|
|
47
|
+
if (parts.length <= strip) return null
|
|
48
|
+
const target = resolve(base, ...parts.slice(strip))
|
|
49
|
+
if (target !== base && !target.startsWith(base + sep)) throw new ArchiveError(`the archive entry ${name} would land outside ${dir}`)
|
|
50
|
+
return target
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries(bytes)) {
|
|
53
|
+
const target = inside(entry.name)
|
|
54
|
+
if (target === null) continue
|
|
55
|
+
if (entry.type === '5') {
|
|
56
|
+
mkdirSync(target, { recursive: true })
|
|
57
|
+
} else if (entry.type === '0') {
|
|
58
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
59
|
+
writeFileSync(target, entry.data, { mode: entry.mode })
|
|
60
|
+
chmodSync(target, entry.mode)
|
|
61
|
+
} else if (entry.type === '2') {
|
|
62
|
+
const pointed = normalize(join(dirname(target), entry.linkName))
|
|
63
|
+
if (pointed !== base && !pointed.startsWith(base + sep)) throw new ArchiveError(`the archive link ${entry.name} points outside ${dir}`)
|
|
64
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
65
|
+
symlinkSync(entry.linkName, target)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Walks the archive one entry at a time: `{ name, type, mode, data, linkName }`
|
|
71
|
+
// for each entry that is not a pax or GNU header, with those headers already
|
|
72
|
+
// applied to the entry that follows them.
|
|
73
|
+
function* entries(bytes) {
|
|
74
|
+
let tar
|
|
75
|
+
try {
|
|
76
|
+
tar = gunzipSync(bytes)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw new ArchiveError(`not a gzip stream: ${e.message}`)
|
|
79
|
+
}
|
|
80
|
+
if (tar.length < BLOCK * 2 || tar.length % BLOCK !== 0) throw new ArchiveError('not a tar archive: the length is not whole blocks')
|
|
81
|
+
|
|
82
|
+
let at = 0
|
|
83
|
+
let paxPath = null
|
|
84
|
+
let paxLink = null
|
|
85
|
+
let longName = null
|
|
86
|
+
let longLink = null
|
|
87
|
+
while (at + BLOCK <= tar.length) {
|
|
88
|
+
const header = tar.subarray(at, at + BLOCK)
|
|
89
|
+
if (header.every((b) => b === 0)) break
|
|
90
|
+
|
|
91
|
+
const magic = header.toString('latin1', 257, 263)
|
|
92
|
+
if (!/^ustar/.test(magic)) throw new ArchiveError(`not a tar archive: no ustar header at block ${at / BLOCK}`)
|
|
93
|
+
if (!checksumHolds(header)) throw new ArchiveError(`not a tar archive: the header checksum at block ${at / BLOCK} does not hold`)
|
|
94
|
+
|
|
95
|
+
const size = parseInt(field(header, 124, 12), 8)
|
|
96
|
+
if (!Number.isInteger(size) || size < 0) throw new ArchiveError(`not a tar archive: an unreadable size at block ${at / BLOCK}`)
|
|
97
|
+
const type = header[156] === 0 ? '0' : String.fromCharCode(header[156])
|
|
98
|
+
const dataStart = at + BLOCK
|
|
99
|
+
const dataEnd = dataStart + size
|
|
100
|
+
if (dataEnd > tar.length) throw new ArchiveError('the archive is truncated')
|
|
101
|
+
const data = tar.subarray(dataStart, dataEnd)
|
|
102
|
+
|
|
103
|
+
if (type === 'x') {
|
|
104
|
+
paxPath = paxField(data, 'path')
|
|
105
|
+
paxLink = paxField(data, 'linkpath')
|
|
106
|
+
} else if (type === 'L') {
|
|
107
|
+
longName = data.toString('utf8').replace(/\0+$/, '')
|
|
108
|
+
} else if (type === 'K') {
|
|
109
|
+
longLink = data.toString('utf8').replace(/\0+$/, '')
|
|
110
|
+
} else {
|
|
111
|
+
let name = paxPath ?? longName ?? field(header, 0, 100)
|
|
112
|
+
if (!paxPath && !longName) {
|
|
113
|
+
const prefix = field(header, 345, 155)
|
|
114
|
+
if (prefix) name = `${prefix}/${name}`
|
|
115
|
+
}
|
|
116
|
+
const mode = parseInt(field(header, 100, 8).trim(), 8) & 0o777
|
|
117
|
+
const linkName = paxLink ?? longLink ?? field(header, 157, 100)
|
|
118
|
+
yield { name, type, mode: Number.isInteger(mode) ? mode : 0o644, data, linkName }
|
|
119
|
+
paxPath = null
|
|
120
|
+
paxLink = null
|
|
121
|
+
longName = null
|
|
122
|
+
longLink = null
|
|
123
|
+
}
|
|
124
|
+
at = dataStart + Math.ceil(size / BLOCK) * BLOCK
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function field(header, start, length) {
|
|
129
|
+
return header.toString('utf8', start, start + length).replace(/\0.*$/s, '')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checksumHolds(header) {
|
|
133
|
+
const stored = parseInt(field(header, 148, 8).trim(), 8)
|
|
134
|
+
let sum = 0
|
|
135
|
+
for (let i = 0; i < BLOCK; i += 1) sum += i >= 148 && i < 156 ? 32 : header[i]
|
|
136
|
+
return sum === stored
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A pax extended header is `<length> <key>=<value>\n` records.
|
|
140
|
+
function paxField(data, key) {
|
|
141
|
+
const text = data.toString('utf8')
|
|
142
|
+
let at = 0
|
|
143
|
+
while (at < text.length) {
|
|
144
|
+
const space = text.indexOf(' ', at)
|
|
145
|
+
if (space < 0) break
|
|
146
|
+
const length = parseInt(text.slice(at, space), 10)
|
|
147
|
+
if (!Number.isInteger(length) || length <= 0) break
|
|
148
|
+
const record = text.slice(space + 1, at + length - 1)
|
|
149
|
+
const eq = record.indexOf('=')
|
|
150
|
+
if (eq > 0 && record.slice(0, eq) === key) return record.slice(eq + 1)
|
|
151
|
+
at += length
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
package/src/atomic.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
// The one way a lifecycle command writes a critical file: the installation
|
|
6
|
+
// record, operator configuration, a secret. The content goes to a fresh
|
|
7
|
+
// temporary file beside the target, is fsynced, and is renamed over the target
|
|
8
|
+
// in one step. A reader sees the old file or the new one, never a partial one,
|
|
9
|
+
// and a crash leaves at most a temporary file that the next write ignores.
|
|
10
|
+
//
|
|
11
|
+
// The mode is set on the temporary file at creation, so the target never
|
|
12
|
+
// spends a moment with broader permissions than requested. Because rename
|
|
13
|
+
// replaces the directory entry, a symbolic link sitting at the target is
|
|
14
|
+
// replaced by the file rather than followed. The directory is fsynced after the
|
|
15
|
+
// rename so the new entry is durable too.
|
|
16
|
+
export function writeAtomically(path, content, { mode }) {
|
|
17
|
+
const dir = dirname(path)
|
|
18
|
+
const temp = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`)
|
|
19
|
+
let fd
|
|
20
|
+
try {
|
|
21
|
+
fd = openSync(temp, 'wx', mode)
|
|
22
|
+
writeSync(fd, content)
|
|
23
|
+
fsyncSync(fd)
|
|
24
|
+
closeSync(fd)
|
|
25
|
+
fd = undefined
|
|
26
|
+
renameSync(temp, path)
|
|
27
|
+
} catch (e) {
|
|
28
|
+
if (fd !== undefined) closeSync(fd)
|
|
29
|
+
try { unlinkSync(temp) } catch {}
|
|
30
|
+
throw e
|
|
31
|
+
}
|
|
32
|
+
const dirFd = openSync(dir, 'r')
|
|
33
|
+
try {
|
|
34
|
+
fsyncSync(dirFd)
|
|
35
|
+
} finally {
|
|
36
|
+
closeSync(dirFd)
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/bundle.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
|
|
3
|
+
// The versioned Compose bundle (#869, implementing #849, #851, and #854).
|
|
4
|
+
//
|
|
5
|
+
// A release is one immutable set of container images plus one Compose file
|
|
6
|
+
// that names them by digest. This module is the contract between the three
|
|
7
|
+
// parties that touch that file: the release workflow that renders it from
|
|
8
|
+
// `deploy/bundle/compose.yaml`, the tests that inspect what was rendered, and
|
|
9
|
+
// the lifecycle interface that starts it under an installation root. It is
|
|
10
|
+
// text in and text out, with no YAML reader, because the package has no
|
|
11
|
+
// dependencies and the questions are answerable by line.
|
|
12
|
+
//
|
|
13
|
+
// Three facts every party shares:
|
|
14
|
+
//
|
|
15
|
+
// - the Compose project is always `curia`, so `docker compose -p curia` and
|
|
16
|
+
// the labels Compose adds name one installation's containers, network, and
|
|
17
|
+
// volume on a host;
|
|
18
|
+
// - every container, the network, and the volume carry the installation ID
|
|
19
|
+
// under `sh.curia.installation`, which is what `curia purge` (#855) removes
|
|
20
|
+
// by and never a name prefix;
|
|
21
|
+
// - the bundle interpolates paths and numbers only, the five variables in
|
|
22
|
+
// `BUNDLE_VARIABLES`, which `curia install` (#873) writes into an env file
|
|
23
|
+
// under `run/` from facts it holds. Never a secret.
|
|
24
|
+
//
|
|
25
|
+
// The release manifest (#870) binds the bundle's checksum and the same
|
|
26
|
+
// digests; the publication order and the stable index are #871's.
|
|
27
|
+
|
|
28
|
+
export class BundleError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'BundleError'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const COMPOSE_PROJECT = 'curia'
|
|
36
|
+
export const INSTALLATION_LABEL = 'sh.curia.installation'
|
|
37
|
+
export const IMAGE_REGISTRY = 'ghcr.io/alp82'
|
|
38
|
+
|
|
39
|
+
// The four images a release builds, keyed by the service that runs them. The
|
|
40
|
+
// attach surface (`ttyd`) runs the tmux image, so it has no image of its own.
|
|
41
|
+
// The agent image is not here: the service builds it on the host from the
|
|
42
|
+
// recipe the service image carries, content-addressed by its pins.
|
|
43
|
+
export const RELEASE_IMAGES = Object.freeze({
|
|
44
|
+
daemon: 'curia-daemon',
|
|
45
|
+
tmux: 'curia-tmux',
|
|
46
|
+
dashboard: 'curia-dashboard',
|
|
47
|
+
overseer: 'curia-overseer',
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// What a started bundle interpolates, in the order the env file lists them.
|
|
51
|
+
export const BUNDLE_VARIABLES = Object.freeze(['CURIA_ROOT', 'CURIA_UID', 'CURIA_GID', 'DOCKER_GID', 'CURIA_INSTALLATION_ID'])
|
|
52
|
+
|
|
53
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
54
|
+
const INSTALLATION_ID = /^[0-9a-f]{32}$/
|
|
55
|
+
const IMAGE_VARIABLE = /\$\{CURIA_([A-Z]+)_IMAGE(?::\?[^}]*)?\}/g
|
|
56
|
+
const ANY_VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)/g
|
|
57
|
+
const IMAGE_LINE = /^\s*image:\s*(\S+)\s*$/
|
|
58
|
+
const OPERATOR_PATH = /\/home\/[A-Za-z0-9_-]+/
|
|
59
|
+
|
|
60
|
+
export function imageReference(service, digest) {
|
|
61
|
+
const name = RELEASE_IMAGES[service]
|
|
62
|
+
if (!name) throw new BundleError(`no release image is built for ${service}`)
|
|
63
|
+
if (typeof digest !== 'string' || !DIGEST.test(digest)) {
|
|
64
|
+
throw new BundleError(`the ${service} image needs a sha256 digest, got ${JSON.stringify(digest)}`)
|
|
65
|
+
}
|
|
66
|
+
return `${IMAGE_REGISTRY}/${name}@${digest}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The template with each `${CURIA_<SERVICE>_IMAGE...}` replaced by that
|
|
70
|
+
// service's digest reference. Every other variable is left as it is.
|
|
71
|
+
export function renderBundle(template, digests) {
|
|
72
|
+
return template.replace(IMAGE_VARIABLE, (whole, upper) => {
|
|
73
|
+
const service = upper.toLowerCase()
|
|
74
|
+
if (!RELEASE_IMAGES[service]) throw new BundleError(`the template names ${whole.slice(2, whole.indexOf('_IMAGE') + 6)}, which no release builds`)
|
|
75
|
+
return imageReference(service, digests?.[service])
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const referencePattern = new RegExp(`^${IMAGE_REGISTRY.replace(/[.]/g, '\\.')}/(${Object.values(RELEASE_IMAGES).join('|')})@sha256:[0-9a-f]{64}$`)
|
|
80
|
+
|
|
81
|
+
// The problems a rendered bundle has, as one line each. Empty means it is fit
|
|
82
|
+
// to publish: one fixed project name, every image an exact digest under the
|
|
83
|
+
// registry, only the run-time variables, no build, no env file, no path of
|
|
84
|
+
// anyone's home.
|
|
85
|
+
export function inspectBundle(text) {
|
|
86
|
+
const problems = []
|
|
87
|
+
const lines = text.split('\n')
|
|
88
|
+
if (!lines.some((l) => l === `name: ${COMPOSE_PROJECT}`)) {
|
|
89
|
+
problems.push(`the project name must be \`name: ${COMPOSE_PROJECT}\` at the top level`)
|
|
90
|
+
}
|
|
91
|
+
lines.forEach((line, i) => {
|
|
92
|
+
const n = i + 1
|
|
93
|
+
const image = IMAGE_LINE.exec(line)
|
|
94
|
+
if (image && !referencePattern.test(image[1])) {
|
|
95
|
+
problems.push(`line ${n}: image ${image[1]} is not a digest reference under ${IMAGE_REGISTRY}`)
|
|
96
|
+
}
|
|
97
|
+
for (const m of line.matchAll(ANY_VARIABLE)) {
|
|
98
|
+
if (!BUNDLE_VARIABLES.includes(m[1])) problems.push(`line ${n}: variable ${m[1]} is not one the lifecycle interface writes`)
|
|
99
|
+
}
|
|
100
|
+
if (/^\s*build:/.test(line)) problems.push(`line ${n}: a build stanza; the bundle runs published images only`)
|
|
101
|
+
if (/^\s*env_file:/.test(line)) problems.push(`line ${n}: env_file; the bundle loads no env file`)
|
|
102
|
+
if (OPERATOR_PATH.test(line)) problems.push(`line ${n}: an operator path, ${OPERATOR_PATH.exec(line)[0]}`)
|
|
103
|
+
})
|
|
104
|
+
return problems
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The env file `curia install` writes under `run/` and passes with
|
|
108
|
+
// `--env-file`. Paths and numbers, one per line, never a secret.
|
|
109
|
+
export function bundleEnvironment({ root, uid, gid, dockerGid, installationId }) {
|
|
110
|
+
if (typeof root !== 'string' || !isAbsolute(root)) throw new BundleError(`CURIA_ROOT must be an absolute path, got ${JSON.stringify(root)}`)
|
|
111
|
+
for (const [name, value] of [['CURIA_UID', uid], ['CURIA_GID', gid], ['DOCKER_GID', dockerGid]]) {
|
|
112
|
+
if (!(Number.isInteger(value) && value >= 0)) throw new BundleError(`${name} must be a non-negative whole number, got ${JSON.stringify(value)}`)
|
|
113
|
+
}
|
|
114
|
+
if (typeof installationId !== 'string' || !INSTALLATION_ID.test(installationId)) {
|
|
115
|
+
throw new BundleError(`CURIA_INSTALLATION_ID must be the 32-hex installation ID, got ${JSON.stringify(installationId)}`)
|
|
116
|
+
}
|
|
117
|
+
const values = { CURIA_ROOT: root, CURIA_UID: uid, CURIA_GID: gid, DOCKER_GID: dockerGid, CURIA_INSTALLATION_ID: installationId }
|
|
118
|
+
return BUNDLE_VARIABLES.map((name) => `${name}=${values[name]}\n`).join('')
|
|
119
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { EXIT, Refusal, UsageError } from './exit.mjs'
|
|
2
|
+
import { commands as lifecycleCommands, packageVersion } from './commands.mjs'
|
|
3
|
+
import { installationRoot } from './root.mjs'
|
|
4
|
+
|
|
5
|
+
// The lifecycle interface's one entry point. `bin/curia.mjs` calls it with the
|
|
6
|
+
// process's argv, env, and streams and exits with what it returns. Tests call
|
|
7
|
+
// it with their own, hand in a `uid` and `gid` to stand in for another operator, and can
|
|
8
|
+
// hand in a `commands` table to observe how the core treats a command that
|
|
9
|
+
// throws.
|
|
10
|
+
export async function runCli({ argv, env, stdout, stderr, uid = process.getuid(), gid = process.getgid(), commands = lifecycleCommands }) {
|
|
11
|
+
const [name, ...args] = argv
|
|
12
|
+
|
|
13
|
+
if (name === undefined) {
|
|
14
|
+
stderr.write(usage(commands))
|
|
15
|
+
return EXIT.usage
|
|
16
|
+
}
|
|
17
|
+
if (name === '--version' || name === '-V') {
|
|
18
|
+
stdout.write(`curia ${packageVersion}\n`)
|
|
19
|
+
return EXIT.ok
|
|
20
|
+
}
|
|
21
|
+
if (name === 'help' || name === '--help' || name === '-h') {
|
|
22
|
+
stdout.write(usage(commands))
|
|
23
|
+
return EXIT.ok
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const command = commands[name]
|
|
27
|
+
if (!command) {
|
|
28
|
+
stderr.write(`curia: unknown command: ${name}\nRun 'curia help' for the command vocabulary.\n`)
|
|
29
|
+
return EXIT.usage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A command that declares no options takes none: an option it does not know
|
|
33
|
+
// is a usage error before anything runs, never something a stub swallows. A
|
|
34
|
+
// command that declares `options` reads its own and throws a `UsageError`
|
|
35
|
+
// for one it does not know.
|
|
36
|
+
const options = args.filter((a) => a.startsWith('-'))
|
|
37
|
+
if (!command.options && options.length > 0) {
|
|
38
|
+
stderr.write(`curia ${name}: unknown option: ${options[0]}\nRun 'curia help' for the command vocabulary.\n`)
|
|
39
|
+
return EXIT.usage
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return await command.run({ env, args, stdout, stderr, uid, gid, root: installationRoot(env) })
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e instanceof UsageError) {
|
|
46
|
+
stderr.write(`curia ${name}: ${e.message}\nRun 'curia help' for the command vocabulary.\n`)
|
|
47
|
+
return EXIT.usage
|
|
48
|
+
}
|
|
49
|
+
stderr.write(`curia ${name}: ${e.message}\n`)
|
|
50
|
+
return e instanceof Refusal ? EXIT.refused : EXIT.failed
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function usage(commands = lifecycleCommands) {
|
|
55
|
+
const width = Math.max(...Object.keys(commands).map((n) => n.length))
|
|
56
|
+
const lines = Object.entries(commands).map(([n, c]) => ` ${n.padEnd(width)} ${c.summary}`)
|
|
57
|
+
return [
|
|
58
|
+
'usage: curia <command>',
|
|
59
|
+
'',
|
|
60
|
+
'Commands:',
|
|
61
|
+
...lines,
|
|
62
|
+
` ${'help'.padEnd(width)} Print this text.`,
|
|
63
|
+
'',
|
|
64
|
+
'Exit codes:',
|
|
65
|
+
` ${EXIT.ok} ok The command did what it said.`,
|
|
66
|
+
` ${EXIT.failed} failed The operation started and failed. The message says what to do next.`,
|
|
67
|
+
` ${EXIT.usage} usage The command line was wrong. Nothing ran.`,
|
|
68
|
+
` ${EXIT.refused} refused Curia refused to start. Nothing changed. The message names the condition.`,
|
|
69
|
+
'',
|
|
70
|
+
'The installation root comes from CURIA_ROOT, which the installed launcher sets.',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n')
|
|
73
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { EXIT } from './exit.mjs'
|
|
4
|
+
import { installationRoot, readInstallationRecord } from './root.mjs'
|
|
5
|
+
import { installCommand } from './install.mjs'
|
|
6
|
+
import { runDoctor } from './doctor.mjs'
|
|
7
|
+
import { updateCommand } from './update.mjs'
|
|
8
|
+
import { rollbackCommand } from './rollback.mjs'
|
|
9
|
+
import { uninstallCommand } from './uninstall.mjs'
|
|
10
|
+
import { purgeCommandRun } from './purge.mjs'
|
|
11
|
+
|
|
12
|
+
export const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
13
|
+
|
|
14
|
+
// One lifecycle command. `run(context)` gets `{ env, args, stdout, stderr, uid, root }`
|
|
15
|
+
// and returns an exit code, throws a `Refusal` to exit `refused` without a
|
|
16
|
+
// change, or throws any other error to exit `failed`. Commands print on their
|
|
17
|
+
// own streams and never call `process.exit`, which keeps every one of them
|
|
18
|
+
// callable from a test.
|
|
19
|
+
//
|
|
20
|
+
// Every lifecycle command enters its root through `openRoot` first, so the
|
|
21
|
+
// boundary refusals (root execution, foreign ownership, broad permissions,
|
|
22
|
+
// symbolic links, an unknown nonempty root) come before anything the command
|
|
23
|
+
// itself does. `version` is read-only and skips the boundary on purpose: it
|
|
24
|
+
// reports the root even when a lifecycle command would refuse it.
|
|
25
|
+
|
|
26
|
+
async function version({ env, stdout }) {
|
|
27
|
+
stdout.write(`curia ${packageVersion}\n`)
|
|
28
|
+
const root = installationRoot(env)
|
|
29
|
+
const record = readInstallationRecord(root)
|
|
30
|
+
stdout.write(`active version: ${record ? record.activeVersion : 'none (no installation record)'}\n`)
|
|
31
|
+
stdout.write(`installation root: ${root}\n`)
|
|
32
|
+
return EXIT.ok
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Order matters: `curia help` lists the commands in lifecycle order.
|
|
36
|
+
export const commands = {
|
|
37
|
+
install: { summary: 'Install Curia into the installation root and start it.', run: installCommand('install') },
|
|
38
|
+
reinstall: { summary: 'Reinstall this version over a preserved installation root, keeping its identity, configuration, secrets, state, and work.', run: installCommand('reinstall') },
|
|
39
|
+
update: { summary: 'Stage, verify, and switch to the latest stable release, or to an exact version (--prerelease for an exact prerelease).', run: updateCommand, options: true },
|
|
40
|
+
rollback: { summary: 'Switch back to the one retained previous release, after it validates the current configuration.', run: rollbackCommand },
|
|
41
|
+
doctor: { summary: 'Check the host, configuration, integrations, and services. Read-only.', run: runDoctor },
|
|
42
|
+
uninstall: { summary: 'Stop Curia and remove the launcher, versions/, cache/, run/, the installation\'s containers, networks, volumes, and Serve routes; keep config/, secrets/, state/, and work/ for a reinstall.', run: uninstallCommand },
|
|
43
|
+
purge: { summary: 'Remove the entire installation root, every Curia-labelled Docker resource, the unused release images, and the Serve routes, after one confirmation (type the root, or pass --confirm <root>).', run: purgeCommandRun, options: true },
|
|
44
|
+
version: { summary: 'Print the lifecycle interface version and the active installed version.', run: version },
|
|
45
|
+
}
|
package/src/compose.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeAtomically } from './atomic.mjs'
|
|
5
|
+
import { bundleEnvironment } from './bundle.mjs'
|
|
6
|
+
import { SERVICES } from './layout.mjs'
|
|
7
|
+
import { versionPaths } from './root.mjs'
|
|
8
|
+
|
|
9
|
+
// The one seam between the lifecycle interface and Docker Compose (#873,
|
|
10
|
+
// implementing #851 and #854).
|
|
11
|
+
//
|
|
12
|
+
// An installed version's Compose bundle is started, watched, and later
|
|
13
|
+
// switched or torn down through this module and nothing else. It knows three
|
|
14
|
+
// things: where the project's files are for one version of one root, how to
|
|
15
|
+
// run `docker compose` against them, and what "healthy" means for the five
|
|
16
|
+
// services the bundle declares. Every Docker call goes through `dockerRunner`,
|
|
17
|
+
// which a test replaces with a fake, so the install sequence is proven against
|
|
18
|
+
// packaged fixtures without a Docker daemon.
|
|
19
|
+
//
|
|
20
|
+
// The project name is in the bundle itself (`name: curia`), so no command here
|
|
21
|
+
// passes one. What a command passes is the env file under `run/` with the five
|
|
22
|
+
// run-time values (paths and numbers, never a secret) and the bundle file of
|
|
23
|
+
// the version.
|
|
24
|
+
|
|
25
|
+
export class ComposeError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'ComposeError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// How long a start may take before a service that is still starting counts
|
|
33
|
+
// as failed: the daemon's start period is 60 s and every check allows three
|
|
34
|
+
// 30 s retries, so four minutes covers a slow first pull of the agent image
|
|
35
|
+
// recipe and a cold journal open.
|
|
36
|
+
export const HEALTH_TIMEOUT_MS = 240_000
|
|
37
|
+
export const HEALTH_POLL_MS = 2_000
|
|
38
|
+
|
|
39
|
+
export function composeEnvPath(root) {
|
|
40
|
+
return join(root, 'run', 'compose.env')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The project of `version` under `root`: the env file, the bundle file, and
|
|
44
|
+
// the `docker` arguments for one Compose verb against them.
|
|
45
|
+
export function composeProject({ root, version }) {
|
|
46
|
+
const envFile = composeEnvPath(root)
|
|
47
|
+
const file = versionPaths(root, version).bundle
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
root,
|
|
50
|
+
version,
|
|
51
|
+
envFile,
|
|
52
|
+
file,
|
|
53
|
+
args: (...verb) => ['compose', '--env-file', envFile, '-f', file, ...verb],
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Writes `run/compose.env` owner-only. `run/` exists because `ensureLayout`
|
|
58
|
+
// created it.
|
|
59
|
+
export function writeComposeEnvironment(project, { uid, gid, dockerGid, installationId }) {
|
|
60
|
+
writeAtomically(project.envFile, bundleEnvironment({ root: project.root, uid, gid, dockerGid, installationId }), { mode: 0o600 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The real runner: one `docker` invocation, its output captured. Compose
|
|
64
|
+
// prints progress on stderr, which is returned with the result so a failure
|
|
65
|
+
// can quote it.
|
|
66
|
+
export const dockerRunner = (args, { timeoutMs = 600_000 } = {}) => new Promise((resolve) => {
|
|
67
|
+
execFile('docker', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
68
|
+
if (!error) return resolve({ ok: true, stdout, stderr, code: 0 })
|
|
69
|
+
resolve({ ok: false, stdout, stderr: stderr || error.message, code: error.code, missing: error.code === 'ENOENT', timedOut: Boolean(error.killed) })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export async function compose(project, verb, { docker }) {
|
|
74
|
+
const args = project.args(...verb)
|
|
75
|
+
const result = await docker(args)
|
|
76
|
+
if (result.ok) return result
|
|
77
|
+
const detail = result.missing ? 'docker is not on the path' : (result.stderr || result.stdout || `exit ${result.code}`).trim().split('\n').slice(-5).join('\n')
|
|
78
|
+
throw new ComposeError(`docker ${args.join(' ')} failed:\n${detail}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pulls every image by its digest, then brings the project up detached.
|
|
82
|
+
// `--remove-orphans` retires a container of a service the bundle no longer
|
|
83
|
+
// declares, which is what a reinstall over an older bundle needs.
|
|
84
|
+
export async function startProject(project, { docker = dockerRunner, stdout }) {
|
|
85
|
+
stdout?.write(`pulling the images of ${project.version} by digest\n`)
|
|
86
|
+
await compose(project, ['pull'], { docker })
|
|
87
|
+
stdout?.write(`starting the Compose project\n`)
|
|
88
|
+
await compose(project, ['up', '--detach', '--remove-orphans', '--quiet-pull'], { docker })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `docker compose ps --format json` prints one object per line since Compose
|
|
92
|
+
// 2.21 and one array before that. Both read into the same list.
|
|
93
|
+
export function parseServiceStates(text) {
|
|
94
|
+
const trimmed = text.trim()
|
|
95
|
+
if (trimmed === '') return []
|
|
96
|
+
const rows = trimmed.startsWith('[') ? JSON.parse(trimmed) : trimmed.split('\n').map((line) => JSON.parse(line))
|
|
97
|
+
return rows.map((row) => ({
|
|
98
|
+
service: row.Service,
|
|
99
|
+
state: row.State ?? '',
|
|
100
|
+
health: row.Health ?? '',
|
|
101
|
+
exitCode: Number.isInteger(row.ExitCode) ? row.ExitCode : null,
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function serviceStates(project, { docker = dockerRunner }) {
|
|
106
|
+
const result = await compose(project, ['ps', '--all', '--format', 'json'], { docker })
|
|
107
|
+
return parseServiceStates(result.stdout)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Waits until every service the bundle declares reports healthy. A service
|
|
111
|
+
// that exited, or that Docker marks unhealthy, fails at once: waiting cannot
|
|
112
|
+
// fix it. A service still starting when the deadline passes fails too. The
|
|
113
|
+
// failure names the service and the log command that shows why.
|
|
114
|
+
export async function waitForHealth(project, { docker = dockerRunner, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, timeoutMs = HEALTH_TIMEOUT_MS, stdout } = {}) {
|
|
115
|
+
const started = now()
|
|
116
|
+
const logs = (service) => `Read its log with 'docker compose --env-file ${project.envFile} -f ${project.file} logs ${service}', fix the cause, and run the command again.`
|
|
117
|
+
for (;;) {
|
|
118
|
+
const states = await serviceStates(project, { docker })
|
|
119
|
+
const pending = []
|
|
120
|
+
for (const service of SERVICES) {
|
|
121
|
+
const s = states.find((x) => x.service === service)
|
|
122
|
+
if (!s) throw new ComposeError(`${service} is not in the project. ${logs(service)}`)
|
|
123
|
+
if (s.state === 'exited' || s.state === 'dead') throw new ComposeError(`${service} exited with code ${s.exitCode ?? '?'}. ${logs(service)}`)
|
|
124
|
+
if (s.health === 'unhealthy') throw new ComposeError(`${service} is unhealthy. ${logs(service)}`)
|
|
125
|
+
if (s.health !== 'healthy') pending.push(service)
|
|
126
|
+
}
|
|
127
|
+
if (pending.length === 0) {
|
|
128
|
+
stdout?.write(`every service is healthy: ${SERVICES.join(', ')}\n`)
|
|
129
|
+
return states
|
|
130
|
+
}
|
|
131
|
+
if (now() - started >= timeoutMs) {
|
|
132
|
+
throw new ComposeError(`${pending[0]} is still starting after ${Math.round(timeoutMs / 1000)} seconds. ${logs(pending[0])}`)
|
|
133
|
+
}
|
|
134
|
+
stdout?.write(`waiting for ${pending.join(', ')}\n`)
|
|
135
|
+
await sleep(HEALTH_POLL_MS)
|
|
136
|
+
}
|
|
137
|
+
}
|