@the-librarian/cli 1.20.0 → 1.21.0
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 +21 -7
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +11 -10
- package/dist/runtime.js.map +1 -1
- package/dist/server/admin.js +2 -2
- package/dist/server/admin.js.map +1 -1
- package/dist/server/autoupdate.d.ts +4 -4
- package/dist/server/autoupdate.d.ts.map +1 -1
- package/dist/server/autoupdate.js +28 -22
- package/dist/server/autoupdate.js.map +1 -1
- package/dist/server/deploy-state.d.ts +24 -5
- package/dist/server/deploy-state.d.ts.map +1 -1
- package/dist/server/deploy-state.js +78 -10
- package/dist/server/deploy-state.js.map +1 -1
- package/dist/server/deployment-image.d.ts +65 -0
- package/dist/server/deployment-image.d.ts.map +1 -0
- package/dist/server/deployment-image.js +281 -0
- package/dist/server/deployment-image.js.map +1 -0
- package/dist/server/docker.d.ts.map +1 -1
- package/dist/server/docker.js +34 -14
- package/dist/server/docker.js.map +1 -1
- package/dist/server/down.js +2 -2
- package/dist/server/down.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +28 -12
- package/dist/server/index.js.map +1 -1
- package/dist/server/logs.js +2 -2
- package/dist/server/logs.js.map +1 -1
- package/dist/server/preflight.d.ts +5 -3
- package/dist/server/preflight.d.ts.map +1 -1
- package/dist/server/preflight.js +11 -6
- package/dist/server/preflight.js.map +1 -1
- package/dist/server/source-repository.d.ts +6 -0
- package/dist/server/source-repository.d.ts.map +1 -0
- package/dist/server/source-repository.js +40 -0
- package/dist/server/source-repository.js.map +1 -0
- package/dist/server/status.d.ts.map +1 -1
- package/dist/server/status.js +26 -18
- package/dist/server/status.js.map +1 -1
- package/dist/server/up.d.ts +38 -14
- package/dist/server/up.d.ts.map +1 -1
- package/dist/server/up.js +584 -134
- package/dist/server/up.js.map +1 -1
- package/dist/server/update.d.ts +5 -17
- package/dist/server/update.d.ts.map +1 -1
- package/dist/server/update.js +737 -296
- package/dist/server/update.js.map +1 -1
- package/package.json +1 -1
package/dist/server/update.js
CHANGED
|
@@ -1,348 +1,789 @@
|
|
|
1
|
-
// `librarian server update` —
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// stash/branch dance is gone — the CLI owns its deploy dir, so it just fetches
|
|
5
|
-
// tags and checks out the resolved ref). The flow (spec §8, success criterion 5):
|
|
6
|
-
//
|
|
7
|
-
// 1. Resolve the target ref: `--ref <tag|main>` pins it; default = the latest
|
|
8
|
-
// release tag (`fetchLatestVersion`).
|
|
9
|
-
// 2. IDEMPOTENCY FIRST: read deploy-state (current ref) + the container's
|
|
10
|
-
// health. Already at the resolved ref AND healthy → a CLEAN no-op (no
|
|
11
|
-
// build, no run, no recreate). When `--ref` is given, compare against it.
|
|
12
|
-
// 3. Otherwise update, IN THE DEPLOY DIR:
|
|
13
|
-
// git fetch --tags origin → git checkout <ref>
|
|
14
|
-
// → docker build -f docker/all-in-one.Dockerfile -t the-librarian:<ref> .
|
|
15
|
-
// → read back the EXISTING agent token + master key + optional bootstrap
|
|
16
|
-
// claim secret from the running container's env (so clients keep
|
|
17
|
-
// working, secrets stay decryptable, and pending claims stay armed)
|
|
18
|
-
// BEFORE removing it
|
|
19
|
-
// → docker stop → docker rm <container> (NEVER `-v`, NEVER `volume rm`)
|
|
20
|
-
// → write the 0600 deploy env-file (preserved/fresh token + preserved-or-
|
|
21
|
-
// omitted master key + loopback ALLOW_NO_AUTH) then docker run …
|
|
22
|
-
// (buildRunArgs with the SAME host + dataVolume from deploy-state, via
|
|
23
|
-
// `--env-file`)
|
|
24
|
-
// → waitForHealthy (rolls back with `docker rm -f` on failure — reusing
|
|
25
|
-
// up's pattern, so a failed recreate leaves no half-up container and
|
|
26
|
-
// does NOT advance deploy-state)
|
|
27
|
-
// → docker exec the-librarian the-librarian migrate-data-dir
|
|
28
|
-
// (server boot only WARNS about pending data-dir migrations; the admin
|
|
29
|
-
// CLI APPLIES them — see packages/cli/src/commands/migrate-data-dir.ts.
|
|
30
|
-
// The `the-librarian` runtime binary is bundled into the image in S7;
|
|
31
|
-
// this slice asserts the argv, the runtime target arrives with S7.)
|
|
32
|
-
// 4. writeDeployState with the new ref/imageTag (host/dataVolume unchanged).
|
|
33
|
-
//
|
|
34
|
-
// SECRETS ON UPDATE (decision): recreating the container needs the agent token,
|
|
35
|
-
// the master key (`LIBRARIAN_SECRET_KEY`), and any optional bootstrap-claim
|
|
36
|
-
// secret. They are persisted host-side only in the 0600 deploy env-file. We read
|
|
37
|
-
// the running values back via `docker inspect` BEFORE removal and use the
|
|
38
|
-
// protected file as the claim-secret fallback when the container is unreadable.
|
|
39
|
-
//
|
|
40
|
-
// - AGENT TOKEN: prefer the read-back token; only if the old container is
|
|
41
|
-
// gone/unreadable do we mint a FRESH token and surface it ONCE with a
|
|
42
|
-
// "clients must update their token" note.
|
|
43
|
-
// - MASTER KEY: prefer the read-back key. If it can't be read back we DO NOT
|
|
44
|
-
// mint a fresh one — re-minting would orphan every secret encrypted under
|
|
45
|
-
// the old key. Instead we OMIT `LIBRARIAN_SECRET_KEY` from the new env-file
|
|
46
|
-
// so the server resolves it from `/data/secret.key` (env → file → generate),
|
|
47
|
-
// which preserves a pre-P4 on-disk key (and only generates when the data dir
|
|
48
|
-
// is genuinely fresh — nothing to orphan).
|
|
49
|
-
//
|
|
50
|
-
// These secrets ride ONLY in the 0600 deploy env-file fed to `docker run
|
|
51
|
-
// --env-file` — never inline on argv, never in any other host file or log (the
|
|
52
|
-
// spec's no-leak boundary; tests scan for them).
|
|
53
|
-
//
|
|
54
|
-
// The DATA VOLUME is sacred: recreate removes the CONTAINER only (`docker rm`),
|
|
55
|
-
// never the named volume (the `-v` flag / `docker volume rm` never appear), so
|
|
56
|
-
// `up`/`update`/`down` all recall the same memories from the untouched volume.
|
|
57
|
-
//
|
|
58
|
-
// Everything goes through the injectable `docker.ts` runner + the injectable
|
|
59
|
-
// latest-release fetcher, so tests assert the exact argv (and its order) WITHOUT
|
|
60
|
-
// a real daemon, network, or git.
|
|
1
|
+
// `librarian server update` — prepare an immutable replacement while the current
|
|
2
|
+
// service keeps serving, then replace it as a recoverable transition.
|
|
3
|
+
import fs from "node:fs";
|
|
61
4
|
import path from "node:path";
|
|
62
5
|
import { librarianDir } from "../paths.js";
|
|
63
6
|
import { fetchLatestVersion } from "../status.js";
|
|
64
|
-
import { readDeployState
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
7
|
+
import { readDeployState } from "./deploy-state.js";
|
|
8
|
+
import { prepareRegistryImage, selectDeploymentTarget, shortImageDigest, } from "./deployment-image.js";
|
|
9
|
+
import { run, stream } from "./docker.js";
|
|
10
|
+
import { dockerPreflight, sourcePreflight } from "./preflight.js";
|
|
67
11
|
import { redactSecrets } from "./redact.js";
|
|
68
|
-
import {
|
|
12
|
+
import { isCanonicalSourceRemote, redactGitDiagnostics } from "./source-repository.js";
|
|
13
|
+
import { buildCreateArgs, CONTAINER_NAME, DeploymentFinalizationError, finalizeDeploymentFiles, LEGACY_DASHBOARD_PORT, mintAgentToken, readDeployEnvFile, REPO_URL, writeStagedDeployEnvFile, } from "./up.js";
|
|
69
14
|
import { acquireUpdateLock, UpdateInProgressError, updateLockPath } from "./update-lock.js";
|
|
70
|
-
/** A teaching error from `update`; the runtime renders `.message` as one stderr line. */
|
|
71
15
|
export class UpdateError extends Error {
|
|
72
16
|
constructor(message) {
|
|
73
17
|
super(message);
|
|
74
18
|
this.name = "UpdateError";
|
|
75
19
|
}
|
|
76
20
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
21
|
+
const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
|
|
22
|
+
const CONTAINER_ID = /^[0-9a-f]{64}$/;
|
|
23
|
+
const realUpdateSecretArtifactRemover = (file) => fs.rmSync(file, { force: true });
|
|
24
|
+
let updateSecretArtifactRemover = realUpdateSecretArtifactRemover;
|
|
25
|
+
export function setUpdateSecretArtifactRemover(next) {
|
|
26
|
+
updateSecretArtifactRemover = next;
|
|
27
|
+
}
|
|
28
|
+
export function resetUpdateSecretArtifactRemover() {
|
|
29
|
+
updateSecretArtifactRemover = realUpdateSecretArtifactRemover;
|
|
30
|
+
}
|
|
31
|
+
class RecoveryAttemptError extends Error {
|
|
32
|
+
ownedContainerId;
|
|
33
|
+
constructor(message, ownedContainerId) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.ownedContainerId = ownedContainerId;
|
|
36
|
+
this.name = "RecoveryAttemptError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
84
39
|
export async function runUpdate(options = {}) {
|
|
85
|
-
|
|
86
|
-
|
|
40
|
+
const target = selectDeploymentTarget(options.ref);
|
|
41
|
+
const preflight = options.platform ? { platform: options.platform } : {};
|
|
42
|
+
if (target.imageSource === "registry")
|
|
43
|
+
await dockerPreflight(preflight);
|
|
44
|
+
else
|
|
45
|
+
await sourcePreflight(preflight);
|
|
87
46
|
const deployDir = options.dir ?? path.join(librarianDir(options.home), "server");
|
|
88
|
-
// The deploy-state is authoritative for the bind host + data volume to reuse.
|
|
89
|
-
// Without it we cannot recreate the container with the same config — teach.
|
|
90
|
-
const state = readDeployState(deployDir);
|
|
91
|
-
if (!state) {
|
|
92
|
-
throw new UpdateError(`No deploy-state found at ${deployDir} — this host has not run \`librarian server up\` ` +
|
|
93
|
-
"(or the deploy dir is wrong). Run `librarian server up` first, " +
|
|
94
|
-
"or pass `--dir <path>` to point at an existing deploy dir.");
|
|
95
|
-
}
|
|
96
|
-
// SPEC 074 SC5: ONE exclusive lock serialises EVERY update — manual runs and
|
|
97
|
-
// auto-update timer fires alike — so two can never interleave stop/rm/run on
|
|
98
|
-
// the same container (a window with no running container takes the server
|
|
99
|
-
// down). Before 074 only the timer wrapper locked, so a manual update could
|
|
100
|
-
// race a fire. Held around everything from ref resolution to deploy-state
|
|
101
|
-
// write; released on success AND failure (a failure must not wedge the next
|
|
102
|
-
// fire — the stale-reclaim window is for crashes, not ordinary errors).
|
|
103
47
|
const lockPath = updateLockPath({ home: options.home, dir: options.dir });
|
|
104
48
|
const lock = acquireUpdateLock(lockPath);
|
|
105
49
|
if (!lock)
|
|
106
50
|
throw new UpdateInProgressError(lockPath);
|
|
107
51
|
try {
|
|
108
|
-
|
|
52
|
+
// State is read only after winning the same lifecycle lock used by `up`, so
|
|
53
|
+
// it cannot go stale behind a concurrent file/container finalization.
|
|
54
|
+
const state = readDeployState(deployDir);
|
|
55
|
+
if (!state) {
|
|
56
|
+
throw new UpdateError(`No deploy-state found at ${deployDir} — this host has not run \`librarian server up\` ` +
|
|
57
|
+
"(or the deploy dir is wrong). Run `librarian server up` first, or pass `--dir <path>`.");
|
|
58
|
+
}
|
|
59
|
+
return await performUpdate(options, deployDir, state, target);
|
|
109
60
|
}
|
|
110
61
|
finally {
|
|
111
62
|
lock.release();
|
|
112
63
|
}
|
|
113
64
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
|
|
65
|
+
async function performUpdate(options, deployDir, state, target) {
|
|
66
|
+
const targetRef = await resolveTargetRef(target);
|
|
67
|
+
// A moving source ref must be fetched and resolved before idempotency. The
|
|
68
|
+
// checkout may move while the current service continues serving; no image is
|
|
69
|
+
// built and no credential/container state is touched yet.
|
|
70
|
+
const sourceResolution = target.imageSource === "source" ? await resolveSourceTarget(deployDir, targetRef) : undefined;
|
|
71
|
+
const current = await inspectCurrentContainer(state);
|
|
72
|
+
// Registry no-op is before a pull. Source no-op compares the fetched commit's
|
|
73
|
+
// deterministic tag and its current immutable image ID, so `main` advancing
|
|
74
|
+
// cannot be mistaken for an already-current deployment.
|
|
75
|
+
const exactTarget = sourceResolution
|
|
76
|
+
? await isExactHealthySourceTarget(state, targetRef, current, deployDir, sourceResolution)
|
|
77
|
+
: isExactHealthyRegistryTarget(state, targetRef, current, deployDir);
|
|
78
|
+
if (exactTarget) {
|
|
79
|
+
const identity = target.imageSource === "registry" && state.imageSource === "registry"
|
|
80
|
+
? `published ${targetRef} (${shortImageDigest(state.imageDigest)})`
|
|
81
|
+
: `source ${targetRef}`;
|
|
120
82
|
return {
|
|
121
|
-
output:
|
|
122
|
-
|
|
123
|
-
"Nothing to do. Pin a different ref with `--ref <tag|main>` to change it.",
|
|
124
|
-
].join("\n"),
|
|
83
|
+
output: `Already up to date (${identity}) — the exact deployment is healthy.\nNothing to do.`,
|
|
84
|
+
changed: false,
|
|
125
85
|
};
|
|
126
86
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// NEVER mint a fresh key, which would orphan encrypted secrets.
|
|
138
|
-
// - bootstrap claim: reuse, falling back to the protected deploy file,
|
|
139
|
-
// so a stopped/unreadable container does not silently become unarmed.
|
|
140
|
-
const existingEnv = await readExistingContainerEnv();
|
|
141
|
-
const existingToken = existingEnv.get("LIBRARIAN_AGENT_TOKEN") ?? null;
|
|
142
|
-
const existingKey = existingEnv.get("LIBRARIAN_SECRET_KEY") ?? null;
|
|
143
|
-
const persistedDeployEnv = readDeployEnvFile(deployDir);
|
|
144
|
-
const existingBootstrapClaimSecret = existingEnv.get("LIBRARIAN_BOOTSTRAP_CLAIM_SECRET") ??
|
|
145
|
-
persistedDeployEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET ??
|
|
146
|
-
null;
|
|
147
|
-
const agentToken = existingToken ?? mintAgentToken();
|
|
148
|
-
const tokenIsFresh = existingToken === null;
|
|
149
|
-
// 7) Recreate — CONTAINER ONLY. `docker stop` then `docker rm <name>`:
|
|
150
|
-
// NEVER `-v`, NEVER `docker volume rm`. The named data volume persists.
|
|
151
|
-
await dockerStop();
|
|
152
|
-
await dockerRm();
|
|
153
|
-
// 8) Write the 0600 deploy env-file, then run the new container with the SAME
|
|
154
|
-
// host + data volume from deploy-state, secrets via `--env-file` (ADR 0008
|
|
155
|
-
// P4 — never inline on argv). The master key is the preserved one, or
|
|
156
|
-
// omitted (then the server resolves it from /data/secret.key).
|
|
157
|
-
const envFile = writeDeployEnvFile(deployDir, {
|
|
87
|
+
const prepared = await prepareTarget(target.imageSource, targetRef, deployDir, sourceResolution);
|
|
88
|
+
// Finish all local, fallible preparation before interrupting the old service.
|
|
89
|
+
const persisted = readDeployEnvFile(deployDir);
|
|
90
|
+
const agentToken = current.env.get("LIBRARIAN_AGENT_TOKEN") ?? persisted.LIBRARIAN_AGENT_TOKEN ?? mintAgentToken();
|
|
91
|
+
const secretKey = current.env.get("LIBRARIAN_SECRET_KEY") ?? persisted.LIBRARIAN_SECRET_KEY ?? undefined;
|
|
92
|
+
const bootstrapClaimSecret = current.env.get("LIBRARIAN_BOOTSTRAP_CLAIM_SECRET") ??
|
|
93
|
+
persisted.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET ??
|
|
94
|
+
undefined;
|
|
95
|
+
const tokenIsFresh = !current.env.has("LIBRARIAN_AGENT_TOKEN") && !persisted.LIBRARIAN_AGENT_TOKEN;
|
|
96
|
+
const stagedEnv = writeStagedDeployEnvFile(deployDir, {
|
|
158
97
|
agentToken,
|
|
159
|
-
secretKey
|
|
160
|
-
bootstrapClaimSecret
|
|
161
|
-
host:
|
|
162
|
-
});
|
|
163
|
-
// Reuse the published dashboard port from deploy-state. A state written before
|
|
164
|
-
// `dashboardPort` existed has none → treat it as the historical 3000, so an
|
|
165
|
-
// existing server keeps its port across the update (no silent jump to 3042).
|
|
166
|
-
const dashboardPort = state.dashboardPort ?? LEGACY_DASHBOARD_PORT;
|
|
167
|
-
await dockerInDir(buildRunArgs({
|
|
168
|
-
host: state.host,
|
|
169
|
-
dataVolume: state.dataVolume,
|
|
170
|
-
dashboardPort,
|
|
171
|
-
// Reuse the same storage: a bind-mounted host dir (run as its owner) when
|
|
172
|
-
// the deploy chose one, else the named volume. The data is sacred either way.
|
|
173
|
-
dataDir: state.dataDir,
|
|
174
|
-
runAsUser: state.dataDir ? dirOwner(state.dataDir) : undefined,
|
|
175
|
-
tag: targetRef,
|
|
176
|
-
envFile,
|
|
177
|
-
}), deployDir);
|
|
178
|
-
// 9) Wait for health (rolls back with `docker rm -f` on failure — up's
|
|
179
|
-
// pattern). If this throws, deploy-state is NOT advanced below.
|
|
180
|
-
await waitForHealthy(options);
|
|
181
|
-
// 10) Apply pending data-dir migrations via the bundled admin CLI (S7 bundles
|
|
182
|
-
// the `the-librarian` binary into the image; this slice asserts the argv).
|
|
183
|
-
await dockerExecMigrate();
|
|
184
|
-
// 11) Persist the new ref/imageTag — host/dataVolume/containerName unchanged.
|
|
185
|
-
writeDeployState(deployDir, {
|
|
186
|
-
containerName: state.containerName,
|
|
187
|
-
host: state.host,
|
|
188
|
-
dataVolume: state.dataVolume,
|
|
189
|
-
dataDir: state.dataDir,
|
|
190
|
-
// Backfill the port we just recreated with — so a legacy state (no port)
|
|
191
|
-
// becomes an explicit 3000 and never drifts to the fresh-install default.
|
|
192
|
-
dashboardPort,
|
|
193
|
-
ref: targetRef,
|
|
194
|
-
imageTag: `${CONTAINER_NAME}:${targetRef}`,
|
|
98
|
+
secretKey,
|
|
99
|
+
bootstrapClaimSecret,
|
|
100
|
+
host: current.host,
|
|
195
101
|
});
|
|
196
|
-
|
|
102
|
+
const priorRecoveryEnv = `${stagedEnv}.previous`;
|
|
103
|
+
try {
|
|
104
|
+
writePriorRecoveryEnv(priorRecoveryEnv, current.env);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const warning = cleanupSecretArtifact(stagedEnv);
|
|
108
|
+
throw new UpdateError(`${errorDetail(error)}${warning ? ` ${warning}` : ""}`);
|
|
109
|
+
}
|
|
110
|
+
const redact = valueAwareRedactor([
|
|
111
|
+
agentToken,
|
|
112
|
+
secretKey,
|
|
113
|
+
bootstrapClaimSecret,
|
|
114
|
+
current.env.get("LIBRARIAN_AGENT_TOKEN"),
|
|
115
|
+
current.env.get("LIBRARIAN_SECRET_KEY"),
|
|
116
|
+
current.env.get("LIBRARIAN_BOOTSTRAP_CLAIM_SECRET"),
|
|
117
|
+
]);
|
|
118
|
+
const nextState = prepared.imageSource === "registry"
|
|
119
|
+
? {
|
|
120
|
+
containerName: state.containerName,
|
|
121
|
+
host: current.host,
|
|
122
|
+
dataVolume: current.dataVolume,
|
|
123
|
+
dataDir: current.dataDir,
|
|
124
|
+
dashboardPort: current.dashboardPort,
|
|
125
|
+
ref: prepared.ref,
|
|
126
|
+
imageTag: prepared.imageRef,
|
|
127
|
+
imageSource: "registry",
|
|
128
|
+
imageRef: prepared.imageRef,
|
|
129
|
+
imageDigest: prepared.imageDigest,
|
|
130
|
+
}
|
|
131
|
+
: {
|
|
132
|
+
containerName: state.containerName,
|
|
133
|
+
host: current.host,
|
|
134
|
+
dataVolume: current.dataVolume,
|
|
135
|
+
dataDir: current.dataDir,
|
|
136
|
+
dashboardPort: current.dashboardPort,
|
|
137
|
+
ref: prepared.ref,
|
|
138
|
+
imageTag: prepared.imageRef,
|
|
139
|
+
imageSource: "source",
|
|
140
|
+
imageRef: prepared.imageRef,
|
|
141
|
+
};
|
|
142
|
+
let oldRemoved = false;
|
|
143
|
+
let candidateId = null;
|
|
144
|
+
let migrationStarted = false;
|
|
145
|
+
let successCleanupWarning = null;
|
|
146
|
+
try {
|
|
147
|
+
await stopCurrent(current.immutableContainerId, redact);
|
|
148
|
+
await removeCurrent(current.immutableContainerId, redact);
|
|
149
|
+
oldRemoved = true;
|
|
150
|
+
candidateId = await createContainer(prepared.runImageRef, current, stagedEnv, prepared.cwd, redact);
|
|
151
|
+
await startContainer(candidateId, prepared.cwd, redact);
|
|
152
|
+
await waitForHealth(candidateId, options, redact);
|
|
153
|
+
migrationStarted = true;
|
|
154
|
+
await execMigration(candidateId, redact);
|
|
155
|
+
finalizeDeploymentFiles(deployDir, stagedEnv, nextState);
|
|
156
|
+
candidateId = null;
|
|
157
|
+
successCleanupWarning = cleanupSecretArtifact(priorRecoveryEnv);
|
|
158
|
+
}
|
|
159
|
+
catch (primary) {
|
|
160
|
+
const primaryMessage = errorDetail(primary, redact);
|
|
161
|
+
const priorFilesRestored = !(primary instanceof DeploymentFinalizationError) || primary.priorFilesRestored;
|
|
162
|
+
if (candidateId) {
|
|
163
|
+
const ownedCandidateId = candidateId;
|
|
164
|
+
try {
|
|
165
|
+
await removeOwnedContainer(ownedCandidateId, redact);
|
|
166
|
+
candidateId = null;
|
|
167
|
+
}
|
|
168
|
+
catch (cleanupError) {
|
|
169
|
+
throw recoveryFailure(primaryMessage, errorDetail(cleanupError, redact), current, priorRecoveryEnv, migrationStarted, true, priorFilesRestored, ownedCandidateId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
if (oldRemoved) {
|
|
174
|
+
await recreatePrevious(current, priorRecoveryEnv, deployDir, options, redact);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
await restartPrevious(current.immutableContainerId, options, redact);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (recoveryError) {
|
|
181
|
+
// Keep the protected staged env-file: the printed recovery command uses it.
|
|
182
|
+
throw recoveryFailure(primaryMessage, errorDetail(recoveryError, redact), current, priorRecoveryEnv, migrationStarted, oldRemoved, priorFilesRestored, recoveryError instanceof RecoveryAttemptError ? recoveryError.ownedContainerId : undefined);
|
|
183
|
+
}
|
|
184
|
+
const cleanupWarnings = [cleanupSecretArtifact(stagedEnv)];
|
|
185
|
+
if (priorFilesRestored)
|
|
186
|
+
cleanupWarnings.push(cleanupSecretArtifact(priorRecoveryEnv));
|
|
187
|
+
throw recoveredFailure(primaryMessage, migrationStarted, priorFilesRestored, priorRecoveryEnv, cleanupWarnings.filter((warning) => warning !== null));
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
output: [renderSuccess(prepared, tokenIsFresh ? agentToken : null), successCleanupWarning]
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.join("\n"),
|
|
193
|
+
changed: true,
|
|
194
|
+
};
|
|
197
195
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (ref
|
|
202
|
-
return ref
|
|
196
|
+
async function resolveTargetRef(target) {
|
|
197
|
+
if (target.imageSource === "source")
|
|
198
|
+
return target.ref;
|
|
199
|
+
if (target.ref)
|
|
200
|
+
return target.ref;
|
|
203
201
|
const latest = await fetchLatestVersion();
|
|
204
202
|
if (!latest) {
|
|
205
|
-
throw new UpdateError("Could not resolve the latest release
|
|
206
|
-
"
|
|
203
|
+
throw new UpdateError("Could not resolve the latest stable release from GitHub. Check the network, or pin " +
|
|
204
|
+
"`--ref vX.Y.Z`. Stable updates never fall back to a source build.");
|
|
207
205
|
}
|
|
208
|
-
// `fetchLatestVersion` strips the leading `v`; the tag we check out keeps it.
|
|
209
206
|
return `v${latest}`;
|
|
210
207
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
208
|
+
async function prepareTarget(imageSource, ref, deployDir, sourceResolution) {
|
|
209
|
+
if (imageSource === "registry") {
|
|
210
|
+
let image;
|
|
211
|
+
try {
|
|
212
|
+
image = await prepareRegistryImage(ref, (chunk) => void process.stderr.write(chunk));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
throw new UpdateError(errorDetail(error).replaceAll("librarian server up", "librarian server update"));
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
ref,
|
|
219
|
+
imageSource,
|
|
220
|
+
runImageRef: image.imageDigest,
|
|
221
|
+
imageRef: image.imageRef,
|
|
222
|
+
imageDigest: image.imageDigest,
|
|
223
|
+
cwd: deployDir,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!sourceResolution)
|
|
227
|
+
throw new UpdateError("Internal error: source target was not resolved.");
|
|
228
|
+
await buildSourceImage(sourceResolution.checkout, sourceResolution.imageTag);
|
|
229
|
+
const immutableImageId = await inspectSourceImageId(sourceResolution.imageTag, true);
|
|
230
|
+
return {
|
|
231
|
+
ref,
|
|
232
|
+
imageSource,
|
|
233
|
+
runImageRef: immutableImageId,
|
|
234
|
+
imageRef: sourceResolution.imageTag,
|
|
235
|
+
cwd: sourceResolution.checkout,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async function inspectCurrentContainer(state) {
|
|
239
|
+
const args = ["container", "inspect", "--format", "{{json .}}", CONTAINER_NAME];
|
|
240
|
+
const result = await run("docker", args);
|
|
241
|
+
if (result.code !== 0) {
|
|
242
|
+
throw new UpdateError(`Could not inspect the current ${CONTAINER_NAME} container before update` +
|
|
243
|
+
detailSuffix(result) +
|
|
244
|
+
" The existing deployment and state were left untouched; run `librarian server up` if it is absent.");
|
|
245
|
+
}
|
|
246
|
+
let live;
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
249
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
250
|
+
throw new Error();
|
|
251
|
+
live = parsed;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw new UpdateError("Docker returned malformed metadata for the current container. Refusing to interrupt it.");
|
|
255
|
+
}
|
|
256
|
+
const immutableImageId = typeof live.Image === "string" ? live.Image : "";
|
|
257
|
+
const immutableContainerId = typeof live.Id === "string" ? live.Id : "";
|
|
258
|
+
const configuredImage = typeof live.Config?.Image === "string" ? live.Config.Image : "";
|
|
259
|
+
if (!CONTAINER_ID.test(immutableContainerId) ||
|
|
260
|
+
!IMAGE_ID.test(immutableImageId) ||
|
|
261
|
+
!configuredImage) {
|
|
262
|
+
throw new UpdateError("Docker did not report the current container's full immutable image ID. Refusing an update that could not restore the previous executable.");
|
|
263
|
+
}
|
|
264
|
+
const ports = preservedPorts(live.HostConfig?.PortBindings);
|
|
265
|
+
const mount = preservedMount(live.Mounts, state.dataVolume);
|
|
266
|
+
const restartPolicy = live.HostConfig?.RestartPolicy?.Name;
|
|
267
|
+
if (typeof restartPolicy !== "string" || !restartPolicy) {
|
|
268
|
+
throw new UpdateError("Docker did not report the current restart policy. Refusing an update that could drift container configuration.");
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
immutableContainerId,
|
|
272
|
+
immutableImageId,
|
|
273
|
+
configuredImage,
|
|
274
|
+
host: ports.host,
|
|
275
|
+
dashboardPort: ports.dashboardPort,
|
|
276
|
+
dataVolume: mount.dataVolume,
|
|
277
|
+
dataDir: mount.dataDir,
|
|
278
|
+
runAsUser: typeof live.Config?.User === "string" && live.Config.User ? live.Config.User : undefined,
|
|
279
|
+
restartPolicy,
|
|
280
|
+
env: envMap(live.Config?.Env),
|
|
281
|
+
healthy: live.State?.Status === "running" && live.State?.Health?.Status === "healthy",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function preservedPorts(value) {
|
|
285
|
+
const record = objectRecord(value);
|
|
286
|
+
const dashboard = oneBinding(record["3000/tcp"], "dashboard");
|
|
287
|
+
const mcp = oneBinding(record["3838/tcp"], "MCP");
|
|
288
|
+
if (dashboard.host !== mcp.host || mcp.port !== 3838) {
|
|
289
|
+
throw new UpdateError("The current dashboard/MCP port bindings are not a supported Librarian configuration. Refusing to replace it or change its exposure.");
|
|
290
|
+
}
|
|
291
|
+
return { host: dashboard.host, dashboardPort: dashboard.port };
|
|
292
|
+
}
|
|
293
|
+
function oneBinding(value, label) {
|
|
294
|
+
if (!Array.isArray(value) || value.length !== 1) {
|
|
295
|
+
throw new UpdateError(`Docker did not report exactly one ${label} host-port binding.`);
|
|
296
|
+
}
|
|
297
|
+
const binding = objectRecord(value[0]);
|
|
298
|
+
const host = typeof binding.HostIp === "string" ? binding.HostIp : "";
|
|
299
|
+
const rawPort = typeof binding.HostPort === "string" ? binding.HostPort : "";
|
|
300
|
+
const port = Number(rawPort);
|
|
301
|
+
if (!host || !/^\d+$/.test(rawPort) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
302
|
+
throw new UpdateError(`Docker returned an invalid ${label} host-port binding.`);
|
|
303
|
+
}
|
|
304
|
+
return { host, port };
|
|
305
|
+
}
|
|
306
|
+
function preservedMount(value, fallbackVolume) {
|
|
307
|
+
const mounts = Array.isArray(value)
|
|
308
|
+
? value.filter((entry) => objectRecord(entry).Destination === "/data")
|
|
309
|
+
: [];
|
|
310
|
+
if (mounts.length !== 1) {
|
|
311
|
+
throw new UpdateError("Docker did not report exactly one /data mount. Refusing to risk data.");
|
|
312
|
+
}
|
|
313
|
+
const mount = objectRecord(mounts[0]);
|
|
314
|
+
if (mount.Type === "bind" && typeof mount.Source === "string" && mount.Source) {
|
|
315
|
+
return { dataVolume: fallbackVolume, dataDir: mount.Source };
|
|
316
|
+
}
|
|
317
|
+
if (mount.Type === "volume" && typeof mount.Name === "string" && mount.Name) {
|
|
318
|
+
return { dataVolume: mount.Name };
|
|
319
|
+
}
|
|
320
|
+
throw new UpdateError("The current /data mount is neither a named volume nor an absolute bind mount. Refusing to replace it.");
|
|
321
|
+
}
|
|
322
|
+
function hasExactHealthyConfiguration(state, requestedSource, ref, current, deployDir) {
|
|
323
|
+
const stateSource = state.imageSource ?? "source";
|
|
324
|
+
if (!current.healthy || stateSource !== requestedSource || state.ref !== ref)
|
|
219
325
|
return false;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
"
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
326
|
+
if (state.host !== current.host)
|
|
327
|
+
return false;
|
|
328
|
+
if ((state.dashboardPort ?? LEGACY_DASHBOARD_PORT) !== current.dashboardPort)
|
|
329
|
+
return false;
|
|
330
|
+
if ((state.dataDir ?? undefined) !== current.dataDir)
|
|
331
|
+
return false;
|
|
332
|
+
if (!current.dataDir && state.dataVolume !== current.dataVolume)
|
|
333
|
+
return false;
|
|
334
|
+
// Update preserves the live container's validated user and restart policy,
|
|
335
|
+
// including operator customisations. Those fields are not in deploy state,
|
|
336
|
+
// so requiring the `up` defaults here would replace the same target forever.
|
|
337
|
+
const persisted = readDeployEnvFile(deployDir);
|
|
338
|
+
const requiredRuntime = {
|
|
339
|
+
LIBRARIAN_DATA_DIR: "/data",
|
|
340
|
+
LIBRARIAN_HOST: "0.0.0.0",
|
|
341
|
+
LIBRARIAN_PORT: "3838",
|
|
342
|
+
PORT: "3000",
|
|
343
|
+
};
|
|
344
|
+
for (const [name, value] of Object.entries(requiredRuntime)) {
|
|
345
|
+
if (current.env.get(name) !== value)
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
for (const name of [
|
|
349
|
+
"LIBRARIAN_AGENT_TOKEN",
|
|
350
|
+
"LIBRARIAN_SECRET_KEY",
|
|
351
|
+
"LIBRARIAN_BOOTSTRAP_CLAIM_SECRET",
|
|
352
|
+
"LIBRARIAN_ALLOW_NO_AUTH",
|
|
353
|
+
]) {
|
|
354
|
+
if ((current.env.get(name) || undefined) !== (persisted[name] || undefined))
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return Boolean(persisted.LIBRARIAN_AGENT_TOKEN);
|
|
358
|
+
}
|
|
359
|
+
function isExactHealthyRegistryTarget(state, ref, current, deployDir) {
|
|
360
|
+
return (hasExactHealthyConfiguration(state, "registry", ref, current, deployDir) &&
|
|
361
|
+
state.imageSource === "registry" &&
|
|
362
|
+
Boolean(state.imageDigest) &&
|
|
363
|
+
current.configuredImage === state.imageDigest);
|
|
364
|
+
}
|
|
365
|
+
async function isExactHealthySourceTarget(state, ref, current, deployDir, source) {
|
|
366
|
+
if (!hasExactHealthyConfiguration(state, "source", ref, current, deployDir))
|
|
367
|
+
return false;
|
|
368
|
+
if (state.imageSource !== "source" || state.imageRef !== source.imageTag)
|
|
369
|
+
return false;
|
|
370
|
+
const taggedImageId = await inspectSourceImageId(source.imageTag, false);
|
|
371
|
+
return (taggedImageId !== null &&
|
|
372
|
+
taggedImageId === current.immutableImageId &&
|
|
373
|
+
current.configuredImage === current.immutableImageId);
|
|
374
|
+
}
|
|
375
|
+
async function resolveSourceTarget(deployDir, ref) {
|
|
376
|
+
assertLiteralSourceRef(ref);
|
|
377
|
+
const rootGit = path.join(deployDir, ".git");
|
|
378
|
+
const checkout = fs.existsSync(rootGit) ? deployDir : path.join(deployDir, "source");
|
|
379
|
+
if (!fs.existsSync(path.join(checkout, ".git"))) {
|
|
380
|
+
if (fs.existsSync(checkout) && fs.readdirSync(checkout).length > 0) {
|
|
381
|
+
throw new UpdateError(`Managed source checkout ${checkout} is non-empty but is not a git clone. Refusing to overwrite it.`);
|
|
382
|
+
}
|
|
383
|
+
await checked("git", ["clone", REPO_URL, checkout], redactGitDiagnostics);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const remote = await run("git", ["-C", checkout, "remote", "get-url", "origin"]);
|
|
387
|
+
checkedResult("git", ["remote", "get-url", "origin"], remote, redactGitDiagnostics);
|
|
388
|
+
if (!isCanonicalSourceRemote(remote.stdout)) {
|
|
389
|
+
throw new UpdateError(`Managed source checkout ${checkout} has an unexpected origin; refusing to modify it. ` +
|
|
390
|
+
"Set origin to the canonical repository or use a different deploy directory.");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const statusArgs = ["-C", checkout, "status", "--porcelain", "--untracked-files=all"];
|
|
394
|
+
const status = await run("git", statusArgs);
|
|
395
|
+
checkedResult("git", statusArgs, status, redactGitDiagnostics);
|
|
396
|
+
if (hasUserCheckoutChanges(status.stdout)) {
|
|
397
|
+
throw new UpdateError(`Managed source checkout ${checkout} has local tracked or untracked changes. ` +
|
|
398
|
+
"The current container is still serving; commit, stash, or move those files before retrying.");
|
|
399
|
+
}
|
|
400
|
+
await checked("git", ["-C", checkout, "fetch", "--tags", "origin"], redactGitDiagnostics);
|
|
401
|
+
const commit = await resolveSourceCommit(checkout, ref);
|
|
402
|
+
await checked("git", ["-C", checkout, "checkout", commit], redactGitDiagnostics);
|
|
403
|
+
return {
|
|
404
|
+
checkout,
|
|
405
|
+
commit,
|
|
406
|
+
imageTag: `${CONTAINER_NAME}:source-${commit}`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
async function resolveSourceCommit(checkout, ref) {
|
|
410
|
+
const candidates = [`refs/remotes/origin/${ref}^{commit}`, `refs/tags/${ref}^{commit}`];
|
|
411
|
+
if (/^[0-9a-f]{40}$/i.test(ref))
|
|
412
|
+
candidates.push(`${ref}^{commit}`);
|
|
413
|
+
let last;
|
|
414
|
+
for (const candidate of candidates) {
|
|
415
|
+
const result = await run("git", [
|
|
416
|
+
"-C",
|
|
417
|
+
checkout,
|
|
418
|
+
"rev-parse",
|
|
419
|
+
"--verify",
|
|
420
|
+
"--end-of-options",
|
|
421
|
+
candidate,
|
|
422
|
+
]);
|
|
423
|
+
last = result;
|
|
424
|
+
const commit = result.stdout.trim();
|
|
425
|
+
if (result.code === 0 && /^[0-9a-f]{40}$/.test(commit))
|
|
426
|
+
return commit;
|
|
427
|
+
}
|
|
428
|
+
throw new UpdateError(`Could not resolve source ref '${ref}' to a full commit after fetching origin` +
|
|
429
|
+
(last ? detailSuffix(last, redactGitDiagnostics) : ".") +
|
|
430
|
+
" Check the branch, tag, or commit and retry.");
|
|
431
|
+
}
|
|
432
|
+
function assertLiteralSourceRef(ref) {
|
|
433
|
+
const components = ref.split("/");
|
|
434
|
+
const forbiddenCharacter = [...ref].some((character) => {
|
|
435
|
+
const codePoint = character.codePointAt(0);
|
|
436
|
+
return (codePoint <= 0x20 ||
|
|
437
|
+
codePoint === 0x7f ||
|
|
438
|
+
["~", "^", ":", "?", "*", "[", "\\"].includes(character));
|
|
439
|
+
});
|
|
440
|
+
const invalidShape = !ref ||
|
|
441
|
+
ref === "@" ||
|
|
442
|
+
ref.startsWith("-") ||
|
|
443
|
+
ref.startsWith("/") ||
|
|
444
|
+
ref.endsWith("/") ||
|
|
445
|
+
ref.endsWith(".") ||
|
|
446
|
+
ref.includes("..") ||
|
|
447
|
+
ref.includes("//") ||
|
|
448
|
+
ref.includes("@{") ||
|
|
449
|
+
components.some((component) => component.startsWith(".") || component.endsWith(".lock"));
|
|
450
|
+
if (forbiddenCharacter || invalidShape) {
|
|
451
|
+
throw new UpdateError("Source --ref must be a literal branch name, tag name, or full 40-character commit SHA; " +
|
|
452
|
+
"Git revision expressions such as '~', '^', and '@{' are not accepted.");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function hasUserCheckoutChanges(porcelain) {
|
|
456
|
+
// In legacy deployments the managed clone itself is the deploy directory.
|
|
457
|
+
// These exact untracked files are owned by the CLI and necessarily coexist
|
|
458
|
+
// with source. The lock exists during this check. Do not ignore staged env,
|
|
459
|
+
// cidfile, state-staging, or wildcard-shaped residue: those need inspection.
|
|
460
|
+
const cliOwnedUntracked = new Set([
|
|
461
|
+
"?? deploy.env",
|
|
462
|
+
"?? deploy-state.json",
|
|
463
|
+
"?? .autoupdate.lock",
|
|
281
464
|
]);
|
|
282
|
-
|
|
283
|
-
|
|
465
|
+
return porcelain
|
|
466
|
+
.split("\n")
|
|
467
|
+
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
468
|
+
.some((line) => line !== "" && !cliOwnedUntracked.has(line));
|
|
284
469
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
470
|
+
async function buildSourceImage(checkout, imageTag) {
|
|
471
|
+
const args = [
|
|
472
|
+
"build",
|
|
473
|
+
"--progress=plain",
|
|
474
|
+
"-f",
|
|
475
|
+
"docker/all-in-one.Dockerfile",
|
|
476
|
+
"-t",
|
|
477
|
+
imageTag,
|
|
478
|
+
".",
|
|
479
|
+
];
|
|
480
|
+
let code;
|
|
481
|
+
try {
|
|
482
|
+
code = await stream("docker", args, {
|
|
483
|
+
onStdout: (chunk) => void process.stderr.write(chunk),
|
|
484
|
+
onStderr: (chunk) => void process.stderr.write(chunk),
|
|
485
|
+
}, { cwd: checkout });
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
throw new UpdateError(`\`docker build\` could not start: ${errorDetail(error)}`);
|
|
489
|
+
}
|
|
490
|
+
if (code !== 0) {
|
|
491
|
+
throw new UpdateError(`\`docker build\` failed (exit ${code ?? "signal"}). The current container is still serving; fix the build and retry.`);
|
|
492
|
+
}
|
|
289
493
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const result = await run("docker",
|
|
293
|
-
if (result.code
|
|
494
|
+
async function inspectSourceImageId(imageTag, required) {
|
|
495
|
+
const args = ["image", "inspect", "--format", "{{.Id}}", imageTag];
|
|
496
|
+
const result = await run("docker", args);
|
|
497
|
+
if (result.code !== 0) {
|
|
498
|
+
if (!required)
|
|
499
|
+
return null;
|
|
500
|
+
checkedResult("docker", args, result);
|
|
501
|
+
}
|
|
502
|
+
const imageId = result.stdout.trim();
|
|
503
|
+
if (!IMAGE_ID.test(imageId)) {
|
|
504
|
+
if (!required)
|
|
505
|
+
return null;
|
|
506
|
+
throw new UpdateError(`Docker did not report a full immutable image ID after building ${imageTag}. The current container is still serving.`);
|
|
507
|
+
}
|
|
508
|
+
return imageId;
|
|
509
|
+
}
|
|
510
|
+
async function stopCurrent(identity, redact) {
|
|
511
|
+
const args = ["stop", identity];
|
|
512
|
+
const result = await run("docker", args);
|
|
513
|
+
if (result.code === 0 || /is not running/i.test(result.stderr))
|
|
294
514
|
return;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
515
|
+
checkedResult("docker", args, result, redact);
|
|
516
|
+
}
|
|
517
|
+
async function removeCurrent(identity, redact) {
|
|
518
|
+
const args = ["rm", identity];
|
|
519
|
+
const result = await run("docker", args);
|
|
520
|
+
if (result.code === 0 || verifiedNotFound(result, identity))
|
|
521
|
+
return;
|
|
522
|
+
// A failed `docker rm` does not prove whether the daemon removed the target.
|
|
523
|
+
// Resolve the exact immutable ID before choosing restart-vs-recreate recovery.
|
|
524
|
+
const classified = await run("docker", ["container", "inspect", identity]);
|
|
525
|
+
if (classified.code !== 0 && verifiedNotFound(classified, identity))
|
|
306
526
|
return;
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
527
|
+
if (classified.code !== 0) {
|
|
528
|
+
throw new UpdateError(`\`docker rm\` failed and Docker could not determine whether container ${identity} still exists` +
|
|
529
|
+
detailSuffix(classified, redact) +
|
|
530
|
+
" Refusing to act on the mutable container name.");
|
|
531
|
+
}
|
|
532
|
+
checkedResult("docker", args, result, redact);
|
|
310
533
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const args =
|
|
534
|
+
async function createContainer(imageRef, config, envFile, cwd, redact) {
|
|
535
|
+
const cidFile = `${envFile}.cid`;
|
|
536
|
+
const args = buildCreateArgs({
|
|
537
|
+
host: config.host,
|
|
538
|
+
dataVolume: config.dataVolume,
|
|
539
|
+
dashboardPort: config.dashboardPort,
|
|
540
|
+
dataDir: config.dataDir,
|
|
541
|
+
runAsUser: config.runAsUser,
|
|
542
|
+
restartPolicy: config.restartPolicy,
|
|
543
|
+
imageRef,
|
|
544
|
+
envFile,
|
|
545
|
+
});
|
|
546
|
+
args.splice(1, 0, "--cidfile", cidFile);
|
|
547
|
+
fs.rmSync(cidFile, { force: true });
|
|
548
|
+
try {
|
|
549
|
+
const created = await run("docker", args, { cwd });
|
|
550
|
+
checkedResult("docker", args, created, redact);
|
|
551
|
+
let id = "";
|
|
552
|
+
try {
|
|
553
|
+
id = fs.readFileSync(cidFile, "utf8").trim();
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// A successful `docker create` without its cidfile leaves ownership
|
|
557
|
+
// ambiguous. Recovery must inspect, never remove/create by mutable name.
|
|
558
|
+
}
|
|
559
|
+
if (!CONTAINER_ID.test(id)) {
|
|
560
|
+
throw new UpdateError(`Docker created a container but did not write a full immutable ID to ${cidFile}. ` +
|
|
561
|
+
`Refusing cleanup by mutable name; inspect ${CONTAINER_NAME} manually.`);
|
|
562
|
+
}
|
|
563
|
+
return id;
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
try {
|
|
567
|
+
fs.rmSync(cidFile, { force: true });
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// The cidfile contains only a container ID. Cleanup failure must not lose
|
|
571
|
+
// an identity we successfully captured and turn safe cleanup ambiguous.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async function startContainer(id, cwd, redact) {
|
|
576
|
+
const started = await run("docker", ["start", id], { cwd });
|
|
577
|
+
checkedResult("docker", ["start", id], started, redact);
|
|
578
|
+
}
|
|
579
|
+
async function waitForHealth(identity, options, redact) {
|
|
580
|
+
const attempts = options.healthAttempts ?? 60;
|
|
581
|
+
const interval = options.healthIntervalMs ?? 2000;
|
|
582
|
+
let last = "unknown";
|
|
583
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
584
|
+
const result = await run("docker", [
|
|
585
|
+
"inspect",
|
|
586
|
+
"--format",
|
|
587
|
+
"{{.State.Health.Status}}",
|
|
588
|
+
identity,
|
|
589
|
+
]);
|
|
590
|
+
if (result.code === 0 && result.stdout.trim())
|
|
591
|
+
last = result.stdout.trim();
|
|
592
|
+
if (last === "healthy")
|
|
593
|
+
return;
|
|
594
|
+
if (last === "unhealthy")
|
|
595
|
+
break;
|
|
596
|
+
if (attempt < attempts - 1 && interval > 0) {
|
|
597
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const logs = await run("docker", [
|
|
601
|
+
"logs",
|
|
602
|
+
"--tail",
|
|
603
|
+
String(options.logTailLines ?? 50),
|
|
604
|
+
identity,
|
|
605
|
+
]);
|
|
606
|
+
const detail = redact(logs.stdout.trim() || logs.stderr.trim());
|
|
607
|
+
throw new UpdateError(`Container ${identity} did not become healthy (last status: ${last})` +
|
|
608
|
+
(detail ? `:\n${detail}` : "."));
|
|
609
|
+
}
|
|
610
|
+
async function execMigration(identity, redact) {
|
|
611
|
+
const args = ["exec", identity, CONTAINER_NAME, "migrate-data-dir"];
|
|
314
612
|
const result = await run("docker", args);
|
|
315
|
-
|
|
613
|
+
checkedResult("docker", args, result, redact);
|
|
316
614
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
615
|
+
async function restartPrevious(identity, options, redact) {
|
|
616
|
+
const result = await run("docker", ["start", identity]);
|
|
617
|
+
if (result.code !== 0 && !/already running/i.test(`${result.stdout}\n${result.stderr}`)) {
|
|
618
|
+
checkedResult("docker", ["start", identity], result, redact);
|
|
619
|
+
}
|
|
620
|
+
await waitForHealth(identity, options, redact);
|
|
320
621
|
}
|
|
321
|
-
function
|
|
322
|
-
|
|
622
|
+
async function recreatePrevious(previous, envFile, deployDir, options, redact) {
|
|
623
|
+
const id = await createContainer(previous.immutableImageId, previous, envFile, deployDir, redact);
|
|
624
|
+
try {
|
|
625
|
+
await startContainer(id, deployDir, redact);
|
|
626
|
+
await waitForHealth(id, options, redact);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
throw new RecoveryAttemptError(errorDetail(error, redact), id);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function removeOwnedContainer(identity, redact) {
|
|
633
|
+
const result = await run("docker", ["rm", "-f", identity]);
|
|
634
|
+
if (result.code === 0 || verifiedNotFound(result, identity))
|
|
323
635
|
return;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
function
|
|
636
|
+
checkedResult("docker", ["rm", "-f", identity], result, redact);
|
|
637
|
+
}
|
|
638
|
+
function recoveredFailure(primary, migrationStarted, priorFilesRestored, recoveryEnv, cleanupWarnings) {
|
|
639
|
+
const dataNote = migrationStarted
|
|
640
|
+
? " The previous executable was restored and verified healthy, but persistent data changes made after migration began were NOT rolled back."
|
|
641
|
+
: " The previous executable and container configuration were restored and verified healthy; persistent data was not deleted or rolled back.";
|
|
642
|
+
const persistenceNote = priorFilesRestored
|
|
643
|
+
? " Deploy state was not advanced."
|
|
644
|
+
: ` Prior deploy.env/deploy-state restoration FAILED, so their on-disk contents may be inconsistent. ` +
|
|
645
|
+
`The exact prior runtime credentials remain protected at ${recoveryEnv} (mode 0600). Repair deploy.env and deploy-state.json from backup or the running container before retrying; do not delete that recovery file until repair is complete.`;
|
|
646
|
+
return new UpdateError(`Update failed: ${primary}.${dataNote}${persistenceNote}` +
|
|
647
|
+
(cleanupWarnings.length > 0 ? ` ${cleanupWarnings.join(" ")}` : ""));
|
|
648
|
+
}
|
|
649
|
+
function recoveryFailure(primary, recovery, previous, envFile, migrationStarted, oldRemoved, priorFilesRestored, ownedContainerId) {
|
|
650
|
+
const create = buildCreateArgs({
|
|
651
|
+
host: previous.host,
|
|
652
|
+
dataVolume: previous.dataVolume,
|
|
653
|
+
dashboardPort: previous.dashboardPort,
|
|
654
|
+
dataDir: previous.dataDir,
|
|
655
|
+
runAsUser: previous.runAsUser,
|
|
656
|
+
restartPolicy: previous.restartPolicy,
|
|
657
|
+
imageRef: previous.immutableImageId,
|
|
658
|
+
envFile,
|
|
659
|
+
});
|
|
660
|
+
const manualCidFile = `${envFile}.manual-recovery.cid`;
|
|
661
|
+
create.splice(1, 0, "--cidfile", manualCidFile);
|
|
662
|
+
const commands = oldRemoved
|
|
663
|
+
? ownedContainerId
|
|
664
|
+
? [
|
|
665
|
+
`docker rm -f ${ownedContainerId}`,
|
|
666
|
+
`docker ${create.map(shellWord).join(" ")}`,
|
|
667
|
+
`docker start "$(cat ${shellWord(manualCidFile)})"`,
|
|
668
|
+
`docker inspect --format '{{.State.Health.Status}}' "$(cat ${shellWord(manualCidFile)})"`,
|
|
669
|
+
]
|
|
670
|
+
: [
|
|
671
|
+
`docker container inspect --format '{{.Id}} {{.Image}} {{.State.Status}}' ${CONTAINER_NAME}`,
|
|
672
|
+
`Do not remove, recreate, or start ${CONTAINER_NAME} by name until you have verified its immutable ID and ownership.`,
|
|
673
|
+
]
|
|
674
|
+
: [
|
|
675
|
+
`docker container inspect ${previous.immutableContainerId}`,
|
|
676
|
+
`docker start ${previous.immutableContainerId}`,
|
|
677
|
+
`docker inspect --format '{{.State.Health.Status}}' ${previous.immutableContainerId}`,
|
|
678
|
+
];
|
|
679
|
+
return new UpdateError(`Update failed: ${primary}. Recovery also failed: ${recovery}. ` +
|
|
680
|
+
(migrationStarted
|
|
681
|
+
? "Persistent data changes made after migration began were NOT rolled back. "
|
|
682
|
+
: "Persistent data, secrets, and deploy state were left in place. ") +
|
|
683
|
+
(priorFilesRestored
|
|
684
|
+
? ""
|
|
685
|
+
: `Prior deploy.env/deploy-state restoration also FAILED; their contents may be inconsistent. Keep ${envFile} (mode 0600) and repair both files from backup or inspected runtime configuration. `) +
|
|
686
|
+
`Protected prior runtime credentials remain at ${envFile} (mode 0600); keep that file until recovery is complete. ` +
|
|
687
|
+
`The server was NOT rolled back. Recover the previous ${oldRemoved ? "image" : "container"} manually:\n${commands.join("\n")}`);
|
|
688
|
+
}
|
|
689
|
+
function renderSuccess(prepared, freshToken) {
|
|
690
|
+
const identity = prepared.imageSource === "registry"
|
|
691
|
+
? `published ${prepared.ref} (${shortImageDigest(prepared.imageDigest)})`
|
|
692
|
+
: `source ${prepared.ref}`;
|
|
338
693
|
const lines = [
|
|
339
|
-
`Updated The Librarian server to ${
|
|
340
|
-
"The
|
|
694
|
+
`Updated The Librarian server to ${identity} — the container is healthy.`,
|
|
695
|
+
"The existing storage, ports, credentials, and restart policy were preserved; pending data-dir migrations were applied.",
|
|
341
696
|
];
|
|
342
697
|
if (freshToken) {
|
|
343
|
-
lines.push("", "NOTE:
|
|
344
|
-
"FRESH agent token was minted. Existing clients must update their token:", ` Agent token: ${freshToken}`, "Paste it into `librarian install` / `librarian config --token <token>` on your clients.");
|
|
698
|
+
lines.push("", "NOTE: no previous agent token was recoverable, so a fresh token was minted. Existing clients must update their token:", ` Agent token: ${freshToken}`);
|
|
345
699
|
}
|
|
346
700
|
return lines.join("\n");
|
|
347
701
|
}
|
|
702
|
+
async function checked(cmd, args, redact = redactSecrets) {
|
|
703
|
+
checkedResult(cmd, args, await run(cmd, args), redact);
|
|
704
|
+
}
|
|
705
|
+
function checkedResult(cmd, args, result, redact = redactSecrets) {
|
|
706
|
+
if (result.code === 0)
|
|
707
|
+
return;
|
|
708
|
+
throw new UpdateError(`\`${cmd} ${args[0]}\` failed (exit ${result.code ?? "signal"})` +
|
|
709
|
+
detailSuffix(result, redact) +
|
|
710
|
+
" Resolve the error, then re-run `librarian server update`.");
|
|
711
|
+
}
|
|
712
|
+
function detailSuffix(result, redact = redactSecrets) {
|
|
713
|
+
const detail = redact(result.stderr.trim() || result.stdout.trim());
|
|
714
|
+
return detail ? `: ${detail}` : ".";
|
|
715
|
+
}
|
|
716
|
+
function errorDetail(error, redact = redactSecrets) {
|
|
717
|
+
return redact(error instanceof Error ? error.message : String(error));
|
|
718
|
+
}
|
|
719
|
+
function envMap(value) {
|
|
720
|
+
const result = new Map();
|
|
721
|
+
if (!Array.isArray(value))
|
|
722
|
+
return result;
|
|
723
|
+
for (const entry of value) {
|
|
724
|
+
if (typeof entry !== "string")
|
|
725
|
+
continue;
|
|
726
|
+
const equals = entry.indexOf("=");
|
|
727
|
+
if (equals > 0)
|
|
728
|
+
result.set(entry.slice(0, equals), entry.slice(equals + 1));
|
|
729
|
+
}
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
/** A protected env-file that reproduces the previous container's auth exactly. */
|
|
733
|
+
function writePriorRecoveryEnv(file, previousEnv) {
|
|
734
|
+
const names = [
|
|
735
|
+
"LIBRARIAN_AGENT_TOKEN",
|
|
736
|
+
"LIBRARIAN_SECRET_KEY",
|
|
737
|
+
"LIBRARIAN_BOOTSTRAP_CLAIM_SECRET",
|
|
738
|
+
"LIBRARIAN_ALLOW_NO_AUTH",
|
|
739
|
+
];
|
|
740
|
+
const lines = [];
|
|
741
|
+
for (const name of names) {
|
|
742
|
+
if (!previousEnv.has(name))
|
|
743
|
+
continue;
|
|
744
|
+
const value = previousEnv.get(name);
|
|
745
|
+
if (/[\r\n]/.test(value)) {
|
|
746
|
+
throw new UpdateError(`Refusing to stage prior ${name} because it contains a newline.`);
|
|
747
|
+
}
|
|
748
|
+
lines.push(`${name}=${value}`);
|
|
749
|
+
}
|
|
750
|
+
fs.writeFileSync(file, lines.length > 0 ? `${lines.join("\n")}\n` : "", {
|
|
751
|
+
encoding: "utf8",
|
|
752
|
+
mode: 0o600,
|
|
753
|
+
});
|
|
754
|
+
fs.chmodSync(file, 0o600);
|
|
755
|
+
}
|
|
756
|
+
/** Shape-redact first, then scrub the exact runtime credentials we already know. */
|
|
757
|
+
function valueAwareRedactor(values) {
|
|
758
|
+
const secrets = [...new Set(values.filter((value) => Boolean(value)))].sort((left, right) => right.length - left.length);
|
|
759
|
+
return (text) => {
|
|
760
|
+
let redacted = redactSecrets(text);
|
|
761
|
+
for (const secret of secrets)
|
|
762
|
+
redacted = redacted.split(secret).join("[redacted]");
|
|
763
|
+
return redacted;
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/** Remove a protected update-only credential file, or return a truthful warning. */
|
|
767
|
+
function cleanupSecretArtifact(file) {
|
|
768
|
+
try {
|
|
769
|
+
updateSecretArtifactRemover(file);
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return (`WARNING: protected credential residue remains at ${file} (mode 0600). ` +
|
|
774
|
+
`Remove it manually with \`rm -- ${shellWord(file)}\` after recovery is complete.`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function objectRecord(value) {
|
|
778
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
779
|
+
? value
|
|
780
|
+
: {};
|
|
781
|
+
}
|
|
782
|
+
function verifiedNotFound(result, identity) {
|
|
783
|
+
const escaped = identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
784
|
+
return new RegExp(`no such (?:object|container):?\\s*${escaped}(?:\\s|$)`, "i").test(`${result.stdout}\n${result.stderr}`);
|
|
785
|
+
}
|
|
786
|
+
function shellWord(value) {
|
|
787
|
+
return /^[a-zA-Z0-9_./:@=-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
788
|
+
}
|
|
348
789
|
//# sourceMappingURL=update.js.map
|