@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/purge.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { createInterface } from 'node:readline'
|
|
4
|
+
|
|
5
|
+
import { EXIT, Refusal, UsageError } from './exit.mjs'
|
|
6
|
+
import { dockerRunner } from './compose.mjs'
|
|
7
|
+
import { launcherPath } from './launcher.mjs'
|
|
8
|
+
import { withLifecycleLock } from './lock.mjs'
|
|
9
|
+
import { removeInstallationResources, removeReleaseImages } from './resources.mjs'
|
|
10
|
+
import { openRoot, recordPath } from './root.mjs'
|
|
11
|
+
import { namedSteps } from './steps.mjs'
|
|
12
|
+
import { readTailscaleRecord, tailscaleRunner, withdrawServeRoutes } from './tailscale.mjs'
|
|
13
|
+
import { externalChecklist, purgeCommand } from './uninstall.mjs'
|
|
14
|
+
|
|
15
|
+
// `curia purge` (#887, implementing #855): the confirmed purge. It removes
|
|
16
|
+
// every local trace of one installation after one explicit confirmation, as
|
|
17
|
+
// six named steps.
|
|
18
|
+
//
|
|
19
|
+
// preflight the root boundary (`openRoot`, which must find an
|
|
20
|
+
// installation), then the warning: the exact root and what
|
|
21
|
+
// goes with it. Nothing changes.
|
|
22
|
+
// confirm the one confirmation. On a terminal the operator types the
|
|
23
|
+
// exact installation root; without one, `--confirm <root>` on
|
|
24
|
+
// the command line says the same thing. Anything else stops the
|
|
25
|
+
// command with nothing changed. There is no second question, no
|
|
26
|
+
// scan for unpublished work, and no salvage.
|
|
27
|
+
// docker under the lifecycle lock from here to the end: every
|
|
28
|
+
// container, network, and volume that carries this
|
|
29
|
+
// installation's label, the same teardown as uninstall.
|
|
30
|
+
// routes the Tailscale Serve routes Curia created, the same as uninstall.
|
|
31
|
+
// images the release images nothing on the host uses any more. Found
|
|
32
|
+
// by their exact repositories, never by a name prefix, and
|
|
33
|
+
// removed only when Docker lists no container over them and
|
|
34
|
+
// accepts the removal without force. An image another
|
|
35
|
+
// installation runs stays. The agent image the service built is
|
|
36
|
+
// not a release image and stays.
|
|
37
|
+
// root the launcher when it names this root, then the installation
|
|
38
|
+
// root itself, last, with the installation record as the last
|
|
39
|
+
// file inside it, so a rerun over a half-removed root still
|
|
40
|
+
// finds the installation.
|
|
41
|
+
//
|
|
42
|
+
// The installation ID and the external identifiers are read before anything
|
|
43
|
+
// is removed, because the files that hold them go with the root. Every step
|
|
44
|
+
// reads before it removes, so a rerun over a partial purge finishes it. The
|
|
45
|
+
// bootstrap's `--purge` runs this command from a temporary stage with
|
|
46
|
+
// `CURIA_ROOT` only: no launcher, no stage, and possibly a root whose `run/`
|
|
47
|
+
// is already gone, so the lock's directory is created when it is missing.
|
|
48
|
+
|
|
49
|
+
export const PURGE_STEPS = Object.freeze(['preflight', 'confirm', 'docker', 'routes', 'images', 'root'])
|
|
50
|
+
|
|
51
|
+
export const CONFIRM_OPTION = '--confirm'
|
|
52
|
+
const PROMPT = 'Type the installation root to confirm, or anything else to stop: '
|
|
53
|
+
|
|
54
|
+
// The command's seam. `context` is what `runCli` hands a command. `deps` are
|
|
55
|
+
// the boundaries a test replaces: the Docker runner, the `tailscale` runner,
|
|
56
|
+
// the question on the terminal, and whether there is a terminal to ask on.
|
|
57
|
+
export async function runPurge(
|
|
58
|
+
{ env, args = [], stdout, uid, root },
|
|
59
|
+
{ docker = dockerRunner, tailscale = tailscaleRunner, prompt = askOnTerminal, isTerminal = () => Boolean(process.stdin.isTTY) } = {},
|
|
60
|
+
) {
|
|
61
|
+
const confirmed = confirmationFromArgs(args)
|
|
62
|
+
const launcher = launcherPath(env)
|
|
63
|
+
const bootstrap = purgeCommand({ env, root })
|
|
64
|
+
const steps = namedSteps({
|
|
65
|
+
steps: PURGE_STEPS,
|
|
66
|
+
stdout,
|
|
67
|
+
rerun: (step) => (existsSync(launcher)
|
|
68
|
+
? `Run '${launcher} purge' to run ${step} again; the completed steps are kept.`
|
|
69
|
+
: `Fix the cause, then run 'curia purge' again from the bootstrap (${bootstrap}); the completed steps are kept.`),
|
|
70
|
+
})
|
|
71
|
+
const say = (text) => stdout.write(`${text}\n`)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// 1. preflight
|
|
75
|
+
steps.begin('preflight')
|
|
76
|
+
const opened = openRoot(root, { uid })
|
|
77
|
+
if (opened.status !== 'installed') {
|
|
78
|
+
throw new Refusal(`${root} holds no installation, so there is nothing to purge. Nothing changed.`)
|
|
79
|
+
}
|
|
80
|
+
const { installationId, activeVersion } = opened.record
|
|
81
|
+
const external = externalChecklist(root, { uid })
|
|
82
|
+
const recordedRoutes = readTailscaleRecord(join(root, 'state')).serve
|
|
83
|
+
say(`This purges the Curia installation at ${root} (installation ${installationId}, version ${activeVersion}).`)
|
|
84
|
+
say('It deletes, with no way back:')
|
|
85
|
+
say(' - your configuration (config/), credentials (secrets/), history (state/), and unfinished work (work/)')
|
|
86
|
+
say(' - the installed versions, caches, and runtime files (versions/, cache/, run/) and the launcher')
|
|
87
|
+
say(" - the installation's containers, networks, and volumes, and the release images nothing else uses")
|
|
88
|
+
say(' - the Tailscale Serve routes Curia created')
|
|
89
|
+
say('It does not delete the GitHub App, the Discord bot and channel, the Tailscale node, or any model-provider login; those stay yours to remove.')
|
|
90
|
+
|
|
91
|
+
// 2. confirm
|
|
92
|
+
steps.begin('confirm')
|
|
93
|
+
if (confirmed !== null) {
|
|
94
|
+
if (confirmed !== root) throw new Refusal(`${CONFIRM_OPTION} names ${confirmed}, not the installation root ${root}. Nothing changed.`)
|
|
95
|
+
} else if (!isTerminal()) {
|
|
96
|
+
throw new Refusal(`no terminal to confirm on. Run 'curia purge ${CONFIRM_OPTION} ${root}' to confirm without a prompt. Nothing changed.`)
|
|
97
|
+
} else if ((await prompt(PROMPT)) !== root) {
|
|
98
|
+
throw new Refusal('the answer did not match the installation root. Nothing changed.')
|
|
99
|
+
}
|
|
100
|
+
say(`confirmed: purging ${root}`)
|
|
101
|
+
|
|
102
|
+
// The lock lives under run/, which a partial purge or the bootstrap's
|
|
103
|
+
// stage case may find missing.
|
|
104
|
+
const runDir = join(root, 'run')
|
|
105
|
+
if (!existsSync(runDir)) mkdirSync(runDir, { mode: 0o700 })
|
|
106
|
+
|
|
107
|
+
return await withLifecycleLock(root, async () => {
|
|
108
|
+
// 3. docker
|
|
109
|
+
steps.begin('docker')
|
|
110
|
+
const removed = await removeInstallationResources(installationId, { docker, stdout })
|
|
111
|
+
const count = removed.containers.length + removed.networks.length + removed.volumes.length
|
|
112
|
+
say(count === 0
|
|
113
|
+
? `no container, network, or volume carries the label of installation ${installationId}`
|
|
114
|
+
: `removed every container, network, and volume of installation ${installationId}`)
|
|
115
|
+
|
|
116
|
+
// 4. routes
|
|
117
|
+
steps.begin('routes')
|
|
118
|
+
const routes = await withdrawServeRoutes({ stateDir: join(root, 'state'), stdout }, { tailscale })
|
|
119
|
+
if (routes.recorded.length === 0) say('no Serve route is recorded for this installation')
|
|
120
|
+
else if (routes.withdrawn.length === 0 && routes.absent.length > 0 && !routes.unreachable) say('no recorded Serve route is standing; nothing to withdraw')
|
|
121
|
+
|
|
122
|
+
// 5. images
|
|
123
|
+
steps.begin('images')
|
|
124
|
+
const images = await removeReleaseImages({ docker, stdout })
|
|
125
|
+
if (images.found.length === 0) say('no release image is on this host')
|
|
126
|
+
else say(`release images: ${images.removed.length} removed, ${images.kept.length} kept`)
|
|
127
|
+
|
|
128
|
+
// 6. root
|
|
129
|
+
steps.begin('root')
|
|
130
|
+
const launcherFate = removeLauncher(launcher, root)
|
|
131
|
+
say(launcherFate === 'removed' ? `removed the launcher ${launcher}`
|
|
132
|
+
: launcherFate === 'foreign' ? `kept the launcher ${launcher}: it belongs to another installation root`
|
|
133
|
+
: `no launcher at ${launcher}`)
|
|
134
|
+
removeRoot(root)
|
|
135
|
+
say(`removed ${root}`)
|
|
136
|
+
|
|
137
|
+
say('')
|
|
138
|
+
say(`Curia is purged. The installation root ${root} is removed.`)
|
|
139
|
+
say(` removed: the root with config/, secrets/, state/, and work/, the launcher, the installation's containers, networks, and volumes, and ${images.removed.length === 1 ? '1 release image' : `${images.removed.length} release images`}`)
|
|
140
|
+
if (images.kept.length > 0) say(` kept: ${images.kept.length === 1 ? '1 release image' : `${images.kept.length} release images`} still in use on this host (listed under [5/6] images)`)
|
|
141
|
+
say(` reinstall: a later install starts a new installation with a new ID; the removed one cannot come back`)
|
|
142
|
+
say('')
|
|
143
|
+
say('External resources Curia never deletes. Deleting the local secret files revokes nothing: each credential stays valid until you remove it where it was issued.')
|
|
144
|
+
for (const line of external) say(` ${line}`)
|
|
145
|
+
for (const route of recordedRoutes) {
|
|
146
|
+
const withdrawn = routes.withdrawn.some((r) => r.https === route.https && r.target === route.target)
|
|
147
|
+
const absent = routes.absent.some((r) => r.https === route.https && r.target === route.target) && !routes.unreachable
|
|
148
|
+
say(` Serve route https://:${route.https} -> ${route.target}: ${withdrawn ? 'withdrawn' : absent ? 'was not standing' : `still recorded when the root was removed; run 'tailscale serve --https=${route.https} off' on the node`}`)
|
|
149
|
+
}
|
|
150
|
+
say(' Model-provider logins (Anthropic, OpenAI): revoke them in each provider\'s account settings if you won\'t reinstall; only the local copies were deleted.')
|
|
151
|
+
return EXIT.ok
|
|
152
|
+
})
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw steps.wrap(e)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// `--confirm <root>` or `--confirm=<root>`; anything else on the line is a
|
|
159
|
+
// usage error before the command runs.
|
|
160
|
+
export function confirmationFromArgs(args) {
|
|
161
|
+
let value = null
|
|
162
|
+
for (let i = 0; i < args.length; i++) {
|
|
163
|
+
const arg = args[i]
|
|
164
|
+
if (arg === CONFIRM_OPTION) {
|
|
165
|
+
if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new UsageError(`${CONFIRM_OPTION} needs the installation root as its value`)
|
|
166
|
+
value = args[++i]
|
|
167
|
+
} else if (arg.startsWith(`${CONFIRM_OPTION}=`)) {
|
|
168
|
+
value = arg.slice(CONFIRM_OPTION.length + 1)
|
|
169
|
+
if (value === '') throw new UsageError(`${CONFIRM_OPTION} needs the installation root as its value`)
|
|
170
|
+
} else if (arg.startsWith('-')) {
|
|
171
|
+
throw new UsageError(`unknown option: ${arg}`)
|
|
172
|
+
} else {
|
|
173
|
+
throw new UsageError(`unexpected argument: ${arg}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return value
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The question on the operator's terminal, answered once.
|
|
180
|
+
function askOnTerminal(question) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
183
|
+
rl.question(question, (answer) => {
|
|
184
|
+
rl.close()
|
|
185
|
+
resolve(answer)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Removes the launcher when it names this root. A launcher of another root
|
|
191
|
+
// is another installation's and stays. The same rule as uninstall.
|
|
192
|
+
function removeLauncher(launcher, root) {
|
|
193
|
+
let text
|
|
194
|
+
try {
|
|
195
|
+
text = readFileSync(launcher, 'utf8')
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e.code === 'ENOENT') return 'absent'
|
|
198
|
+
throw e
|
|
199
|
+
}
|
|
200
|
+
if (!text.includes(`CURIA_ROOT='${root}'`)) return 'foreign'
|
|
201
|
+
unlinkSync(launcher)
|
|
202
|
+
return 'removed'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Removes the root with the installation record last: everything beside
|
|
206
|
+
// `state/` first, then everything in `state/` beside the record, then the
|
|
207
|
+
// record with the rest. A failure anywhere before the end leaves a root that
|
|
208
|
+
// `openRoot` still recognizes, so the rerun is a purge and not a refusal of
|
|
209
|
+
// an unknown directory.
|
|
210
|
+
function removeRoot(root) {
|
|
211
|
+
const record = recordPath(root)
|
|
212
|
+
const stateDir = join(root, 'state')
|
|
213
|
+
for (const entry of readdirSync(root)) {
|
|
214
|
+
if (entry === 'state') continue
|
|
215
|
+
rmSync(join(root, entry), { recursive: true, force: true })
|
|
216
|
+
}
|
|
217
|
+
if (existsSync(stateDir)) {
|
|
218
|
+
for (const entry of readdirSync(stateDir)) {
|
|
219
|
+
const file = join(stateDir, entry)
|
|
220
|
+
if (file === record) continue
|
|
221
|
+
rmSync(file, { recursive: true, force: true })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
rmSync(root, { recursive: true, force: true })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Used by the command table.
|
|
228
|
+
export function purgeCommandRun(context, deps) {
|
|
229
|
+
return runPurge(context, deps)
|
|
230
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { IMAGE_REGISTRY, INSTALLATION_LABEL, RELEASE_IMAGES } from './bundle.mjs'
|
|
2
|
+
import { dockerRunner } from './compose.mjs'
|
|
3
|
+
|
|
4
|
+
// The Docker resources of one installation, found by label and by nothing
|
|
5
|
+
// else (#886 and #887, implementing #855).
|
|
6
|
+
//
|
|
7
|
+
// Every container, network, and volume Curia creates for an installation
|
|
8
|
+
// carries `sh.curia.installation=<installation ID>`: the Compose bundle puts
|
|
9
|
+
// it on the five services, the project network, and the tmux socket volume,
|
|
10
|
+
// and the service puts it on every agent container and the agent cache
|
|
11
|
+
// volumes it creates. So one filter names what belongs to an installation on
|
|
12
|
+
// a host, whatever the Compose project's files are in, and a name prefix is
|
|
13
|
+
// never read as ownership. A container of another installation, an
|
|
14
|
+
// operator's own container, and a probe container without the label are
|
|
15
|
+
// never listed and never touched.
|
|
16
|
+
//
|
|
17
|
+
// Uninstall stops and removes them; purge does the same and then removes
|
|
18
|
+
// the release images. Both read what is there before they remove it, so a
|
|
19
|
+
// rerun over a host where nothing is left lists nothing and removes nothing.
|
|
20
|
+
|
|
21
|
+
export class DockerError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = 'DockerError'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const filter = (installationId) => ['--filter', `label=${INSTALLATION_LABEL}=${installationId}`]
|
|
29
|
+
|
|
30
|
+
// The labelled resources: `{ containers: [{ id, name, running }], networks:
|
|
31
|
+
// [{ id, name }], volumes: [{ name }] }`.
|
|
32
|
+
export async function installationResources(installationId, { docker = dockerRunner } = {}) {
|
|
33
|
+
const containers = (await run(docker, ['ps', '--all', '--no-trunc', ...filter(installationId), '--format', '{{.ID}}\t{{.Names}}\t{{.State}}']))
|
|
34
|
+
.map((line) => {
|
|
35
|
+
const [id, name, state] = line.split('\t')
|
|
36
|
+
return { id, name, running: state === 'running' }
|
|
37
|
+
})
|
|
38
|
+
const networks = (await run(docker, ['network', 'ls', '--no-trunc', ...filter(installationId), '--format', '{{.ID}}\t{{.Name}}']))
|
|
39
|
+
.map((line) => {
|
|
40
|
+
const [id, name] = line.split('\t')
|
|
41
|
+
return { id, name }
|
|
42
|
+
})
|
|
43
|
+
const volumes = (await run(docker, ['volume', 'ls', ...filter(installationId), '--format', '{{.Name}}']))
|
|
44
|
+
.map((name) => ({ name }))
|
|
45
|
+
return { containers, networks, volumes }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Stops every running labelled container, then removes every labelled
|
|
49
|
+
// container, network, and volume, in that order, so the network and the
|
|
50
|
+
// volumes are free when they are removed. Returns what was removed. A stop
|
|
51
|
+
// gives each process its termination signal and the usual grace before the
|
|
52
|
+
// kill, which is what lets the service close its journal; nothing waits for
|
|
53
|
+
// a session to finish, and nothing drains one.
|
|
54
|
+
export async function removeInstallationResources(installationId, { docker = dockerRunner, stdout } = {}) {
|
|
55
|
+
const found = await installationResources(installationId, { docker })
|
|
56
|
+
const running = found.containers.filter((c) => c.running)
|
|
57
|
+
if (running.length > 0) {
|
|
58
|
+
stdout?.write(`stopping ${describe(running.map((c) => c.name), 'container')}\n`)
|
|
59
|
+
await run(docker, ['stop', ...running.map((c) => c.id)])
|
|
60
|
+
}
|
|
61
|
+
if (found.containers.length > 0) {
|
|
62
|
+
stdout?.write(`removing ${describe(found.containers.map((c) => c.name), 'container')}\n`)
|
|
63
|
+
await run(docker, ['rm', '--force', '--volumes', ...found.containers.map((c) => c.id)])
|
|
64
|
+
}
|
|
65
|
+
if (found.networks.length > 0) {
|
|
66
|
+
stdout?.write(`removing ${describe(found.networks.map((n) => n.name), 'network')}\n`)
|
|
67
|
+
await run(docker, ['network', 'rm', ...found.networks.map((n) => n.id)])
|
|
68
|
+
}
|
|
69
|
+
if (found.volumes.length > 0) {
|
|
70
|
+
stdout?.write(`removing ${describe(found.volumes.map((v) => v.name), 'volume')}\n`)
|
|
71
|
+
await run(docker, ['volume', 'rm', '--force', ...found.volumes.map((v) => v.name)])
|
|
72
|
+
}
|
|
73
|
+
return found
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The release images on the host: every image under one of the four exact
|
|
77
|
+
// repositories `RELEASE_IMAGES` names, whatever release it belongs to, as
|
|
78
|
+
// `{ id, reference }` with the reference `<repository>@<digest>` (or
|
|
79
|
+
// `<repository>:<tag>` for one pulled by tag). Images are not labelled with
|
|
80
|
+
// an installation, because two installations on one host share a pulled
|
|
81
|
+
// image, so the repository is the identity; a name that merely starts with
|
|
82
|
+
// `curia-` is never read as a release image.
|
|
83
|
+
export async function releaseImages({ docker = dockerRunner } = {}) {
|
|
84
|
+
const seen = new Map()
|
|
85
|
+
for (const name of Object.values(RELEASE_IMAGES)) {
|
|
86
|
+
const repository = `${IMAGE_REGISTRY}/${name}`
|
|
87
|
+
const rows = await run(docker, ['image', 'ls', '--no-trunc', '--digests', '--filter', `reference=${repository}`, '--format', '{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}'])
|
|
88
|
+
for (const line of rows) {
|
|
89
|
+
const [id, repo, tag, digest] = line.split('\t')
|
|
90
|
+
const reference = digest && digest !== '<none>' ? `${repo}@${digest}` : `${repo}:${tag}`
|
|
91
|
+
if (!seen.has(id)) seen.set(id, { id, reference })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...seen.values()]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Removes every release image that Docker proves unused, and keeps the rest
|
|
98
|
+
// with the reason. Unused means: `docker ps --all --filter ancestor=<id>`
|
|
99
|
+
// lists no container, of this installation or any other, and Docker itself
|
|
100
|
+
// accepts the removal without `--force`. An image Docker refuses (a container
|
|
101
|
+
// created between the two calls, a dependent image) is kept and reported,
|
|
102
|
+
// not a failure: images are shared host resources, and keeping one leaves
|
|
103
|
+
// nothing of the installation behind. Returns `{ found, removed, kept }`
|
|
104
|
+
// with `kept` as `[{ reference, reason }]`.
|
|
105
|
+
export async function removeReleaseImages({ docker = dockerRunner, stdout } = {}) {
|
|
106
|
+
const found = await releaseImages({ docker })
|
|
107
|
+
const removed = []
|
|
108
|
+
const kept = []
|
|
109
|
+
for (const image of found) {
|
|
110
|
+
const users = (await run(docker, ['ps', '--all', '--no-trunc', '--filter', `ancestor=${image.id}`, '--format', '{{.ID}}\t{{.Names}}']))
|
|
111
|
+
.map((line) => line.split('\t')[1])
|
|
112
|
+
if (users.length > 0) {
|
|
113
|
+
const reason = `in use by ${describe(users, 'container')}`
|
|
114
|
+
stdout?.write(`kept the image ${image.reference}: ${reason}\n`)
|
|
115
|
+
kept.push({ reference: image.reference, reason })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const result = await docker(['image', 'rm', image.id])
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
const reason = `docker refused: ${lastLines(result)}`
|
|
121
|
+
stdout?.write(`kept the image ${image.reference}: ${reason}\n`)
|
|
122
|
+
kept.push({ reference: image.reference, reason })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
stdout?.write(`removed the image ${image.reference}\n`)
|
|
126
|
+
removed.push(image)
|
|
127
|
+
}
|
|
128
|
+
return { found, removed, kept }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function describe(names, noun) {
|
|
132
|
+
return `${names.length} ${noun}${names.length === 1 ? '' : 's'}: ${names.join(', ')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One `docker` invocation whose output is read line by line.
|
|
136
|
+
async function run(docker, args) {
|
|
137
|
+
const result = await docker(args)
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
throw new DockerError(`docker ${args.join(' ')} failed:\n${lastLines(result)}`)
|
|
140
|
+
}
|
|
141
|
+
return String(result.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function lastLines(result) {
|
|
145
|
+
return result.missing ? 'docker is not on the path' : (result.stderr || result.stdout || `exit ${result.code}`).trim().split('\n').slice(-5).join('\n')
|
|
146
|
+
}
|
package/src/rollback.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { EXIT, Refusal } from './exit.mjs'
|
|
5
|
+
import { dockerRunner } from './compose.mjs'
|
|
6
|
+
import { operatorConfigPath } from './config.mjs'
|
|
7
|
+
import { launcherPath } from './launcher.mjs'
|
|
8
|
+
import { withLifecycleLock } from './lock.mjs'
|
|
9
|
+
import { hostProbes, preflight } from './preflight.mjs'
|
|
10
|
+
import { openRoot, versionPaths } from './root.mjs'
|
|
11
|
+
import { IncompatibleRelease, isCompleteStage, validateWithRelease } from './stage.mjs'
|
|
12
|
+
import { namedSteps } from './steps.mjs'
|
|
13
|
+
import { switchRelease } from './switch.mjs'
|
|
14
|
+
|
|
15
|
+
// `curia rollback` (#885, implementing #854): the switch of `curia update`
|
|
16
|
+
// with the versions swapped, from the active release to the one rollback
|
|
17
|
+
// release, as four named steps.
|
|
18
|
+
//
|
|
19
|
+
// preflight the root boundary (`openRoot`, which must find an
|
|
20
|
+
// installation) and the host preflight. Nothing changes.
|
|
21
|
+
// select under the lifecycle lock from here to the end: the rollback
|
|
22
|
+
// release is the one complete release under versions/ that is
|
|
23
|
+
// not the active one. Curia keeps exactly one after a
|
|
24
|
+
// successful switch, so none means there was no update to roll
|
|
25
|
+
// back, and two means a failed update left its staged target
|
|
26
|
+
// beside the rollback release, and the operator says which one
|
|
27
|
+
// goes. Both are refusals; nothing is guessed.
|
|
28
|
+
// validate the rollback release validates the current operator
|
|
29
|
+
// configuration with its own reader (`validateWithRelease`,
|
|
30
|
+
// the door `curia update` validates through). A configuration
|
|
31
|
+
// the rollback release refuses is the blocking incompatibility
|
|
32
|
+
// the contract names: the rollback is refused, exit `refused`,
|
|
33
|
+
// and the running release is not touched.
|
|
34
|
+
// switch `switchRelease`, the same door as the update's `switch`
|
|
35
|
+
// step: the core services are recreated from the rollback
|
|
36
|
+
// release's bundle while tmux, ttyd, and the agent containers
|
|
37
|
+
// keep running, every service must be healthy, the service and
|
|
38
|
+
// the app must report the rollback release, and every live
|
|
39
|
+
// session must be adopted back; then the record names the
|
|
40
|
+
// rollback release and the release that was active is kept as
|
|
41
|
+
// the new rollback release. A failure switches back once to
|
|
42
|
+
// the release that was running and leaves the record alone.
|
|
43
|
+
//
|
|
44
|
+
// `config/`, `secrets/`, `state/`, and `work/` are never read for writing
|
|
45
|
+
// here: the record is the one file the rollback changes, and the switch
|
|
46
|
+
// changes it only after acceptance.
|
|
47
|
+
|
|
48
|
+
export const ROLLBACK_STEPS = Object.freeze(['preflight', 'select', 'validate', 'switch'])
|
|
49
|
+
|
|
50
|
+
// The command's seam. `context` is what `runCli` hands a command. `deps` are
|
|
51
|
+
// the boundaries a test replaces: the host probes, the rollback release's
|
|
52
|
+
// configuration validator, the Docker runner, the loopback `fetch`, and the
|
|
53
|
+
// clock the waits use.
|
|
54
|
+
export async function runRollback(
|
|
55
|
+
{ env, args = [], stdout, uid, gid, root },
|
|
56
|
+
{ hostProbes: host = hostProbes, validateTarget = validateWithRelease, docker = dockerRunner, fetch: fetchImpl = globalThis.fetch, sleep, now } = {},
|
|
57
|
+
) {
|
|
58
|
+
const command = [launcherPath(env), 'rollback', ...args].join(' ')
|
|
59
|
+
const steps = namedSteps({ steps: ROLLBACK_STEPS, stdout, rerun: (step) => `Run '${command}' to run ${step} again; the completed steps are kept.` })
|
|
60
|
+
const say = (text) => stdout.write(`${text}\n`)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// 1. preflight
|
|
64
|
+
steps.begin('preflight')
|
|
65
|
+
const opened = openRoot(root, { uid })
|
|
66
|
+
if (opened.status !== 'installed') {
|
|
67
|
+
throw new Refusal(`${root} holds no installation, so there is nothing to roll back. Run the bootstrap to install Curia there.`)
|
|
68
|
+
}
|
|
69
|
+
const record = opened.record
|
|
70
|
+
const active = record.activeVersion
|
|
71
|
+
const hostReport = await preflight({ uid, root, stdout }, host)
|
|
72
|
+
if (!hostReport.ok) throw hostReport.refusal
|
|
73
|
+
const dockerGid = hostReport.facts.docker.group.gid
|
|
74
|
+
|
|
75
|
+
return await withLifecycleLock(root, async () => {
|
|
76
|
+
// 2. select
|
|
77
|
+
steps.begin('select')
|
|
78
|
+
const { version: previous, candidates } = rollbackRelease(root, active)
|
|
79
|
+
if (previous === null) {
|
|
80
|
+
if (candidates.length === 0) {
|
|
81
|
+
throw new Refusal(`versions/ holds no release beside the active one, ${active}, so there is nothing to roll back to. Curia keeps the release you updated from after a successful 'curia update', and only that one.`)
|
|
82
|
+
}
|
|
83
|
+
throw new Refusal(`versions/ holds ${candidates.length === 2 ? 'two' : candidates.length} releases beside the active one, ${active}: ${candidates.join(' and ')}. One of them is the release you updated from and the other is the staged target of an update that did not switch. Finish the update with 'curia update', or remove the staged release you don't want from ${join(root, 'versions')}, then run 'curia rollback' again.`)
|
|
84
|
+
}
|
|
85
|
+
say(`rolling back from ${active} to ${previous}`)
|
|
86
|
+
|
|
87
|
+
// 3. validate
|
|
88
|
+
steps.begin('validate')
|
|
89
|
+
try {
|
|
90
|
+
await validateTarget({ root, version: previous, dir: versionPaths(root, previous).dir })
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (!(e instanceof IncompatibleRelease)) throw e
|
|
93
|
+
const action = e.reason === 'configuration'
|
|
94
|
+
? `Fix the file so that both releases accept it, or stay on ${active}.`
|
|
95
|
+
: `${previous} is older than the operator configuration contract, so Curia cannot prove it reads this installation.`
|
|
96
|
+
throw new Refusal(`${e.message} ${action} The active version is unchanged.`)
|
|
97
|
+
}
|
|
98
|
+
say(`${previous} accepts the current operator configuration at ${operatorConfigPath(root)}`)
|
|
99
|
+
|
|
100
|
+
// 4. switch
|
|
101
|
+
steps.begin('switch')
|
|
102
|
+
await switchRelease(
|
|
103
|
+
{ root, from: active, to: previous, record, environment: { uid, gid, dockerGid, installationId: record.installationId }, stdout },
|
|
104
|
+
{ docker, fetch: fetchImpl, sleep, now },
|
|
105
|
+
)
|
|
106
|
+
say('')
|
|
107
|
+
say(`Curia ${previous} is running. Open the Curia app as before; nothing in integration setup has to be repeated.`)
|
|
108
|
+
say(`${active} is now the rollback release: 'curia rollback' switches forward to it, and 'curia update' replaces it.`)
|
|
109
|
+
return EXIT.ok
|
|
110
|
+
})
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throw steps.wrap(e)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The rollback release under `root`: the one complete release under
|
|
117
|
+
// versions/ that is not `active`. Returns `{ version, candidates }`, with
|
|
118
|
+
// `version` null when there is none or more than one. A dot directory is a
|
|
119
|
+
// staging leftover and never a candidate; an incomplete directory is not one
|
|
120
|
+
// either (the switch removes both).
|
|
121
|
+
export function rollbackRelease(root, active, { complete = isCompleteStage } = {}) {
|
|
122
|
+
const candidates = readdirSync(join(root, 'versions'))
|
|
123
|
+
.filter((name) => !name.startsWith('.') && name !== active && complete(join(root, 'versions', name)))
|
|
124
|
+
.sort()
|
|
125
|
+
return { version: candidates.length === 1 ? candidates[0] : null, candidates }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Used by the command table.
|
|
129
|
+
export function rollbackCommand(context, deps) {
|
|
130
|
+
return runRollback(context, deps)
|
|
131
|
+
}
|