@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.
Files changed (49) hide show
  1. package/README.md +21 -7
  2. package/dist/runtime.d.ts.map +1 -1
  3. package/dist/runtime.js +11 -10
  4. package/dist/runtime.js.map +1 -1
  5. package/dist/server/admin.js +2 -2
  6. package/dist/server/admin.js.map +1 -1
  7. package/dist/server/autoupdate.d.ts +4 -4
  8. package/dist/server/autoupdate.d.ts.map +1 -1
  9. package/dist/server/autoupdate.js +28 -22
  10. package/dist/server/autoupdate.js.map +1 -1
  11. package/dist/server/deploy-state.d.ts +24 -5
  12. package/dist/server/deploy-state.d.ts.map +1 -1
  13. package/dist/server/deploy-state.js +78 -10
  14. package/dist/server/deploy-state.js.map +1 -1
  15. package/dist/server/deployment-image.d.ts +65 -0
  16. package/dist/server/deployment-image.d.ts.map +1 -0
  17. package/dist/server/deployment-image.js +281 -0
  18. package/dist/server/deployment-image.js.map +1 -0
  19. package/dist/server/docker.d.ts.map +1 -1
  20. package/dist/server/docker.js +34 -14
  21. package/dist/server/docker.js.map +1 -1
  22. package/dist/server/down.js +2 -2
  23. package/dist/server/down.js.map +1 -1
  24. package/dist/server/index.d.ts +2 -0
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +28 -12
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/logs.js +2 -2
  29. package/dist/server/logs.js.map +1 -1
  30. package/dist/server/preflight.d.ts +5 -3
  31. package/dist/server/preflight.d.ts.map +1 -1
  32. package/dist/server/preflight.js +11 -6
  33. package/dist/server/preflight.js.map +1 -1
  34. package/dist/server/source-repository.d.ts +6 -0
  35. package/dist/server/source-repository.d.ts.map +1 -0
  36. package/dist/server/source-repository.js +40 -0
  37. package/dist/server/source-repository.js.map +1 -0
  38. package/dist/server/status.d.ts.map +1 -1
  39. package/dist/server/status.js +26 -18
  40. package/dist/server/status.js.map +1 -1
  41. package/dist/server/up.d.ts +38 -14
  42. package/dist/server/up.d.ts.map +1 -1
  43. package/dist/server/up.js +584 -134
  44. package/dist/server/up.js.map +1 -1
  45. package/dist/server/update.d.ts +5 -17
  46. package/dist/server/update.d.ts.map +1 -1
  47. package/dist/server/update.js +737 -296
  48. package/dist/server/update.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,348 +1,789 @@
1
- // `librarian server update` — re-pin forward, rebuild, recreate, migrate.
2
- //
3
- // The tag-pinned, deploy-dir-owning successor to `pull-and-restart.sh` (the
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, writeDeployState } from "./deploy-state.js";
65
- import { run } from "./docker.js";
66
- import { preflight } from "./preflight.js";
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 { buildRunArgs, CONTAINER_NAME, dirOwner, LEGACY_DASHBOARD_PORT, mintAgentToken, readDeployEnvFile, waitForHealthy, writeDeployEnvFile, } from "./up.js";
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
- * Run `server update`. Throws `UpdateError` (teaching message) on a failure
79
- * before/around the recreate; a failed health-wait throws the (already
80
- * secret-redacted) `UpError` from `waitForHealthy` after rolling the new
81
- * container back. deploy-state is advanced ONLY after a confirmed-healthy
82
- * recreate, so a failed update never records the new ref.
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
- // 1) Preflight: docker (daemon reachable) + git, or a teaching error.
86
- await preflight(options.platform ? { platform: options.platform } : {});
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
- return await performUpdate(options, deployDir, state);
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
- /** The locked body of {@link runUpdate} — everything after preflight/state/lock. */
115
- async function performUpdate(options, deployDir, state) {
116
- // 2) Resolve the target ref (explicit `--ref` wins; else the latest tag).
117
- const targetRef = await resolveRef(options.ref);
118
- // 3) IDEMPOTENCY FIRST: already at the resolved ref AND healthy → clean no-op.
119
- if (state.ref === targetRef && (await isHealthy())) {
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
- `Already up to date (${targetRef}) — the container is healthy.`,
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
- // 4) Update, in the deploy dir: fetch tags → checkout the resolved ref.
128
- await git(["-C", deployDir, "fetch", "--tags", "origin"]);
129
- await checkoutRef(deployDir, targetRef);
130
- // 5) Build the new image from the deploy dir.
131
- await dockerInDir(["build", "-f", "docker/all-in-one.Dockerfile", "-t", `${CONTAINER_NAME}:${targetRef}`, "."], deployDir);
132
- // 6) Read the EXISTING secrets back from the running container's env BEFORE
133
- // removal (one `docker inspect`; ADR 0008 P4 put them there via
134
- // `--env-file`). PREFER reusing them:
135
- // - agent token: reuse, else mint fresh (clients re-paste).
136
- // - master key: reuse, else OMIT (server resolves /data/secret.key) —
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: existingKey ?? undefined,
160
- bootstrapClaimSecret: existingBootstrapClaimSecret ?? undefined,
161
- host: state.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
- return { output: renderSuccess(targetRef, tokenIsFresh ? agentToken : null) };
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
- // --- ref + health probes -------------------------------------------------
199
- /** Resolve the target ref: an explicit `--ref` wins; else the latest tag. */
200
- async function resolveRef(ref) {
201
- if (ref && ref.trim().length > 0)
202
- return ref.trim();
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 tag from GitHub. " +
206
- "Check your network, or pin a ref with `--ref <tag|main>`.");
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
- * True iff the container exists AND reports `healthy`. Used by the idempotency
213
- * check — already at the ref but unhealthy means we still recreate. A failing
214
- * `docker inspect` (no such container) → not healthy.
215
- */
216
- async function isHealthy() {
217
- const status = await run("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME]);
218
- if (status.code !== 0 || status.stdout.trim() !== "running")
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
- const health = await run("docker", [
221
- "inspect",
222
- "--format",
223
- "{{.State.Health.Status}}",
224
- CONTAINER_NAME,
225
- ]);
226
- return health.code === 0 && health.stdout.trim() === "healthy";
227
- }
228
- // --- secret read-back (clients keep working; secrets stay decryptable) ----
229
- /**
230
- * Read the running container's env (`docker inspect .Config.Env`) into a map,
231
- * BEFORE the container is removed, so an `update` reuses the existing secrets
232
- * rather than silently breaking clients or orphaning encrypted settings. Returns
233
- * an EMPTY map when the container is gone/unreadable (a non-zero inspect) — the
234
- * caller then mints a fresh agent token and omits the master key. Empty values
235
- * are dropped (treated as "not present"). The values are returned for reuse on
236
- * recreate — never logged.
237
- */
238
- async function readExistingContainerEnv() {
239
- const env = new Map();
240
- const result = await run("docker", [
241
- "inspect",
242
- "--format",
243
- "{{range .Config.Env}}{{println .}}{{end}}",
244
- CONTAINER_NAME,
245
- ]);
246
- if (result.code !== 0)
247
- return env;
248
- for (const line of result.stdout.split("\n")) {
249
- const trimmed = line.trim();
250
- const eq = trimmed.indexOf("=");
251
- if (eq <= 0)
252
- continue; // no `=`, or a leading `=` (no name) → skip
253
- const name = trimmed.slice(0, eq);
254
- const value = trimmed.slice(eq + 1);
255
- if (value.length > 0)
256
- env.set(name, value);
257
- }
258
- return env;
259
- }
260
- // --- thin runner wrappers (teaching errors on a non-zero exit) ----------
261
- /** Run a `git …` command; a non-zero exit is a teaching error. */
262
- async function git(args) {
263
- const result = await run("git", args);
264
- failIfNonZero("git", args, result);
265
- }
266
- /**
267
- * Check out `ref` in `dir` without letting a `--…`-shaped ref inject a git
268
- * option (S-1). `git checkout` does NOT honor `--end-of-options` — it reads the
269
- * marker itself as a pathspec (verified on git 2.43) — so we resolve the ref to
270
- * a commit SHA with `git rev-parse` (which DOES honor it, the injection guard
271
- * that works), then check out that SHA — a hex object id can't be an option.
272
- */
273
- async function checkoutRef(dir, ref) {
274
- const resolved = await run("git", [
275
- "-C",
276
- dir,
277
- "rev-parse",
278
- "--verify",
279
- "--end-of-options",
280
- `${ref}^{commit}`,
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
- failIfNonZero("git", ["-C", dir, "rev-parse", ref], resolved);
283
- await git(["-C", dir, "checkout", resolved.stdout.trim()]);
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
- /** Run a `docker …` command from the deploy dir; non-zero exit → teaching error. */
286
- async function dockerInDir(args, cwd) {
287
- const result = await run("docker", args, { cwd });
288
- failIfNonZero("docker", args, result);
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
- /** Stop the old container. A not-running/not-found container is fine (we recreate). */
291
- async function dockerStop() {
292
- const result = await run("docker", ["stop", CONTAINER_NAME]);
293
- if (result.code === 0)
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
- if (isNotFound(result.stderr))
296
- return; // nothing to stop — proceed to rm/run
297
- failIfNonZero("docker", ["stop", CONTAINER_NAME], result);
298
- }
299
- /**
300
- * Remove the old CONTAINER (NOT the volume). `docker rm <name>` — never `-v`,
301
- * never `docker volume rm`. A not-found container is fine (we recreate).
302
- */
303
- async function dockerRm() {
304
- const result = await run("docker", ["rm", CONTAINER_NAME]);
305
- if (result.code === 0)
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 (isNotFound(result.stderr))
308
- return; // already gone — proceed to run
309
- failIfNonZero("docker", ["rm", CONTAINER_NAME], result);
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
- /** Apply pending data-dir migrations inside the (now-healthy) container. */
312
- async function dockerExecMigrate() {
313
- const args = ["exec", CONTAINER_NAME, CONTAINER_NAME, "migrate-data-dir"];
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
- failIfNonZero("docker", args, result);
613
+ checkedResult("docker", args, result, redact);
316
614
  }
317
- /** True when a docker error means the container simply isn't there. */
318
- function isNotFound(stderr) {
319
- return /no such container|no such object|is not running/i.test(stderr);
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 failIfNonZero(cmd, args, result) {
322
- if (result.code === 0)
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
- // Redact in case a non-zero docker step echoed a secret-shaped line.
325
- const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
326
- throw new UpdateError(`\`${cmd} ${args[0]}\` failed (exit ${result.code ?? "signal"})` +
327
- (detail ? `:\n${detail}` : ".") +
328
- "\n\nResolve the error above, then re-run `librarian server update`.");
329
- }
330
- // --- success report ------------------------------------------------------
331
- /**
332
- * The success report. `freshToken` is non-null ONLY when we had to mint a new
333
- * agent token (the old container's token was unreadable) — then we surface it
334
- * ONCE with a clients-must-update note. When the existing token was reused,
335
- * nothing about the token is printed (clients keep working untouched).
336
- */
337
- function renderSuccess(ref, freshToken) {
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 ${ref} — the container is healthy.`,
340
- "The data volume was preserved; pending data-dir migrations were applied.",
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: the previous container's agent token could not be read back, so a " +
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