@the-librarian/cli 1.20.1 → 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/up.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// `librarian server up` — build
|
|
1
|
+
// `librarian server up` — pull a stable release (or build a source ref) and run
|
|
2
|
+
// the all-in-one container.
|
|
2
3
|
//
|
|
3
|
-
// This is the loop-closer: on a fresh Docker host it
|
|
4
|
-
//
|
|
5
|
-
// agent token into a 0600 deploy env-file,
|
|
6
|
-
// `docker
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// This is the loop-closer: on a fresh Docker host it resolves and validates the
|
|
5
|
+
// stable release image (or clones/builds an arbitrary `--ref`), mints the master
|
|
6
|
+
// key + agent token into a 0600 deploy env-file, creates and starts the
|
|
7
|
+
// all-in-one container with `docker create --env-file` (ADR 0008 P4 — secrets
|
|
8
|
+
// off argv), waits for it to
|
|
9
|
+
// report healthy, surfaces the CLI-MINTED master key ONCE, and prints the MCP URL
|
|
10
|
+
// + dashboard URL + the agent token ready to paste into `librarian install`.
|
|
9
11
|
//
|
|
10
12
|
// ADR 0008 P3: there is no admin token. The admin tRPC API is served only on the
|
|
11
13
|
// trusted internal listener (off the network), so `server up` neither reads back
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
//
|
|
35
37
|
// Security (AGENTS.md): the agent token, master key, and optional bootstrap
|
|
36
38
|
// claim secret ride ONLY in the 0600 deploy env-file fed to
|
|
37
|
-
// `docker
|
|
39
|
+
// `docker create --env-file` (ADR 0008 P4) — never inline on argv. `--env-file`
|
|
38
40
|
// keeps them off the process argv (and out of any
|
|
39
41
|
// argv-echoing error); it does NOT hide them from `docker inspect .Config.Env`
|
|
40
42
|
// (docker expands the file client-side into the same env list) — that's an
|
|
@@ -51,15 +53,18 @@ import { readEnvFile, writeEnvFile } from "../env.js";
|
|
|
51
53
|
import { librarianDir } from "../paths.js";
|
|
52
54
|
import { fetchLatestVersion } from "../status.js";
|
|
53
55
|
import { enableBoot } from "./boot.js";
|
|
54
|
-
import { writeDeployState } from "./deploy-state.js";
|
|
56
|
+
import { deployStatePath, readDeployState, writeDeployState, } from "./deploy-state.js";
|
|
57
|
+
import { CANONICAL_IMAGE_NAME, prepareRegistryImage, isReleasedVersionRef, selectDeploymentTarget, shortImageDigest, } from "./deployment-image.js";
|
|
55
58
|
import { run, stream, which } from "./docker.js";
|
|
56
|
-
import {
|
|
59
|
+
import { dockerPreflight, sourcePreflight } from "./preflight.js";
|
|
57
60
|
import { redactSecrets } from "./redact.js";
|
|
61
|
+
import { CANONICAL_SOURCE_REPOSITORY, isCanonicalSourceRemote, redactGitDiagnostics, } from "./source-repository.js";
|
|
62
|
+
import { acquireUpdateLock, updateLockPath } from "./update-lock.js";
|
|
58
63
|
// Re-exported from its shared home so existing importers (`update.ts`) keep
|
|
59
64
|
// working; new code should import from `./redact.js` directly.
|
|
60
65
|
export { redactSecrets } from "./redact.js";
|
|
61
66
|
/** The repository the deploy dir clones (same repo the latest-tag fetch targets). */
|
|
62
|
-
export const REPO_URL =
|
|
67
|
+
export const REPO_URL = CANONICAL_SOURCE_REPOSITORY;
|
|
63
68
|
/** The container name every `server` command operates on (single instance per host). */
|
|
64
69
|
export const CONTAINER_NAME = "the-librarian";
|
|
65
70
|
/** The named data volume default (`--data-volume` overrides). The volume is sacred. */
|
|
@@ -118,10 +123,45 @@ export const SAVE_KEY_WARNING = "SAVE THIS KEY — excluded from backups";
|
|
|
118
123
|
* the client `~/.librarian/env` (env.ts). 0600, by construction.
|
|
119
124
|
*/
|
|
120
125
|
export const DEPLOY_ENV_FILE = "deploy.env";
|
|
126
|
+
/** Prefix for invocation-private credentials used while a candidate starts. */
|
|
127
|
+
export const STAGED_DEPLOY_ENV_FILE = "deploy.env.next";
|
|
121
128
|
/** `<deployDir>/deploy.env` — the 0600 deploy env-file path within a deploy dir. */
|
|
122
129
|
export function deployEnvFilePath(deployDir) {
|
|
123
130
|
return path.join(deployDir, DEPLOY_ENV_FILE);
|
|
124
131
|
}
|
|
132
|
+
/** An invocation-private staged env path, promoted only after health succeeds. */
|
|
133
|
+
export function stagedDeployEnvFilePath(deployDir, invocationId) {
|
|
134
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(invocationId)) {
|
|
135
|
+
throw new UpError("Could not create a safe invocation ID for the staged deploy env-file.");
|
|
136
|
+
}
|
|
137
|
+
return path.join(deployDir, `${STAGED_DEPLOY_ENV_FILE}-${invocationId}`);
|
|
138
|
+
}
|
|
139
|
+
const realStagedEnvIdMinter = () => randomBytes(12).toString("hex");
|
|
140
|
+
let stagedEnvIdMinter = realStagedEnvIdMinter;
|
|
141
|
+
/** Inject the non-secret staged-file suffix (tests only). */
|
|
142
|
+
export function setStagedEnvIdMinter(next) {
|
|
143
|
+
stagedEnvIdMinter = next;
|
|
144
|
+
}
|
|
145
|
+
export function resetStagedEnvIdMinter() {
|
|
146
|
+
stagedEnvIdMinter = realStagedEnvIdMinter;
|
|
147
|
+
}
|
|
148
|
+
const realFinalizationRenamer = (source, destination) => fs.renameSync(source, destination);
|
|
149
|
+
let finalizationRenamer = realFinalizationRenamer;
|
|
150
|
+
/** Inject the two final promotion operations (tests force the second to fail). */
|
|
151
|
+
export function setFinalizationRenamer(next) {
|
|
152
|
+
finalizationRenamer = next;
|
|
153
|
+
}
|
|
154
|
+
export function resetFinalizationRenamer() {
|
|
155
|
+
finalizationRenamer = realFinalizationRenamer;
|
|
156
|
+
}
|
|
157
|
+
let finalizationRestorer = restoreFile;
|
|
158
|
+
/** Inject prior-file restoration failures (tests only). */
|
|
159
|
+
export function setFinalizationRestorer(next) {
|
|
160
|
+
finalizationRestorer = next;
|
|
161
|
+
}
|
|
162
|
+
export function resetFinalizationRestorer() {
|
|
163
|
+
finalizationRestorer = restoreFile;
|
|
164
|
+
}
|
|
125
165
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
166
|
let sleepImpl = realSleep;
|
|
127
167
|
/** Override the health-poll sleep (tests inject a no-op so polling is instant). */
|
|
@@ -175,6 +215,15 @@ export class UpError extends Error {
|
|
|
175
215
|
this.name = "UpError";
|
|
176
216
|
}
|
|
177
217
|
}
|
|
218
|
+
/** Deployment-file promotion failed; tells update whether exact prior bytes were restored. */
|
|
219
|
+
export class DeploymentFinalizationError extends UpError {
|
|
220
|
+
priorFilesRestored;
|
|
221
|
+
constructor(message, priorFilesRestored) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.priorFilesRestored = priorFilesRestored;
|
|
224
|
+
this.name = "DeploymentFinalizationError";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
178
227
|
/**
|
|
179
228
|
* Construct the `docker run` argv (everything after `docker`). The SINGLE place
|
|
180
229
|
* the run vector is assembled.
|
|
@@ -193,14 +242,14 @@ export class UpError extends Error {
|
|
|
193
242
|
* {@link writeDeployEnvFile}), not on this argv.
|
|
194
243
|
*/
|
|
195
244
|
export function buildRunArgs(input) {
|
|
196
|
-
const { host, dataVolume, dashboardPort, dataDir, runAsUser,
|
|
245
|
+
const { host, dataVolume, dashboardPort, dataDir, runAsUser, restartPolicy = "unless-stopped", imageRef, envFile, } = input;
|
|
197
246
|
const args = [
|
|
198
247
|
"run",
|
|
199
248
|
"-d",
|
|
200
249
|
"--name",
|
|
201
250
|
CONTAINER_NAME,
|
|
202
251
|
"--restart",
|
|
203
|
-
|
|
252
|
+
restartPolicy,
|
|
204
253
|
// Publish the dashboard on the chosen HOST port; the container always listens
|
|
205
254
|
// on 3000 internally (image PORT=3000), so only the left side varies.
|
|
206
255
|
"-p",
|
|
@@ -217,17 +266,13 @@ export function buildRunArgs(input) {
|
|
|
217
266
|
// and writable by — the operator, not the image's default user.
|
|
218
267
|
if (runAsUser)
|
|
219
268
|
args.push("--user", runAsUser);
|
|
220
|
-
args.push(
|
|
269
|
+
args.push(imageRef);
|
|
221
270
|
return args;
|
|
222
271
|
}
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
function resolveHostDataDir(input) {
|
|
228
|
-
const abs = path.resolve(input);
|
|
229
|
-
fs.mkdirSync(abs, { recursive: true });
|
|
230
|
-
return abs;
|
|
272
|
+
/** Build the detached candidate configuration without starting it. */
|
|
273
|
+
export function buildCreateArgs(input) {
|
|
274
|
+
const runArgs = buildRunArgs(input);
|
|
275
|
+
return ["create", ...runArgs.slice(2)];
|
|
231
276
|
}
|
|
232
277
|
/**
|
|
233
278
|
* The `uid:gid` that owns a path — the user the container runs as for a
|
|
@@ -250,7 +295,7 @@ export function dirOwner(dir) {
|
|
|
250
295
|
* raw value is safe; we reject a value with a newline (it would corrupt the file
|
|
251
296
|
* / smuggle a second var) rather than emit a malformed file.
|
|
252
297
|
*/
|
|
253
|
-
|
|
298
|
+
function writeDeployEnvFileAt(file, input) {
|
|
254
299
|
const secretKey = input.secretKey?.trim() ?? "";
|
|
255
300
|
const bootstrapClaimSecret = input.bootstrapClaimSecret ?? "";
|
|
256
301
|
for (const [name, value] of [
|
|
@@ -263,7 +308,7 @@ export function writeDeployEnvFile(deployDir, input) {
|
|
|
263
308
|
}
|
|
264
309
|
}
|
|
265
310
|
validateBootstrapClaimSecret(bootstrapClaimSecret);
|
|
266
|
-
fs.mkdirSync(
|
|
311
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
267
312
|
const lines = [`LIBRARIAN_AGENT_TOKEN=${input.agentToken}`];
|
|
268
313
|
// Omit the key line entirely when absent (update's read-back-failed path) — the
|
|
269
314
|
// server then resolves it from /data/secret.key, preserving encrypted secrets.
|
|
@@ -276,13 +321,18 @@ export function writeDeployEnvFile(deployDir, input) {
|
|
|
276
321
|
if (input.host === LOCALHOST) {
|
|
277
322
|
lines.push("LIBRARIAN_ALLOW_NO_AUTH=true");
|
|
278
323
|
}
|
|
279
|
-
const file = deployEnvFilePath(deployDir);
|
|
280
324
|
fs.writeFileSync(file, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
281
325
|
// writeFileSync only applies `mode` on create; chmod unconditionally so a
|
|
282
326
|
// pre-existing looser file is tightened (env.ts discipline).
|
|
283
327
|
fs.chmodSync(file, 0o600);
|
|
284
328
|
return file;
|
|
285
329
|
}
|
|
330
|
+
export function writeDeployEnvFile(deployDir, input) {
|
|
331
|
+
return writeDeployEnvFileAt(deployEnvFilePath(deployDir), input);
|
|
332
|
+
}
|
|
333
|
+
export function writeStagedDeployEnvFile(deployDir, input) {
|
|
334
|
+
return writeDeployEnvFileAt(stagedDeployEnvFilePath(deployDir, stagedEnvIdMinter()), input);
|
|
335
|
+
}
|
|
286
336
|
/**
|
|
287
337
|
* Parse an existing deploy env-file into a `KEY=VALUE` record, or `{}` when absent.
|
|
288
338
|
* `up` uses this to REUSE the master key and optional bootstrap-claim secret
|
|
@@ -325,8 +375,14 @@ export async function runUp(options, deps) {
|
|
|
325
375
|
// Progress to stderr (the stdout result carries the master key) so a long `up`
|
|
326
376
|
// shows where it is + what remains, instead of a blank line.
|
|
327
377
|
const log = deps.log ?? ((line) => void process.stderr.write(`${line}\n`));
|
|
328
|
-
// 1)
|
|
329
|
-
|
|
378
|
+
// 1) Select before preflight: stable releases need Docker only; arbitrary refs
|
|
379
|
+
// keep the source path's Docker + Git contract.
|
|
380
|
+
const target = selectDeploymentTarget(options.ref);
|
|
381
|
+
const preflightOptions = deps.platform ? { platform: deps.platform } : {};
|
|
382
|
+
if (target.imageSource === "registry")
|
|
383
|
+
await dockerPreflight(preflightOptions);
|
|
384
|
+
else
|
|
385
|
+
await sourcePreflight(preflightOptions);
|
|
330
386
|
// 2) Resolve the bind host (default loopback; Tailscale offer; `0.0.0.0`
|
|
331
387
|
// ask-first). May throw `UpError` if the user declines a `0.0.0.0` bind —
|
|
332
388
|
// BEFORE any clone/build/run, so a declined exposure leaves nothing behind.
|
|
@@ -350,89 +406,242 @@ export async function runUp(options, deps) {
|
|
|
350
406
|
if (options.dataDir && options.dataVolume) {
|
|
351
407
|
throw new UpError("Pass either --data-dir (a host directory) or --data-volume (a Docker volume), not both.");
|
|
352
408
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
//
|
|
398
|
-
// ADR 0008 P4: the master key is no longer READ BACK from the container
|
|
399
|
-
// (`docker exec cat /data/secret.key`) — the CLI minted it and supplied
|
|
400
|
-
// it via env, so the server never writes that file. We surface the
|
|
401
|
-
// key we minted.
|
|
402
|
-
log("[5/5] Waiting for the server to become healthy…");
|
|
409
|
+
// Resolve without creating yet: a registry pull/metadata failure must leave no
|
|
410
|
+
// new host material. Source behavior still creates it before clone/build.
|
|
411
|
+
const dataDir = options.dataDir ? path.resolve(options.dataDir) : undefined;
|
|
412
|
+
if (target.imageSource === "source" && dataDir)
|
|
413
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
414
|
+
// 3) Resolve the ref, then prepare either the immutable registry image or the
|
|
415
|
+
// source checkout/build. Stable failures never fall back to source.
|
|
416
|
+
if (target.imageSource === "registry") {
|
|
417
|
+
log(target.ref
|
|
418
|
+
? `[1/5] Selecting exact published release ${target.ref}…`
|
|
419
|
+
: "[1/5] Resolving the latest published release…");
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
log(`[1/5] Selecting source ref ${target.ref}…`);
|
|
423
|
+
}
|
|
424
|
+
const tag = target.imageSource === "registry"
|
|
425
|
+
? await resolveRegistryRef(target.ref)
|
|
426
|
+
: await resolveRef(target.ref);
|
|
427
|
+
// The shared deployment lock lives inside deployDir. An initial source clone
|
|
428
|
+
// must therefore happen before acquiring it; otherwise the lockfile itself
|
|
429
|
+
// makes the clone target non-empty. Existing managed clones are prepared
|
|
430
|
+
// under the lock below, alongside the rest of the deployment transaction.
|
|
431
|
+
const sourceNeedsInitialPreparation = target.imageSource === "source" && !(await pathExists(path.join(deployDir, ".git")));
|
|
432
|
+
if (sourceNeedsInitialPreparation) {
|
|
433
|
+
log(`[2/5] Preparing the source checkout at ${deployDir} (cloning the repository)…`);
|
|
434
|
+
await prepareDeployDir(deployDir, tag);
|
|
435
|
+
}
|
|
436
|
+
// `server up` and every update path share one host-level critical section.
|
|
437
|
+
// From the first container inspect through env/state finalization, no loser
|
|
438
|
+
// may observe or mutate the winner's deployment files or candidate.
|
|
439
|
+
const deploymentLockPath = updateLockPath({ home: deps.home, dir: options.dir });
|
|
440
|
+
const deploymentLock = acquireUpdateLock(deploymentLockPath);
|
|
441
|
+
if (!deploymentLock) {
|
|
442
|
+
throw new UpError(`Another server deployment is already in progress (lock: ${deploymentLockPath}). ` +
|
|
443
|
+
"Wait for it to finish, then re-run `librarian server up`.");
|
|
444
|
+
}
|
|
445
|
+
let lockReleased = false;
|
|
446
|
+
const releaseDeploymentLock = () => {
|
|
447
|
+
if (lockReleased)
|
|
448
|
+
return;
|
|
449
|
+
lockReleased = true;
|
|
450
|
+
deploymentLock.release();
|
|
451
|
+
};
|
|
452
|
+
let completedDeployment;
|
|
403
453
|
try {
|
|
404
|
-
|
|
405
|
-
|
|
454
|
+
const existingDeployEnv = readDeployEnvFile(deployDir);
|
|
455
|
+
const bootstrapClaimSecret = suppliedBootstrapClaimSecret === undefined
|
|
456
|
+
? existingDeployEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET
|
|
457
|
+
: suppliedBootstrapClaimSecret || undefined;
|
|
458
|
+
validateBootstrapClaimSecret(bootstrapClaimSecret ?? "");
|
|
459
|
+
const existingContainer = await inspectContainerPresence();
|
|
460
|
+
if (existingContainer.exists) {
|
|
461
|
+
if (target.imageSource !== "registry")
|
|
462
|
+
throw existingContainerError([]);
|
|
463
|
+
const existingState = readDeployState(deployDir);
|
|
464
|
+
const drift = registryDeploymentDrift(deployDir, tag, existingState, existingContainer.container, {
|
|
465
|
+
host,
|
|
466
|
+
dataVolume,
|
|
467
|
+
dataDir,
|
|
468
|
+
dashboardPort,
|
|
469
|
+
bootstrapClaimSecret,
|
|
470
|
+
});
|
|
471
|
+
if (drift.length > 0)
|
|
472
|
+
throw existingContainerError(drift);
|
|
473
|
+
if (existingState?.imageSource !== "registry") {
|
|
474
|
+
throw existingContainerError(["persisted deployment state"]);
|
|
475
|
+
}
|
|
476
|
+
log("✓ The requested release is already running and healthy.");
|
|
477
|
+
releaseDeploymentLock();
|
|
478
|
+
return alreadyRunningOutput(`published ${tag} (${shortImageDigest(existingState.imageDigest)})`, host, dashboardPort, await enableBootOutput(options, deps));
|
|
479
|
+
}
|
|
480
|
+
let registryImage;
|
|
481
|
+
if (target.imageSource === "registry") {
|
|
482
|
+
log(`[2/5] Pulling and validating ${tag} from the release registry…`);
|
|
483
|
+
registryImage = await prepareRegistryImage(tag, (chunk) => {
|
|
484
|
+
if (deps.log) {
|
|
485
|
+
for (const line of chunk.split("\n"))
|
|
486
|
+
if (line.length > 0)
|
|
487
|
+
log(line);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
process.stderr.write(chunk);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else if (!sourceNeedsInitialPreparation) {
|
|
495
|
+
log(`[2/5] Preparing the source checkout at ${deployDir} (cloning the repository)…`);
|
|
496
|
+
await prepareDeployDir(deployDir, tag);
|
|
497
|
+
}
|
|
498
|
+
if (target.imageSource === "registry" && dataDir)
|
|
499
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
500
|
+
const runAsUser = dataDir ? dirOwner(dataDir) : undefined;
|
|
501
|
+
// 4) Source targets build locally; registry targets are already verified and
|
|
502
|
+
// run only by immutable digest. No credential material is staged until this
|
|
503
|
+
// preparation has succeeded.
|
|
504
|
+
let runImageRef;
|
|
505
|
+
if (registryImage) {
|
|
506
|
+
log(`[3/5] Release image verified at ${registryImage.imageDigest}.`);
|
|
507
|
+
runImageRef = registryImage.imageDigest;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
log(`[3/5] Building source image ${CONTAINER_NAME}:${tag} — the slow step: pulling the base ` +
|
|
511
|
+
`image, installing dependencies, and downloading the embeddings model. Expect several ` +
|
|
512
|
+
`minutes on a first run; live build output follows.`);
|
|
513
|
+
await build(deployDir, tag);
|
|
514
|
+
runImageRef = `${CONTAINER_NAME}:${tag}`;
|
|
515
|
+
}
|
|
516
|
+
// 5) Mint the secrets the CLI owns (the loop-closer). Both are CSPRNG and
|
|
517
|
+
// NEVER logged: the agent token, and — ADR 0008 P4 — the master key. The
|
|
518
|
+
// MASTER KEY must NOT be re-minted on a re-run — that orphans every secret
|
|
519
|
+
// encrypted under the previous key. A candidate env-file is kept separate
|
|
520
|
+
// from the live one until the new container reports healthy.
|
|
521
|
+
const agentToken = minter();
|
|
522
|
+
const existingKey = existingDeployEnv.LIBRARIAN_SECRET_KEY?.trim() || undefined;
|
|
523
|
+
const masterKey = existingKey ?? mintSecretKey();
|
|
524
|
+
const mintedKey = existingKey === undefined;
|
|
525
|
+
const envFile = writeStagedDeployEnvFile(deployDir, {
|
|
526
|
+
agentToken,
|
|
527
|
+
secretKey: masterKey,
|
|
528
|
+
bootstrapClaimSecret,
|
|
529
|
+
host,
|
|
530
|
+
});
|
|
531
|
+
const nextDeployState = registryImage
|
|
532
|
+
? {
|
|
533
|
+
containerName: CONTAINER_NAME,
|
|
534
|
+
host,
|
|
535
|
+
dataVolume,
|
|
536
|
+
dataDir,
|
|
537
|
+
dashboardPort,
|
|
538
|
+
ref: tag,
|
|
539
|
+
imageTag: registryImage.imageRef,
|
|
540
|
+
imageSource: "registry",
|
|
541
|
+
imageRef: registryImage.imageRef,
|
|
542
|
+
imageDigest: registryImage.imageDigest,
|
|
543
|
+
}
|
|
544
|
+
: {
|
|
545
|
+
containerName: CONTAINER_NAME,
|
|
546
|
+
host,
|
|
547
|
+
dataVolume,
|
|
548
|
+
dataDir,
|
|
549
|
+
dashboardPort,
|
|
550
|
+
ref: tag,
|
|
551
|
+
imageTag: `${CONTAINER_NAME}:${tag}`,
|
|
552
|
+
imageSource: "source",
|
|
553
|
+
imageRef: `${CONTAINER_NAME}:${tag}`,
|
|
554
|
+
};
|
|
555
|
+
log("[4/5] Starting the container…");
|
|
556
|
+
const createArgs = buildCreateArgs({
|
|
557
|
+
host,
|
|
558
|
+
dataVolume,
|
|
559
|
+
dashboardPort,
|
|
560
|
+
dataDir,
|
|
561
|
+
runAsUser,
|
|
562
|
+
imageRef: runImageRef,
|
|
563
|
+
envFile,
|
|
564
|
+
});
|
|
565
|
+
let candidateId = null;
|
|
566
|
+
let healthRollbackComplete = false;
|
|
567
|
+
try {
|
|
568
|
+
const createResult = await run("docker", createArgs, { cwd: deployDir });
|
|
569
|
+
failIfNonZero("docker", createArgs, createResult);
|
|
570
|
+
const returnedId = createResult.stdout.trim();
|
|
571
|
+
if (!/^[0-9a-f]{64}$/.test(returnedId)) {
|
|
572
|
+
throw new UpError("Docker created the candidate container but did not return its full immutable ID. " +
|
|
573
|
+
`Refusing unsafe cleanup by mutable name; inspect ${CONTAINER_NAME} manually before retrying.`);
|
|
574
|
+
}
|
|
575
|
+
candidateId = returnedId;
|
|
576
|
+
const startArgs = ["start", candidateId];
|
|
577
|
+
const startResult = await run("docker", startArgs, { cwd: deployDir });
|
|
578
|
+
failIfNonZero("docker", startArgs, startResult);
|
|
579
|
+
// 6+7) Wait for health. ANY failure in this post-`docker start` phase — a
|
|
580
|
+
// timeout/unhealthy report or an exception from `docker inspect`/`sleep`
|
|
581
|
+
// — MUST force-remove the container so no half-up state is left behind
|
|
582
|
+
// (spec §11). `waitForHealthy` already rolls back on its own
|
|
583
|
+
// timeout/unhealthy path; this guard catches the throwing cases it
|
|
584
|
+
// can't. Every cleanup targets the immutable candidate ID.
|
|
585
|
+
//
|
|
586
|
+
// ADR 0008 P4: the master key is no longer READ BACK from the container
|
|
587
|
+
// (`docker exec cat /data/secret.key`) — the CLI minted it and supplied
|
|
588
|
+
// it via env, so the server never writes that file. We surface the
|
|
589
|
+
// key we minted.
|
|
590
|
+
log("[5/5] Waiting for the server to become healthy…");
|
|
591
|
+
await waitForHealthy({ ...options, containerIdentity: candidateId });
|
|
592
|
+
log("✓ The server is healthy.");
|
|
593
|
+
// Promote the non-secret deploy state and the secret-bearing env-file as
|
|
594
|
+
// one recoverable operation. A failed promotion restores both exact prior
|
|
595
|
+
// files (or their absence) before the candidate is removed.
|
|
596
|
+
finalizeDeploymentFiles(deployDir, envFile, nextDeployState);
|
|
597
|
+
candidateId = null;
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (error instanceof HealthRollbackCompleteError) {
|
|
601
|
+
healthRollbackComplete = true;
|
|
602
|
+
candidateId = null;
|
|
603
|
+
}
|
|
604
|
+
if (candidateId && !healthRollbackComplete) {
|
|
605
|
+
try {
|
|
606
|
+
await removeAttemptContainer(candidateId);
|
|
607
|
+
candidateId = null;
|
|
608
|
+
}
|
|
609
|
+
catch (cleanupError) {
|
|
610
|
+
removeStagedEnv(envFile);
|
|
611
|
+
throw cleanupError;
|
|
612
|
+
}
|
|
613
|
+
if (error instanceof CleanupError) {
|
|
614
|
+
removeStagedEnv(envFile);
|
|
615
|
+
throw new UpError("The candidate container could not start or become healthy. The first cleanup " +
|
|
616
|
+
"attempt failed, but a retry " +
|
|
617
|
+
"removed the candidate container; the data volume is untouched. Fix the startup " +
|
|
618
|
+
"cause, then re-run `librarian server up`.");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
removeStagedEnv(envFile);
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
completedDeployment = {
|
|
625
|
+
agentToken,
|
|
626
|
+
masterKey,
|
|
627
|
+
mintedKey,
|
|
628
|
+
deploymentIdentity: registryImage
|
|
629
|
+
? `published ${tag} (${shortImageDigest(registryImage.imageDigest)})`
|
|
630
|
+
: `source ${tag}`,
|
|
631
|
+
};
|
|
406
632
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
throw error;
|
|
633
|
+
finally {
|
|
634
|
+
releaseDeploymentLock();
|
|
410
635
|
}
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
// tag / container name — NEVER a token or key (deploy-state.ts whitelists
|
|
415
|
-
// the fields). Only after a confirmed-healthy run, so the recorded state
|
|
416
|
-
// reflects a container that actually came up.
|
|
417
|
-
writeDeployState(deployDir, {
|
|
418
|
-
containerName: CONTAINER_NAME,
|
|
419
|
-
host,
|
|
420
|
-
dataVolume,
|
|
421
|
-
dataDir,
|
|
422
|
-
dashboardPort,
|
|
423
|
-
ref: tag,
|
|
424
|
-
imageTag: `${CONTAINER_NAME}:${tag}`,
|
|
425
|
-
});
|
|
636
|
+
// A successful locked body always assigns this before releasing the lock;
|
|
637
|
+
// every failure throws through the finally above.
|
|
638
|
+
const { agentToken, masterKey, mintedKey, deploymentIdentity } = completedDeployment;
|
|
426
639
|
// 8) Boot persistence (opt-in, spec §5.8). With `--enable-boot`, install +
|
|
427
640
|
// enable the systemd unit AFTER a healthy up (so the named container the
|
|
428
641
|
// unit references actually exists). On macOS this prints the deferred
|
|
429
642
|
// notice and continues — the `up` still succeeds. The unit references the
|
|
430
643
|
// EXISTING container by name and carries NO secret (boot.ts).
|
|
431
|
-
const lines =
|
|
432
|
-
if (options.enableBoot) {
|
|
433
|
-
const bootResult = await enableBoot(deps.platform ? { platform: deps.platform } : {});
|
|
434
|
-
lines.push(bootResult.output, "");
|
|
435
|
-
}
|
|
644
|
+
const lines = await enableBootOutput(options, deps);
|
|
436
645
|
// 9) Close the loop: surface secrets/URLs + offer the local env write.
|
|
437
646
|
await closeTheLoop(lines, {
|
|
438
647
|
host,
|
|
@@ -440,6 +649,7 @@ export async function runUp(options, deps) {
|
|
|
440
649
|
agentToken,
|
|
441
650
|
masterKey,
|
|
442
651
|
mintedKey,
|
|
652
|
+
deploymentIdentity,
|
|
443
653
|
options,
|
|
444
654
|
deps,
|
|
445
655
|
});
|
|
@@ -559,6 +769,249 @@ async function resolveRef(ref) {
|
|
|
559
769
|
// `fetchLatestVersion` strips the leading `v`; the tag we check out keeps it.
|
|
560
770
|
return `v${latest}`;
|
|
561
771
|
}
|
|
772
|
+
/** Resolve an omitted stable ref and reject any non-release response fail-closed. */
|
|
773
|
+
async function resolveRegistryRef(ref) {
|
|
774
|
+
if (ref !== undefined)
|
|
775
|
+
return ref;
|
|
776
|
+
const latest = await fetchLatestVersion();
|
|
777
|
+
const candidate = latest ? `v${latest}` : "";
|
|
778
|
+
if (!candidate || !isReleasedVersionRef(candidate)) {
|
|
779
|
+
throw new UpError("Could not resolve a valid latest stable release tag from GitHub. Check your network and " +
|
|
780
|
+
"GitHub availability, or pin an exact stable tag with `--ref vX.Y.Z`. Stable installs " +
|
|
781
|
+
"never fall back to a source build.");
|
|
782
|
+
}
|
|
783
|
+
return candidate;
|
|
784
|
+
}
|
|
785
|
+
function verifiedNotFound(result, identity = CONTAINER_NAME) {
|
|
786
|
+
const detail = `${result.stdout}\n${result.stderr}`;
|
|
787
|
+
const escapedIdentity = identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
788
|
+
return (new RegExp(`no such (?:object|container):?\\s*${escapedIdentity}(?:\\s|$)`, "i").test(detail) ||
|
|
789
|
+
new RegExp(`no container .*${escapedIdentity}.* found`, "i").test(detail));
|
|
790
|
+
}
|
|
791
|
+
/** Inspect the fixed container name without treating daemon/permission failures as absence. */
|
|
792
|
+
async function inspectContainerPresence() {
|
|
793
|
+
const result = await run("docker", [
|
|
794
|
+
"container",
|
|
795
|
+
"inspect",
|
|
796
|
+
"--format",
|
|
797
|
+
"{{json .}}",
|
|
798
|
+
CONTAINER_NAME,
|
|
799
|
+
]);
|
|
800
|
+
if (result.code !== 0) {
|
|
801
|
+
if (verifiedNotFound(result))
|
|
802
|
+
return { exists: false };
|
|
803
|
+
const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
|
|
804
|
+
throw new UpError(`Could not inspect the existing ${CONTAINER_NAME} container` +
|
|
805
|
+
(detail ? `: ${detail}` : ".") +
|
|
806
|
+
" Fix Docker access, then re-run `librarian server up`.");
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const parsed = JSON.parse(result.stdout);
|
|
810
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
811
|
+
throw new Error();
|
|
812
|
+
return { exists: true, container: parsed };
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
throw new UpError(`Docker returned malformed metadata for the existing ${CONTAINER_NAME} container. ` +
|
|
816
|
+
"Refusing to replace it; inspect or remove it manually, then re-run `librarian server up`.");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function envRecord(value) {
|
|
820
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))
|
|
821
|
+
return null;
|
|
822
|
+
const record = {};
|
|
823
|
+
for (const entry of value) {
|
|
824
|
+
const equals = entry.indexOf("=");
|
|
825
|
+
if (equals > 0)
|
|
826
|
+
record[entry.slice(0, equals)] = entry.slice(equals + 1);
|
|
827
|
+
}
|
|
828
|
+
return record;
|
|
829
|
+
}
|
|
830
|
+
function exactPortBinding(bindings, key, host, port) {
|
|
831
|
+
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings))
|
|
832
|
+
return false;
|
|
833
|
+
const value = bindings[key];
|
|
834
|
+
return (Array.isArray(value) &&
|
|
835
|
+
value.length === 1 &&
|
|
836
|
+
value[0] !== null &&
|
|
837
|
+
typeof value[0] === "object" &&
|
|
838
|
+
!Array.isArray(value[0]) &&
|
|
839
|
+
value[0].HostIp === host &&
|
|
840
|
+
value[0].HostPort === String(port));
|
|
841
|
+
}
|
|
842
|
+
/** Exact live + persisted identity check performed before any registry access. */
|
|
843
|
+
function registryDeploymentDrift(deployDir, ref, state, container, desired) {
|
|
844
|
+
const drift = [];
|
|
845
|
+
if (!state ||
|
|
846
|
+
state.imageSource !== "registry" ||
|
|
847
|
+
state.ref !== ref ||
|
|
848
|
+
state.imageRef !== `${CANONICAL_IMAGE_NAME}:${ref}` ||
|
|
849
|
+
state.imageTag !== `${CANONICAL_IMAGE_NAME}:${ref}` ||
|
|
850
|
+
!state.imageDigest ||
|
|
851
|
+
state.host !== desired.host ||
|
|
852
|
+
state.dataVolume !== desired.dataVolume ||
|
|
853
|
+
state.dataDir !== desired.dataDir ||
|
|
854
|
+
state.dashboardPort !== desired.dashboardPort) {
|
|
855
|
+
drift.push("persisted deployment state");
|
|
856
|
+
}
|
|
857
|
+
const digest = state?.imageSource === "registry" ? state.imageDigest : undefined;
|
|
858
|
+
if (container.State?.Health?.Status !== "healthy")
|
|
859
|
+
drift.push("health status");
|
|
860
|
+
if (!digest || container.Config?.Image !== digest)
|
|
861
|
+
drift.push("image digest");
|
|
862
|
+
const mounts = Array.isArray(container.Mounts)
|
|
863
|
+
? container.Mounts.filter((mount) => mount !== null &&
|
|
864
|
+
typeof mount === "object" &&
|
|
865
|
+
!Array.isArray(mount) &&
|
|
866
|
+
mount.Destination === "/data")
|
|
867
|
+
: [];
|
|
868
|
+
const mount = mounts.length === 1 ? mounts[0] : undefined;
|
|
869
|
+
if (!mount ||
|
|
870
|
+
(desired.dataDir
|
|
871
|
+
? mount.Type !== "bind" || mount.Source !== desired.dataDir
|
|
872
|
+
: mount.Type !== "volume" || mount.Name !== desired.dataVolume)) {
|
|
873
|
+
drift.push("/data mount source or type");
|
|
874
|
+
}
|
|
875
|
+
const bindings = container.HostConfig?.PortBindings;
|
|
876
|
+
if (!bindings ||
|
|
877
|
+
typeof bindings !== "object" ||
|
|
878
|
+
Array.isArray(bindings) ||
|
|
879
|
+
Object.keys(bindings).sort().join(",") !== `3000/tcp,${MCP_PUBLISHED_PORT}/tcp` ||
|
|
880
|
+
!exactPortBinding(bindings, "3000/tcp", desired.host, desired.dashboardPort) ||
|
|
881
|
+
!exactPortBinding(bindings, `${MCP_PUBLISHED_PORT}/tcp`, desired.host, MCP_PUBLISHED_PORT)) {
|
|
882
|
+
drift.push("published host or ports");
|
|
883
|
+
}
|
|
884
|
+
if (container.HostConfig?.RestartPolicy?.Name !== "unless-stopped") {
|
|
885
|
+
drift.push("restart policy");
|
|
886
|
+
}
|
|
887
|
+
let expectedUser = "node";
|
|
888
|
+
if (desired.dataDir) {
|
|
889
|
+
try {
|
|
890
|
+
expectedUser = dirOwner(desired.dataDir);
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
drift.push("bind-mounted data directory");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (container.Config?.User !== expectedUser)
|
|
897
|
+
drift.push("container user");
|
|
898
|
+
const persistedEnv = readDeployEnvFile(deployDir);
|
|
899
|
+
const liveEnv = envRecord(container.Config?.Env);
|
|
900
|
+
const requiredRuntime = {
|
|
901
|
+
LIBRARIAN_DATA_DIR: "/data",
|
|
902
|
+
LIBRARIAN_HOST: ALL_INTERFACES,
|
|
903
|
+
LIBRARIAN_PORT: String(MCP_PUBLISHED_PORT),
|
|
904
|
+
PORT: "3000",
|
|
905
|
+
};
|
|
906
|
+
let envMatches = liveEnv !== null;
|
|
907
|
+
for (const [name, value] of Object.entries(requiredRuntime)) {
|
|
908
|
+
if (liveEnv?.[name] !== value)
|
|
909
|
+
envMatches = false;
|
|
910
|
+
}
|
|
911
|
+
for (const name of [
|
|
912
|
+
"LIBRARIAN_AGENT_TOKEN",
|
|
913
|
+
"LIBRARIAN_SECRET_KEY",
|
|
914
|
+
"LIBRARIAN_BOOTSTRAP_CLAIM_SECRET",
|
|
915
|
+
]) {
|
|
916
|
+
const expected = persistedEnv[name];
|
|
917
|
+
if ((liveEnv?.[name] || undefined) !== (expected || undefined))
|
|
918
|
+
envMatches = false;
|
|
919
|
+
}
|
|
920
|
+
if (!persistedEnv.LIBRARIAN_AGENT_TOKEN || !persistedEnv.LIBRARIAN_SECRET_KEY) {
|
|
921
|
+
envMatches = false;
|
|
922
|
+
}
|
|
923
|
+
if ((persistedEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET || undefined) !==
|
|
924
|
+
(desired.bootstrapClaimSecret || undefined)) {
|
|
925
|
+
envMatches = false;
|
|
926
|
+
}
|
|
927
|
+
const expectedNoAuth = desired.host === LOCALHOST ? "true" : undefined;
|
|
928
|
+
if ((liveEnv?.LIBRARIAN_ALLOW_NO_AUTH || undefined) !== expectedNoAuth)
|
|
929
|
+
envMatches = false;
|
|
930
|
+
if (!envMatches)
|
|
931
|
+
drift.push("authentication or runtime environment");
|
|
932
|
+
return drift;
|
|
933
|
+
}
|
|
934
|
+
async function enableBootOutput(options, deps) {
|
|
935
|
+
if (!options.enableBoot)
|
|
936
|
+
return [];
|
|
937
|
+
const bootResult = await enableBoot(deps.platform ? { platform: deps.platform } : {});
|
|
938
|
+
return [bootResult.output, ""];
|
|
939
|
+
}
|
|
940
|
+
function existingContainerError(drift) {
|
|
941
|
+
const detail = drift.length > 0 ? ` (${drift.join(", ")})` : "";
|
|
942
|
+
return new UpError(`The existing ${CONTAINER_NAME} container differs from the ` +
|
|
943
|
+
`requested healthy deployment${detail}. Refusing to pull, rewrite credentials, or replace ` +
|
|
944
|
+
"it. Run `librarian server down` if you intend to replace it, then retry.");
|
|
945
|
+
}
|
|
946
|
+
function alreadyRunningOutput(deploymentIdentity, host, dashboardPort, bootLines) {
|
|
947
|
+
const lines = [...bootLines];
|
|
948
|
+
lines.push(`The Librarian server is already running and healthy from ${deploymentIdentity}.`, "", ` MCP URL: http://${host}:${MCP_PUBLISHED_PORT}/mcp`, ` Dashboard: http://${host}:${dashboardPort}`);
|
|
949
|
+
return { output: lines.join("\n") };
|
|
950
|
+
}
|
|
951
|
+
export function removeStagedEnv(stagedEnvFile) {
|
|
952
|
+
try {
|
|
953
|
+
fs.rmSync(stagedEnvFile, { force: true });
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// The primary deployment/cleanup error is more useful than a temp-file error.
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function snapshotFile(file) {
|
|
960
|
+
try {
|
|
961
|
+
const stat = fs.statSync(file);
|
|
962
|
+
return { contents: fs.readFileSync(file), mode: stat.mode & 0o777 };
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
if (error.code === "ENOENT")
|
|
966
|
+
return { contents: null };
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
function restoreFile(file, snapshot) {
|
|
971
|
+
if (snapshot.contents === null) {
|
|
972
|
+
fs.rmSync(file, { force: true });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
fs.writeFileSync(file, snapshot.contents, { mode: snapshot.mode });
|
|
976
|
+
if (snapshot.mode !== undefined)
|
|
977
|
+
fs.chmodSync(file, snapshot.mode);
|
|
978
|
+
}
|
|
979
|
+
/** Promote env + state as one guarded unit, restoring exact prior bytes on failure. */
|
|
980
|
+
export function finalizeDeploymentFiles(deployDir, stagedEnvFile, state) {
|
|
981
|
+
const liveEnvFile = deployEnvFilePath(deployDir);
|
|
982
|
+
const liveStateFile = deployStatePath(deployDir);
|
|
983
|
+
const priorEnv = snapshotFile(liveEnvFile);
|
|
984
|
+
const priorState = snapshotFile(liveStateFile);
|
|
985
|
+
const stagedStateDir = fs.mkdtempSync(path.join(deployDir, ".deploy-state-next-"));
|
|
986
|
+
const stagedStateFile = deployStatePath(stagedStateDir);
|
|
987
|
+
try {
|
|
988
|
+
writeDeployState(stagedStateDir, state);
|
|
989
|
+
finalizationRenamer(stagedEnvFile, liveEnvFile);
|
|
990
|
+
finalizationRenamer(stagedStateFile, liveStateFile);
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
try {
|
|
994
|
+
finalizationRestorer(liveEnvFile, priorEnv);
|
|
995
|
+
finalizationRestorer(liveStateFile, priorState);
|
|
996
|
+
}
|
|
997
|
+
catch (rollbackError) {
|
|
998
|
+
const detail = redactSecrets(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
|
|
999
|
+
throw new DeploymentFinalizationError(`Deployment persistence failed and prior files could not be restored${detail ? `: ${detail}` : "."} ` +
|
|
1000
|
+
"The candidate container will be removed; restore deploy.env and deploy-state.json from backup before retrying.", false);
|
|
1001
|
+
}
|
|
1002
|
+
const detail = redactSecrets(error instanceof Error ? error.message : String(error));
|
|
1003
|
+
throw new DeploymentFinalizationError(`Could not finalize deployment persistence${detail ? `: ${detail}` : "."} ` +
|
|
1004
|
+
"Prior deploy.env and deploy-state.json content was restored.", true);
|
|
1005
|
+
}
|
|
1006
|
+
finally {
|
|
1007
|
+
try {
|
|
1008
|
+
fs.rmSync(stagedStateDir, { recursive: true, force: true });
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// Non-secret staging residue must not override persistence/rollback truth.
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
562
1015
|
/**
|
|
563
1016
|
* Ready the deploy dir at `tag`:
|
|
564
1017
|
* - absent → `git clone <repo> <dir>` then checkout the ref;
|
|
@@ -583,24 +1036,14 @@ async function prepareDeployDir(dir, tag) {
|
|
|
583
1036
|
}
|
|
584
1037
|
// It's a git repo — confirm it's OUR clone before touching it.
|
|
585
1038
|
const originResult = await run("git", ["-C", dir, "remote", "get-url", "origin"]);
|
|
586
|
-
|
|
587
|
-
if (!
|
|
588
|
-
throw new UpError(`Deploy dir ${dir} is a git repo with a different remote
|
|
1039
|
+
failIfNonZero("git", ["-C", dir, "remote", "get-url", "origin"], originResult, redactGitDiagnostics);
|
|
1040
|
+
if (!isCanonicalSourceRemote(originResult.stdout)) {
|
|
1041
|
+
throw new UpError(`Deploy dir ${dir} is a git repo with a different remote. ` +
|
|
589
1042
|
"Refusing to touch a clone I didn't create — choose another path with `--dir <path>`.");
|
|
590
1043
|
}
|
|
591
1044
|
await git(["-C", dir, "fetch", "--tags", "origin"]);
|
|
592
1045
|
await checkoutRef(dir, tag);
|
|
593
1046
|
}
|
|
594
|
-
/** True iff `origin` points at the same repo as `REPO_URL` (scheme/.git tolerant). */
|
|
595
|
-
function sameRepo(origin, repo) {
|
|
596
|
-
const norm = (u) => u
|
|
597
|
-
.trim()
|
|
598
|
-
.replace(/\.git$/, "")
|
|
599
|
-
.replace(/\/$/, "")
|
|
600
|
-
.replace(/^git@github\.com:/, "https://github.com/")
|
|
601
|
-
.toLowerCase();
|
|
602
|
-
return norm(origin) === norm(repo);
|
|
603
|
-
}
|
|
604
1047
|
/**
|
|
605
1048
|
* Build the all-in-one image from the deploy dir, STREAMING docker's output live.
|
|
606
1049
|
* This is the slow step (base-image pull, deps install, embeddings-model download);
|
|
@@ -628,9 +1071,20 @@ async function build(deployDir, tag) {
|
|
|
628
1071
|
"Fix the error shown above, then re-run `librarian server up`.");
|
|
629
1072
|
}
|
|
630
1073
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1074
|
+
class CleanupError extends UpError {
|
|
1075
|
+
}
|
|
1076
|
+
class HealthRollbackCompleteError extends UpError {
|
|
1077
|
+
}
|
|
1078
|
+
/** Remove only the container created by this attempt, and verify the outcome. */
|
|
1079
|
+
async function removeAttemptContainer(candidateId) {
|
|
1080
|
+
const result = await run("docker", ["rm", "-f", candidateId]);
|
|
1081
|
+
if (result.code === 0 || verifiedNotFound(result, candidateId))
|
|
1082
|
+
return;
|
|
1083
|
+
const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
|
|
1084
|
+
throw new CleanupError(`Could not remove failed candidate container ${candidateId}` +
|
|
1085
|
+
(detail ? `: ${detail}` : ".") +
|
|
1086
|
+
` The data volume was not removed. Run \`docker rm -f ${candidateId}\` manually, ` +
|
|
1087
|
+
"then re-run `librarian server up`.");
|
|
634
1088
|
}
|
|
635
1089
|
/**
|
|
636
1090
|
* Poll `docker inspect … Health.Status` until `healthy`, bounded. On timeout or
|
|
@@ -645,6 +1099,7 @@ export async function waitForHealthy(options) {
|
|
|
645
1099
|
const attempts = options.healthAttempts ?? 60;
|
|
646
1100
|
const intervalMs = options.healthIntervalMs ?? 2000;
|
|
647
1101
|
const tail = options.logTailLines ?? 50;
|
|
1102
|
+
const identity = options.containerIdentity ?? CONTAINER_NAME;
|
|
648
1103
|
// Track whether ANY poll yielded a non-empty status. Snap docker's confinement
|
|
649
1104
|
// does not emit stdout to a non-TTY pipe, so every `docker inspect` comes back
|
|
650
1105
|
// exit-0-but-EMPTY — health can never be read, the loop times out, and `docker
|
|
@@ -657,7 +1112,7 @@ export async function waitForHealthy(options) {
|
|
|
657
1112
|
"inspect",
|
|
658
1113
|
"--format",
|
|
659
1114
|
"{{.State.Health.Status}}",
|
|
660
|
-
|
|
1115
|
+
identity,
|
|
661
1116
|
]);
|
|
662
1117
|
if (result.code !== 0)
|
|
663
1118
|
allInspectsOk = false; // a failing inspect is NOT the snap signature
|
|
@@ -677,15 +1132,15 @@ export async function waitForHealthy(options) {
|
|
|
677
1132
|
// (defense-in-depth, e.g. an older image) — none of that may reach an error
|
|
678
1133
|
// message (spec §5.6 wants logs surfaced, just not secrets). Then roll back so
|
|
679
1134
|
// no half-up container survives.
|
|
680
|
-
const logs = await run("docker", ["logs", "--tail", String(tail),
|
|
681
|
-
await
|
|
1135
|
+
const logs = await run("docker", ["logs", "--tail", String(tail), identity]);
|
|
1136
|
+
await removeAttemptContainer(identity);
|
|
682
1137
|
// Conservative snap-docker detection: every `docker inspect` SUCCEEDED (exit 0)
|
|
683
1138
|
// yet returned empty output — never a single "starting"/"healthy"/"unhealthy".
|
|
684
1139
|
// That exit-0-but-empty shape is snap's pipe confinement; a FAILING inspect
|
|
685
1140
|
// (exit ≠ 0) is a different problem and falls through to the normal error below.
|
|
686
1141
|
// The container may well be running fine — we just can't see it through snap.
|
|
687
1142
|
if (!sawAnyStatus && allInspectsOk) {
|
|
688
|
-
throw new
|
|
1143
|
+
throw new HealthRollbackCompleteError(`Could not read the container's health — every \`docker inspect\` returned empty ` +
|
|
689
1144
|
`output. That is the signature of snap docker, whose confinement does not emit ` +
|
|
690
1145
|
`stdout to a non-TTY pipe, so \`librarian server\` cannot read health or logs (the ` +
|
|
691
1146
|
`container itself may be running fine). \`librarian server\` is not supported on snap ` +
|
|
@@ -694,7 +1149,7 @@ export async function waitForHealthy(options) {
|
|
|
694
1149
|
`volume is untouched).`);
|
|
695
1150
|
}
|
|
696
1151
|
const detail = redactSecrets(logs.stdout.trim() || logs.stderr.trim());
|
|
697
|
-
throw new
|
|
1152
|
+
throw new HealthRollbackCompleteError(`The server did not become healthy in time and was rolled back ` +
|
|
698
1153
|
`(container removed; the data volume is untouched). Recent logs:\n` +
|
|
699
1154
|
(detail ? detail : "(no log output captured)") +
|
|
700
1155
|
`\n\nFix the cause above, then re-run \`librarian server up\`.`);
|
|
@@ -713,10 +1168,10 @@ export async function waitForHealthy(options) {
|
|
|
713
1168
|
* (internal listener only), so there is no admin token to mint or paste anywhere.
|
|
714
1169
|
*/
|
|
715
1170
|
async function closeTheLoop(lines, ctx) {
|
|
716
|
-
const { host, dashboardPort, agentToken, masterKey, mintedKey, options, deps } = ctx;
|
|
1171
|
+
const { host, dashboardPort, agentToken, masterKey, mintedKey, deploymentIdentity, options, deps, } = ctx;
|
|
717
1172
|
const mcpUrl = `http://${host}:${MCP_PUBLISHED_PORT}/mcp`;
|
|
718
1173
|
const dashboardUrl = `http://${host}:${dashboardPort}`;
|
|
719
|
-
lines.push(
|
|
1174
|
+
lines.push(`The Librarian server is up and healthy from ${deploymentIdentity}.`, "", ` MCP URL: ${mcpUrl}`, ` Dashboard: ${dashboardUrl}`, ` Agent token: ${agentToken}`, "");
|
|
720
1175
|
// `0.0.0.0` is a bind directive, not a connectable address — point clients at
|
|
721
1176
|
// the machine's real reachable IP rather than over-engineering auto-detection.
|
|
722
1177
|
if (host === ALL_INTERFACES) {
|
|
@@ -777,7 +1232,7 @@ function isYes(answer) {
|
|
|
777
1232
|
/** Run a `git …` command from anywhere; a non-zero exit is a teaching error. */
|
|
778
1233
|
async function git(args) {
|
|
779
1234
|
const result = await run("git", args);
|
|
780
|
-
failIfNonZero("git", args, result);
|
|
1235
|
+
failIfNonZero("git", args, result, redactGitDiagnostics);
|
|
781
1236
|
}
|
|
782
1237
|
/**
|
|
783
1238
|
* Check out `ref` in `dir` without letting a `--…`-shaped ref inject a git
|
|
@@ -797,15 +1252,10 @@ export async function checkoutRef(dir, ref) {
|
|
|
797
1252
|
"--end-of-options",
|
|
798
1253
|
`${ref}^{commit}`,
|
|
799
1254
|
]);
|
|
800
|
-
failIfNonZero("git", ["-C", dir, "rev-parse", ref], resolved);
|
|
1255
|
+
failIfNonZero("git", ["-C", dir, "rev-parse", ref], resolved, redactGitDiagnostics);
|
|
801
1256
|
await git(["-C", dir, "checkout", resolved.stdout.trim()]);
|
|
802
1257
|
}
|
|
803
|
-
|
|
804
|
-
async function dockerInDir(args, cwd) {
|
|
805
|
-
const result = await run("docker", args, { cwd });
|
|
806
|
-
failIfNonZero("docker", args, result);
|
|
807
|
-
}
|
|
808
|
-
function failIfNonZero(cmd, args, result) {
|
|
1258
|
+
function failIfNonZero(cmd, args, result, redact = redactSecrets) {
|
|
809
1259
|
if (result.code === 0)
|
|
810
1260
|
return;
|
|
811
1261
|
// Redact in case a failed docker/git step echoed a secret-shaped value. Post
|
|
@@ -813,7 +1263,7 @@ function failIfNonZero(cmd, args, result) {
|
|
|
813
1263
|
// NOT inline on argv — so an argv-echoing `build`/`run` failure no longer
|
|
814
1264
|
// carries them. We still redact defensively (e.g. a daemon that prints the
|
|
815
1265
|
// expanded env, or an older code path) so no 64-hex secret reaches the message.
|
|
816
|
-
const detail =
|
|
1266
|
+
const detail = redact(result.stderr.trim() || result.stdout.trim());
|
|
817
1267
|
throw new UpError(`\`${cmd} ${args[0]}\` failed (exit ${result.code ?? "signal"})` +
|
|
818
1268
|
(detail ? `:\n${detail}` : ".") +
|
|
819
1269
|
"\n\nResolve the error above, then re-run `librarian server up`.");
|