@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }