@0xmaxma/claude-gateway 1.7.9 → 1.8.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 +178 -44
- package/dist/agent/builtin-commands.js +2 -2
- package/dist/agent/builtin-commands.js.map +1 -1
- package/dist/agent/dreaming/accept.d.ts.map +1 -1
- package/dist/agent/dreaming/accept.js +4 -1
- package/dist/agent/dreaming/accept.js.map +1 -1
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +2 -0
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
- package/dist/agent/dreaming/reviewer.js +9 -2
- package/dist/agent/dreaming/reviewer.js.map +1 -1
- package/dist/agent/dreaming/staleness.d.ts +7 -1
- package/dist/agent/dreaming/staleness.d.ts.map +1 -1
- package/dist/agent/dreaming/staleness.js +17 -2
- package/dist/agent/dreaming/staleness.js.map +1 -1
- package/dist/agent/dreaming/types.d.ts +17 -1
- package/dist/agent/dreaming/types.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.d.ts +43 -0
- package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.js +130 -0
- package/dist/agent/knowledge/archive-db.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts +3 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +32 -10
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/knowledge/index.d.ts +11 -4
- package/dist/agent/knowledge/index.d.ts.map +1 -1
- package/dist/agent/knowledge/index.js +22 -1
- package/dist/agent/knowledge/index.js.map +1 -1
- package/dist/agent/knowledge/indexer.d.ts +0 -7
- package/dist/agent/knowledge/indexer.d.ts.map +1 -1
- package/dist/agent/knowledge/indexer.js +34 -7
- package/dist/agent/knowledge/indexer.js.map +1 -1
- package/dist/agent/knowledge/lifecycle.d.ts +20 -4
- package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
- package/dist/agent/knowledge/lifecycle.js +33 -4
- package/dist/agent/knowledge/lifecycle.js.map +1 -1
- package/dist/agent/knowledge/reflection.d.ts +114 -0
- package/dist/agent/knowledge/reflection.d.ts.map +1 -0
- package/dist/agent/knowledge/reflection.js +447 -0
- package/dist/agent/knowledge/reflection.js.map +1 -0
- package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
- package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-dedup.js +187 -0
- package/dist/agent/knowledge/shared-dedup.js.map +1 -0
- package/dist/agent/knowledge/shared-promote.d.ts +82 -7
- package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-promote.js +244 -11
- package/dist/agent/knowledge/shared-promote.js.map +1 -1
- package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
- package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-staleness.js +149 -0
- package/dist/agent/knowledge/shared-staleness.js.map +1 -0
- package/dist/agent/knowledge/shared-writer.d.ts +19 -0
- package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-writer.js +42 -0
- package/dist/agent/knowledge/shared-writer.js.map +1 -1
- package/dist/agent/knowledge/types.d.ts +24 -0
- package/dist/agent/knowledge/types.d.ts.map +1 -1
- package/dist/agent/model-catalog.d.ts +62 -0
- package/dist/agent/model-catalog.d.ts.map +1 -0
- package/dist/agent/model-catalog.js +264 -0
- package/dist/agent/model-catalog.js.map +1 -0
- package/dist/agent/runner.d.ts +83 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +281 -51
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/workspace-loader.d.ts +17 -2
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +17 -1
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/cron-router.d.ts +12 -8
- package/dist/api/cron-router.d.ts.map +1 -1
- package/dist/api/cron-router.js +82 -16
- package/dist/api/cron-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +16 -1
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/manifest-sources.d.ts +15 -0
- package/dist/api/manifest-sources.d.ts.map +1 -0
- package/dist/api/manifest-sources.js +27 -0
- package/dist/api/manifest-sources.js.map +1 -0
- package/dist/api/meta-router.d.ts +13 -0
- package/dist/api/meta-router.d.ts.map +1 -0
- package/dist/api/meta-router.js +26 -0
- package/dist/api/meta-router.js.map +1 -0
- package/dist/api/packages.d.ts.map +1 -1
- package/dist/api/packages.js +18 -198
- package/dist/api/packages.js.map +1 -1
- package/dist/api/route-registry.d.ts +81 -0
- package/dist/api/route-registry.d.ts.map +1 -0
- package/dist/api/route-registry.js +44 -0
- package/dist/api/route-registry.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +21 -3
- package/dist/api/router.js.map +1 -1
- package/dist/apps/compose-generator.js +7 -4
- package/dist/apps/compose-generator.js.map +1 -1
- package/dist/apps/installer.d.ts +154 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +657 -54
- package/dist/apps/installer.js.map +1 -1
- package/dist/cli/args.d.ts +25 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +55 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/colors.d.ts +37 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/colors.js +63 -0
- package/dist/cli/colors.js.map +1 -0
- package/dist/cli/command-names.d.ts +98 -0
- package/dist/cli/command-names.d.ts.map +1 -0
- package/dist/cli/command-names.js +109 -0
- package/dist/cli/command-names.js.map +1 -0
- package/dist/cli/commands/agents.d.ts +28 -0
- package/dist/cli/commands/agents.d.ts.map +1 -0
- package/dist/cli/commands/agents.js +321 -0
- package/dist/cli/commands/agents.js.map +1 -0
- package/dist/cli/commands/channels.d.ts +17 -0
- package/dist/cli/commands/channels.d.ts.map +1 -0
- package/dist/cli/commands/channels.js +101 -0
- package/dist/cli/commands/channels.js.map +1 -0
- package/dist/cli/commands/debug-bundle.d.ts +6 -0
- package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
- package/dist/cli/commands/debug-bundle.js +220 -0
- package/dist/cli/commands/debug-bundle.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +104 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gateway.d.ts +12 -0
- package/dist/cli/commands/gateway.d.ts.map +1 -0
- package/dist/cli/commands/gateway.js +145 -0
- package/dist/cli/commands/gateway.js.map +1 -0
- package/dist/cli/commands/service.d.ts +33 -0
- package/dist/cli/commands/service.d.ts.map +1 -0
- package/dist/cli/commands/service.js +513 -0
- package/dist/cli/commands/service.js.map +1 -0
- package/dist/cli/commands/update.d.ts +16 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +149 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/commands.generated.d.ts +4 -0
- package/dist/cli/commands.generated.d.ts.map +1 -0
- package/dist/cli/commands.generated.js +220 -0
- package/dist/cli/commands.generated.js.map +1 -0
- package/dist/cli/health.d.ts +31 -0
- package/dist/cli/health.d.ts.map +1 -0
- package/dist/cli/health.js +49 -0
- package/dist/cli/health.js.map +1 -0
- package/dist/cli/http-client.d.ts +140 -0
- package/dist/cli/http-client.d.ts.map +1 -0
- package/dist/cli/http-client.js +332 -0
- package/dist/cli/http-client.js.map +1 -0
- package/dist/cli/index.d.ts +27 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +372 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/manager.d.ts +64 -0
- package/dist/cli/manager.d.ts.map +1 -0
- package/dist/cli/manager.js +181 -0
- package/dist/cli/manager.js.map +1 -0
- package/dist/cli/output.d.ts +49 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +101 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/prompt.d.ts +29 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +154 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/redact.d.ts +3 -0
- package/dist/cli/redact.d.ts.map +1 -0
- package/dist/cli/redact.js +33 -0
- package/dist/cli/redact.js.map +1 -0
- package/dist/cli/types.d.ts +22 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +4 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config/bootstrap.d.ts +33 -0
- package/dist/config/bootstrap.d.ts.map +1 -0
- package/dist/config/bootstrap.js +117 -0
- package/dist/config/bootstrap.js.map +1 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +5 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +2 -35
- package/dist/config/watcher.js.map +1 -1
- package/dist/discord/receiver.d.ts +6 -1
- package/dist/discord/receiver.d.ts.map +1 -1
- package/dist/discord/receiver.js +9 -1
- package/dist/discord/receiver.js.map +1 -1
- package/dist/entry.d.ts +3 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +83 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.js +197 -47
- package/dist/index.js.map +1 -1
- package/dist/load-dotenv.d.ts +2 -0
- package/dist/load-dotenv.d.ts.map +1 -0
- package/dist/load-dotenv.js +68 -0
- package/dist/load-dotenv.js.map +1 -0
- package/dist/packages/registry.d.ts +50 -0
- package/dist/packages/registry.d.ts.map +1 -0
- package/dist/packages/registry.js +209 -0
- package/dist/packages/registry.js.map +1 -0
- package/dist/session/process.d.ts +20 -3
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +120 -5
- package/dist/session/process.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +146 -27
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/draft-phantom.d.ts +49 -0
- package/dist/shell/draft-phantom.d.ts.map +1 -0
- package/dist/shell/draft-phantom.js +59 -0
- package/dist/shell/draft-phantom.js.map +1 -0
- package/dist/shell/submit-diag.d.ts +67 -0
- package/dist/shell/submit-diag.d.ts.map +1 -0
- package/dist/shell/submit-diag.js +69 -0
- package/dist/shell/submit-diag.js.map +1 -0
- package/dist/shutdown-signals.d.ts +47 -0
- package/dist/shutdown-signals.d.ts.map +1 -0
- package/dist/shutdown-signals.js +62 -0
- package/dist/shutdown-signals.js.map +1 -0
- package/dist/telegram/receiver.d.ts +6 -1
- package/dist/telegram/receiver.d.ts.map +1 -1
- package/dist/telegram/receiver.js +9 -1
- package/dist/telegram/receiver.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/orphan-receivers.d.ts +90 -0
- package/dist/utils/orphan-receivers.d.ts.map +1 -0
- package/dist/utils/orphan-receivers.js +218 -0
- package/dist/utils/orphan-receivers.js.map +1 -0
- package/dist/utils/paths.d.ts +16 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +60 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/stop-child.d.ts +21 -0
- package/dist/utils/stop-child.d.ts.map +1 -0
- package/dist/utils/stop-child.js +64 -0
- package/dist/utils/stop-child.js.map +1 -0
- package/lib/pairing.ts +1 -1
- package/mcp/tools/agent/handlers.ts +1 -1
- package/mcp/tools/discord/module.ts +23 -15
- package/mcp/tools/discord/receiver-server.ts +4 -0
- package/mcp/tools/memory/archive-reader.test.ts +25 -1
- package/mcp/tools/memory/archive-reader.ts +70 -12
- package/mcp/tools/memory/archive-writer.test.ts +168 -0
- package/mcp/tools/memory/archive-writer.ts +137 -0
- package/mcp/tools/memory/module.test.ts +187 -0
- package/mcp/tools/memory/module.ts +192 -6
- package/mcp/tools/telegram/module.ts +10 -2
- package/mcp/tools/telegram/receiver-server.ts +15 -6
- package/mcp/tools/telegram/typing.ts +178 -34
- package/package.json +6 -4
package/dist/apps/installer.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AppInstaller = void 0;
|
|
37
|
+
exports.isPermissionError = isPermissionError;
|
|
38
|
+
exports.commonAncestorDir = commonAncestorDir;
|
|
37
39
|
exports.parseComposePs = parseComposePs;
|
|
38
40
|
exports.mapContainerStatesToAppStatus = mapContainerStatesToAppStatus;
|
|
39
41
|
const fs = __importStar(require("node:fs"));
|
|
@@ -65,6 +67,10 @@ const VOLUME_TAR_TIMEOUT_MS = 300000;
|
|
|
65
67
|
// OCI image used for the throwaway tar helper. Small, ubiquitous, already a
|
|
66
68
|
// transitive dependency of most stacks, so it is almost always cache-warm.
|
|
67
69
|
const BACKUP_HELPER_IMAGE = 'alpine';
|
|
70
|
+
// Ceiling for the root helper that moves a bind path the gateway user cannot
|
|
71
|
+
// rename itself. The move is a single rename(2) — instant — so this only bounds
|
|
72
|
+
// a stuck container (or a cold pull of the helper image) during an update swap.
|
|
73
|
+
const BIND_MOVE_TIMEOUT_MS = 120000;
|
|
68
74
|
// Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
|
|
69
75
|
// the background (non-blocking), so this only bounds how long a hung container
|
|
70
76
|
// keeps its child process alive — not the gateway's responsiveness. Shorter than
|
|
@@ -96,6 +102,90 @@ function isValidTimezone(tz) {
|
|
|
96
102
|
// Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
|
|
97
103
|
const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
|
|
98
104
|
// ─── Installer ────────────────────────────────────────────────────────────────
|
|
105
|
+
const UUID_RE_SRC = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
106
|
+
/**
|
|
107
|
+
* The staging checkout `runUpdate` creates beside an app's install path,
|
|
108
|
+
* `.cg-update-<app>-<uuid>`. It holds a fresh clone and nothing else, so a
|
|
109
|
+
* crash leftover is pure garbage and safe to reclaim. The UUID means the
|
|
110
|
+
* pattern cannot match an app directory named by a user.
|
|
111
|
+
*/
|
|
112
|
+
const STALE_UPDATE_DIR_RE = new RegExp(`^\\.cg-update-.+-${UUID_RE_SRC}$`);
|
|
113
|
+
/**
|
|
114
|
+
* The release snapshots `runUpdate` renames an install path to during the swap:
|
|
115
|
+
* `<appDir>-old-<uuid>` (previous release) and `<appDir>-failed-<uuid>` (the
|
|
116
|
+
* release that failed). Deliberately **not** swept.
|
|
117
|
+
*
|
|
118
|
+
* Both can hold the only copy of live bind-mount data. `-failed-` is kept on
|
|
119
|
+
* purpose when a rollback cannot move a bind path back, and a crash between the
|
|
120
|
+
* swap's two renames leaves an `-old-` still holding the paths that had not
|
|
121
|
+
* moved yet. Sweeping them would delete a database — through {@link rmrf}'s
|
|
122
|
+
* `sudo rm -rf` fallback, which exists precisely to defeat the container
|
|
123
|
+
* ownership that made the data unmovable in the first place. They are reported
|
|
124
|
+
* at boot instead, so a leak is visible rather than silent.
|
|
125
|
+
*/
|
|
126
|
+
const RELEASE_SNAPSHOT_DIR_RE = new RegExp(`-(?:old|failed)-${UUID_RE_SRC}$`);
|
|
127
|
+
/**
|
|
128
|
+
* A filesystem call that failed because the gateway user lacks the rights, not
|
|
129
|
+
* because the path is wrong. Containers running as their image's uid leave
|
|
130
|
+
* files and directories the gateway user can neither delete nor rename, and
|
|
131
|
+
* those are the two codes the kernel reports for it.
|
|
132
|
+
*/
|
|
133
|
+
function isPermissionError(err) {
|
|
134
|
+
const code = err?.code;
|
|
135
|
+
return code === 'EACCES' || code === 'EPERM';
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Deepest directory containing both paths, never either path itself.
|
|
139
|
+
*
|
|
140
|
+
* Used to pick a **single** mount for the root helper that moves a bind path:
|
|
141
|
+
* mounting each parent separately would put the two paths on different mount
|
|
142
|
+
* points, where `rename(2)` fails EXDEV and `mv` degrades to a copy.
|
|
143
|
+
*
|
|
144
|
+
* The comparison stops one component short of the shorter path, so the result
|
|
145
|
+
* is always a strict ancestor of both — for siblings (what the callers here
|
|
146
|
+
* pass) that is exact. When one path contains the other it is the *shallower*
|
|
147
|
+
* path's parent, i.e. broader than strictly needed; never narrower, which would
|
|
148
|
+
* put a path outside the mount.
|
|
149
|
+
*/
|
|
150
|
+
function commonAncestorDir(a, b) {
|
|
151
|
+
const left = path.resolve(a).split(path.sep);
|
|
152
|
+
const right = path.resolve(b).split(path.sep);
|
|
153
|
+
const shared = [];
|
|
154
|
+
for (let i = 0; i < Math.min(left.length, right.length) - 1; i++) {
|
|
155
|
+
if (left[i] !== right[i])
|
|
156
|
+
break;
|
|
157
|
+
shared.push(left[i]);
|
|
158
|
+
}
|
|
159
|
+
return shared.join(path.sep) || path.sep;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Rows from a `docker compose … --format json` call. Compose has emitted both a
|
|
163
|
+
* single JSON array and one object per line across its 2.x line, so accept
|
|
164
|
+
* either rather than silently reading a newer/older daemon as "nothing here".
|
|
165
|
+
*/
|
|
166
|
+
function parseJsonRows(stdout) {
|
|
167
|
+
const text = stdout.trim();
|
|
168
|
+
if (!text)
|
|
169
|
+
return [];
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(text);
|
|
172
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
173
|
+
}
|
|
174
|
+
catch { /* not a single document — try line-delimited below */ }
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const line of text.split('\n')) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
rows.push(JSON.parse(trimmed));
|
|
182
|
+
}
|
|
183
|
+
catch { /* skip a line that is not JSON */ }
|
|
184
|
+
}
|
|
185
|
+
return rows;
|
|
186
|
+
}
|
|
187
|
+
/** Private tag the update holds on an image so a rollback can put it back. */
|
|
188
|
+
const ROLLBACK_TAG_PREFIX = 'cg-rollback-';
|
|
99
189
|
class AppInstaller {
|
|
100
190
|
constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
|
|
101
191
|
this.registry = registry;
|
|
@@ -720,23 +810,29 @@ class AppInstaller {
|
|
|
720
810
|
* read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
|
|
721
811
|
* returns `[]` on any failure, mirroring {@link discoverVolumes}.
|
|
722
812
|
*/
|
|
723
|
-
|
|
813
|
+
/**
|
|
814
|
+
* The paths a generated compose file may have resolved this app dir to.
|
|
815
|
+
* Local-dev installs symlink the app dir into appsDir, and the generated
|
|
816
|
+
* compose resolves bind sources against the symlink's *realpath*, so both the
|
|
817
|
+
* symlink path and its target must be treated as the app root.
|
|
818
|
+
*/
|
|
819
|
+
bindMountBases(appDir) {
|
|
820
|
+
const bases = [appDir];
|
|
821
|
+
try {
|
|
822
|
+
const real = fs.realpathSync(appDir);
|
|
823
|
+
if (real !== appDir)
|
|
824
|
+
bases.push(real);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
/* app dir unreadable — fall back to the literal path */
|
|
828
|
+
}
|
|
829
|
+
return bases;
|
|
830
|
+
}
|
|
831
|
+
discoverBindMounts(appName, appDir, onError = 'empty') {
|
|
832
|
+
const bases = this.bindMountBases(appDir);
|
|
724
833
|
try {
|
|
725
834
|
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
|
|
726
835
|
const parsed = JSON.parse(stdout);
|
|
727
|
-
// Local-dev installs symlink the app dir into appsDir, and the generated
|
|
728
|
-
// compose resolves bind sources against the symlink's *realpath*. Match a
|
|
729
|
-
// source that sits under either the symlink path or its target, so those
|
|
730
|
-
// bind mounts are not wrongly excluded.
|
|
731
|
-
const bases = [appDir];
|
|
732
|
-
try {
|
|
733
|
-
const real = fs.realpathSync(appDir);
|
|
734
|
-
if (real !== appDir)
|
|
735
|
-
bases.push(real);
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
/* app dir unreadable — fall back to the literal path */
|
|
739
|
-
}
|
|
740
836
|
const rels = new Set();
|
|
741
837
|
for (const svc of Object.values(parsed.services ?? {})) {
|
|
742
838
|
for (const vol of svc.volumes ?? []) {
|
|
@@ -753,8 +849,18 @@ class AppInstaller {
|
|
|
753
849
|
}
|
|
754
850
|
return Array.from(rels).sort();
|
|
755
851
|
}
|
|
756
|
-
catch {
|
|
757
|
-
|
|
852
|
+
catch (err) {
|
|
853
|
+
if (onError === 'empty')
|
|
854
|
+
return [];
|
|
855
|
+
// Fail closed: the caller is about to move this app's directory, so an
|
|
856
|
+
// unknown bind set would silently strand live state. Only an app whose
|
|
857
|
+
// stored compose anchors nothing to the app dir (under either base) is
|
|
858
|
+
// safe to treat as bind-free.
|
|
859
|
+
const composePath = path.join(appDir, 'docker-compose.yml');
|
|
860
|
+
const compose = fs.existsSync(composePath) ? fs.readFileSync(composePath, 'utf-8') : '';
|
|
861
|
+
if (!bases.some((b) => compose.includes(b)))
|
|
862
|
+
return [];
|
|
863
|
+
throw new Error(`Cannot safely discover bind mounts for update: ${err.message}`);
|
|
758
864
|
}
|
|
759
865
|
}
|
|
760
866
|
/**
|
|
@@ -823,6 +929,60 @@ class AppInstaller {
|
|
|
823
929
|
* Returns a cancel function. No-op (returns a noop canceller) when both caps
|
|
824
930
|
* are disabled. The timer is `unref`'d so it never holds the event loop open.
|
|
825
931
|
*/
|
|
932
|
+
/**
|
|
933
|
+
* Reclaim the `.cg-update-*` staging checkout a crashed or killed update left
|
|
934
|
+
* behind. Staging moved next to the install path so the swap is a
|
|
935
|
+
* same-filesystem rename, which also means `/tmp` cleanup no longer collects
|
|
936
|
+
* it — without this sweep a mid-update crash leaks a full app checkout
|
|
937
|
+
* forever.
|
|
938
|
+
*
|
|
939
|
+
* Release snapshots (`-old-`/`-failed-`) are **reported, never removed**: see
|
|
940
|
+
* {@link RELEASE_SNAPSHOT_DIR_RE}. They can hold the only copy of a live bind
|
|
941
|
+
* mount, and this sweep deletes with root.
|
|
942
|
+
*
|
|
943
|
+
* **Boot only.** An update in flight owns directories matching these names,
|
|
944
|
+
* so this must run before any update can start. Never throws; a directory it
|
|
945
|
+
* cannot remove is reported and skipped.
|
|
946
|
+
*/
|
|
947
|
+
async sweepStaleUpdateDirs() {
|
|
948
|
+
const swept = [];
|
|
949
|
+
let names;
|
|
950
|
+
try {
|
|
951
|
+
names = fs.readdirSync(this.appsDir);
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
return swept; // no apps dir yet
|
|
955
|
+
}
|
|
956
|
+
// An installPath is authoritative — never remove a directory an app still
|
|
957
|
+
// points at, however its name happens to look.
|
|
958
|
+
let live;
|
|
959
|
+
try {
|
|
960
|
+
live = new Set((await this.registry.list()).map((a) => a.installPath));
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
return swept; // registry unreadable — do not guess
|
|
964
|
+
}
|
|
965
|
+
for (const name of names) {
|
|
966
|
+
const full = path.join(this.appsDir, name);
|
|
967
|
+
if (live.has(full))
|
|
968
|
+
continue;
|
|
969
|
+
if (RELEASE_SNAPSHOT_DIR_RE.test(name)) {
|
|
970
|
+
console.warn(`[installer] keeping app release snapshot "${full}" — it may hold the only copy of a `
|
|
971
|
+
+ 'bind mount an update could not move back. Recover or delete it by hand.');
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (!STALE_UPDATE_DIR_RE.test(name))
|
|
975
|
+
continue;
|
|
976
|
+
try {
|
|
977
|
+
this.rmrf(full);
|
|
978
|
+
swept.push(full);
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
console.warn(`[installer] failed to sweep stale update dir "${full}": ${err.message}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return swept;
|
|
985
|
+
}
|
|
826
986
|
startBackupCleanup() {
|
|
827
987
|
const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
|
|
828
988
|
if (retention <= 0 && maxAgeDays <= 0)
|
|
@@ -1357,7 +1517,12 @@ class AppInstaller {
|
|
|
1357
1517
|
this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
1358
1518
|
}
|
|
1359
1519
|
}
|
|
1360
|
-
|
|
1520
|
+
// Stage beside the durable install path so the directory swap and bind-data
|
|
1521
|
+
// moves are same-filesystem renames, not cross-device copies/failures.
|
|
1522
|
+
const tmpDir = path.join(path.dirname(entry.installPath), `.cg-update-${appName}-${crypto.randomUUID()}`);
|
|
1523
|
+
// Rollback tags held on the images this update is about to build over.
|
|
1524
|
+
// Declared out here so every exit path can drop them again.
|
|
1525
|
+
let preservedImages = [];
|
|
1361
1526
|
try {
|
|
1362
1527
|
// ── Shallow fetch of specific commit into tmp dir ─────────────────────
|
|
1363
1528
|
this.log(job, `Cloning ${target.repo}`);
|
|
@@ -1392,12 +1557,20 @@ class AppInstaller {
|
|
|
1392
1557
|
version: newVersion,
|
|
1393
1558
|
commit: target.newCommit,
|
|
1394
1559
|
installPath: tmpDir,
|
|
1395
|
-
|
|
1560
|
+
agentDeclaration: generated.agentDeclaration,
|
|
1396
1561
|
...(agentPaths ? { agentPaths } : {}),
|
|
1397
1562
|
};
|
|
1398
1563
|
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
1399
1564
|
this.agentManager.injectAgentService(newEntry);
|
|
1400
1565
|
}
|
|
1566
|
+
// Pin the images the app is running before the build takes their tags
|
|
1567
|
+
// over. A `build:` service's new image reuses the old tag
|
|
1568
|
+
// (`<project>-<service>`), so once the build succeeds that tag names the
|
|
1569
|
+
// new release: rolling only the source back would bring the app up on the
|
|
1570
|
+
// failed release's image and crash-loop on a "successful" rollback. This
|
|
1571
|
+
// has to happen before the build — afterwards the old image has no
|
|
1572
|
+
// reference left to grab it by.
|
|
1573
|
+
preservedImages = this.preserveRunningImages(appName, entry.installPath, job);
|
|
1401
1574
|
// ── Build new images in tmp dir ───────────────────────────────────────
|
|
1402
1575
|
this.log(job, 'Building new images');
|
|
1403
1576
|
this.run(['docker', 'compose', '-p', appName, 'build'], tmpDir, 600000);
|
|
@@ -1409,30 +1582,99 @@ class AppInstaller {
|
|
|
1409
1582
|
this.log(job, 'MEMORY.md backed up');
|
|
1410
1583
|
}
|
|
1411
1584
|
}
|
|
1585
|
+
// Discover the app-owned bind paths while the old compose file still points
|
|
1586
|
+
// at the durable install directory. They must move with the release swap so
|
|
1587
|
+
// cleanup cannot delete live state. This query fails closed, so it runs
|
|
1588
|
+
// *before* routes and sockets come down — a throw here would otherwise
|
|
1589
|
+
// leave the app running but unreachable, with no path back.
|
|
1590
|
+
const oldBindMounts = this.discoverBindMounts(appName, entry.installPath, 'throw');
|
|
1412
1591
|
// ── Deregister old routes before taking down containers ───────────────
|
|
1413
1592
|
this.callbacks.deregisterRoutes(appName);
|
|
1414
1593
|
this.callbacks.stopSockets(appName);
|
|
1415
|
-
// Capture the current (old) image IDs while the old
|
|
1416
|
-
//
|
|
1417
|
-
// `compose down` that would collide with the new stack (issue #283).
|
|
1594
|
+
// Capture the current (old) image IDs while the old compose file is still
|
|
1595
|
+
// the live one, so the reclaim below targets exactly this app's images.
|
|
1418
1596
|
const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
|
|
1419
|
-
// Also capture the app's declared image refs (repo:tag) so a superseded
|
|
1420
|
-
// *pulled* tag can be reclaimed after the update (issue #302).
|
|
1421
1597
|
const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
|
|
1422
|
-
// ──
|
|
1598
|
+
// ── Swap dirs before starting the new containers ───────────────────────
|
|
1599
|
+
// The new compose file's bind sources are anchored to finalDir. Starting
|
|
1600
|
+
// before this swap would bind the old directory inode then delete it.
|
|
1423
1601
|
this.log(job, 'Stopping old containers');
|
|
1424
1602
|
this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
|
|
1425
|
-
|
|
1426
|
-
|
|
1603
|
+
this.log(job, 'Swapping app directories');
|
|
1604
|
+
const finalDir = entry.installPath;
|
|
1605
|
+
const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
|
|
1606
|
+
let swapped = false;
|
|
1607
|
+
let reachedContainerStart = false;
|
|
1608
|
+
let failedDir = null;
|
|
1609
|
+
const movedBindMounts = [];
|
|
1427
1610
|
try {
|
|
1428
|
-
|
|
1611
|
+
fs.renameSync(finalDir, oldBackupDir);
|
|
1612
|
+
fs.renameSync(tmpDir, finalDir);
|
|
1613
|
+
swapped = true;
|
|
1614
|
+
this.moveBindMounts(oldBackupDir, finalDir, oldBindMounts, job, movedBindMounts);
|
|
1615
|
+
// The source checkout is now permanent. Rewrite the compose file with
|
|
1616
|
+
// finalDir as its base so every bind source and build context resolves
|
|
1617
|
+
// to the durable path — this call is kept for that side effect; its
|
|
1618
|
+
// return value necessarily matches `generated` (same app.yaml). The
|
|
1619
|
+
// rewrite drops the injected agent service, so re-inject it after.
|
|
1620
|
+
const finalComposePath = path.join(finalDir, 'docker-compose.yml');
|
|
1621
|
+
const finalYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(path.join(finalDir, 'app.yaml'), 'utf-8'), finalDir);
|
|
1622
|
+
(0, compose_generator_1.generateCompose)(finalYaml, appName, finalDir, finalComposePath);
|
|
1623
|
+
if (generated.agentDeclaration && this.agentManager && agentPaths) {
|
|
1624
|
+
this.agentManager.injectAgentService({ ...newEntry, installPath: finalDir });
|
|
1625
|
+
}
|
|
1626
|
+
// ── Start new containers ────────────────────────────────────────────
|
|
1627
|
+
this.log(job, 'Starting new containers');
|
|
1628
|
+
reachedContainerStart = true;
|
|
1629
|
+
this.composeUp(appName, finalDir, job);
|
|
1429
1630
|
}
|
|
1430
1631
|
catch (upErr) {
|
|
1431
|
-
//
|
|
1432
|
-
|
|
1632
|
+
// The catch spans the whole swap, so a failure here is not necessarily a
|
|
1633
|
+
// container failure — saying it is sends diagnosis to the wrong
|
|
1634
|
+
// subsystem (a bind-mount move that threw looked exactly like a crashed
|
|
1635
|
+
// container in the job log).
|
|
1636
|
+
this.log(job, reachedContainerStart
|
|
1637
|
+
? 'New containers failed — rolling back to previous version'
|
|
1638
|
+
: `Update failed during the directory swap — rolling back to previous version: ${upErr.message}`);
|
|
1433
1639
|
let rollbackFailed = false;
|
|
1640
|
+
failedDir = `${finalDir}-failed-${crypto.randomUUID()}`;
|
|
1434
1641
|
try {
|
|
1435
|
-
|
|
1642
|
+
let unrestored = [];
|
|
1643
|
+
if (swapped) {
|
|
1644
|
+
fs.renameSync(finalDir, failedDir);
|
|
1645
|
+
fs.renameSync(oldBackupDir, finalDir);
|
|
1646
|
+
unrestored = this.restoreBindMounts(failedDir, finalDir, movedBindMounts, job);
|
|
1647
|
+
}
|
|
1648
|
+
else if (fs.existsSync(oldBackupDir)) {
|
|
1649
|
+
fs.renameSync(oldBackupDir, finalDir);
|
|
1650
|
+
}
|
|
1651
|
+
// Point every tag back at the image the app was actually running
|
|
1652
|
+
// before this update built over it, so the restored source and the
|
|
1653
|
+
// restored image are the same release. Falls back to rebuilding from
|
|
1654
|
+
// the restored source when an old image is no longer on disk.
|
|
1655
|
+
//
|
|
1656
|
+
// This has to happen even when the bind restore below refuses to
|
|
1657
|
+
// start the app: the source is already back on the previous release,
|
|
1658
|
+
// so `<app>-<service>:latest` must name that release's build before
|
|
1659
|
+
// *anything* starts it. Leaving it on the failed release's build
|
|
1660
|
+
// means an operator who finishes the recovery by hand brings old
|
|
1661
|
+
// source up on a new image — the crash-loop `preserveRunningImages`
|
|
1662
|
+
// exists to prevent.
|
|
1663
|
+
const rebuild = !this.restorePreservedImages(preservedImages, finalDir, job);
|
|
1664
|
+
if (unrestored.length > 0) {
|
|
1665
|
+
// Throwing here is deliberate: it skips both the container start
|
|
1666
|
+
// below and the safeRmrf of failedDir, so the only copy of that
|
|
1667
|
+
// data stays on disk. Starting the app on a half-restored
|
|
1668
|
+
// directory is worse than not starting it — postgres on an empty
|
|
1669
|
+
// pgdata initialises a fresh cluster and then looks healthy.
|
|
1670
|
+
throw new Error(`live bind-mount data for ${unrestored.map((r) => `"${r}"`).join(', ')} could not be moved back `
|
|
1671
|
+
+ `— it is still in "${failedDir}", which has been kept. Move those paths back into `
|
|
1672
|
+
+ `"${finalDir}" before starting the app.`);
|
|
1673
|
+
}
|
|
1674
|
+
const upArgs = ['docker', 'compose', '-p', appName, 'up', '-d'];
|
|
1675
|
+
if (rebuild)
|
|
1676
|
+
upArgs.push('--build');
|
|
1677
|
+
this.run(upArgs, finalDir, rebuild ? 600000 : 120000);
|
|
1436
1678
|
this.callbacks.registerRoutes(appName, entry.ports.map((p) => ({
|
|
1437
1679
|
name: p.name,
|
|
1438
1680
|
service: p.service,
|
|
@@ -1442,38 +1684,41 @@ class AppInstaller {
|
|
|
1442
1684
|
rateLimit: p.rateLimit,
|
|
1443
1685
|
})));
|
|
1444
1686
|
await this.registry.updateStatus(appName, 'running');
|
|
1687
|
+
if (failedDir !== null)
|
|
1688
|
+
this.safeRmrf(failedDir, job, 'failed update dir');
|
|
1445
1689
|
}
|
|
1446
1690
|
catch (rollbackErr) {
|
|
1447
1691
|
rollbackFailed = true;
|
|
1448
1692
|
this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
|
|
1449
1693
|
}
|
|
1450
|
-
this.safeRmrf(tmpDir, job, 'update temp dir');
|
|
1451
1694
|
if (rollbackFailed) {
|
|
1695
|
+
// Keep the private `cg-rollback-*` tags: they are the only remaining
|
|
1696
|
+
// reference to the pre-update build, and the outer catch drops them.
|
|
1697
|
+
// Clearing the list here is what stops that — an incomplete rollback
|
|
1698
|
+
// is finished by hand, and that recovery needs the old image to still
|
|
1699
|
+
// exist. Untagged, containerd is free to reclaim it.
|
|
1700
|
+
if (preservedImages.some((i) => i.backupRef)) {
|
|
1701
|
+
this.log(job, `Pre-update images kept for manual recovery: ${preservedImages.filter((i) => i.backupRef).map((i) => `"${i.backupRef}" (for "${i.ref}")`).join(', ')}`);
|
|
1702
|
+
}
|
|
1703
|
+
preservedImages = [];
|
|
1704
|
+
// The success path above sets 'running'. Leaving the registry on the
|
|
1705
|
+
// pre-update 'running' here would report a healthy app that is not
|
|
1706
|
+
// running at all — and this branch now includes the case where the
|
|
1707
|
+
// app was deliberately not restarted.
|
|
1708
|
+
await this.registry.updateStatus(appName, 'error').catch(() => { });
|
|
1452
1709
|
throw new Error(`Update failed and rollback also failed — app "${appName}" may be in a broken state. Check job logs for details.`);
|
|
1453
1710
|
}
|
|
1454
1711
|
throw upErr;
|
|
1455
1712
|
}
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
// the app name here throws ENOENT (issue #275). `entry.installPath` is the
|
|
1466
|
-
// authoritative location the `down`/rollback steps above already use.
|
|
1467
|
-
this.log(job, 'Swapping app directories');
|
|
1468
|
-
const finalDir = entry.installPath;
|
|
1469
|
-
const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
|
|
1470
|
-
fs.renameSync(finalDir, oldBackupDir);
|
|
1471
|
-
fs.renameSync(tmpDir, finalDir);
|
|
1472
|
-
// ── Restore MEMORY.md ─────────────────────────────────────────────────
|
|
1473
|
-
if (memoryBackup !== null && generated.agentDeclaration && this.agentManager) {
|
|
1474
|
-
this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
|
|
1475
|
-
this.log(job, 'MEMORY.md restored');
|
|
1476
|
-
}
|
|
1713
|
+
// The new stack is up: the previous images are no longer a rollback
|
|
1714
|
+
// target. Untag them before the reclaim below, which removes by image ID
|
|
1715
|
+
// and would be refused while a second reference exists.
|
|
1716
|
+
this.dropPreservedImageTags(preservedImages);
|
|
1717
|
+
preservedImages = [];
|
|
1718
|
+
// Capture the new stack's image IDs from its permanent compose file so
|
|
1719
|
+
// cleanup below never removes an image the new containers depend on.
|
|
1720
|
+
const newImageIds = this.captureComposeImageIds(appName, finalDir);
|
|
1721
|
+
const newImageRefs = this.captureComposeImageRefs(appName, finalDir);
|
|
1477
1722
|
// ── Update registry ───────────────────────────────────────────────────
|
|
1478
1723
|
const finalEntry = {
|
|
1479
1724
|
...newEntry,
|
|
@@ -1482,10 +1727,28 @@ class AppInstaller {
|
|
|
1482
1727
|
status: 'running',
|
|
1483
1728
|
};
|
|
1484
1729
|
await this.registry.upsert(finalEntry);
|
|
1485
|
-
// ── Re-create
|
|
1730
|
+
// ── Re-create, rename, or remove the app-agent registration ───────────
|
|
1731
|
+
// `upsertAgent` keys off the *new* agent name, so a release that drops or
|
|
1732
|
+
// renames its agent would otherwise strand the previous workspace symlink
|
|
1733
|
+
// and config.json entry. Both transitions are the same deregistration.
|
|
1734
|
+
const oldAgentName = entry.agentDeclaration?.name ?? null;
|
|
1735
|
+
const newAgentName = generated.agentDeclaration?.name ?? null;
|
|
1736
|
+
if (this.agentManager && oldAgentName !== null && oldAgentName !== newAgentName) {
|
|
1737
|
+
await this.agentManager.deleteAgentByName(oldAgentName);
|
|
1738
|
+
this.log(job, newAgentName === null
|
|
1739
|
+
? `Agent "${oldAgentName}" removed`
|
|
1740
|
+
: `Agent "${oldAgentName}" deregistered (renamed to "${newAgentName}")`);
|
|
1741
|
+
}
|
|
1486
1742
|
if (generated.agentDeclaration && this.agentManager) {
|
|
1487
1743
|
await this.agentManager.upsertAgent(finalEntry);
|
|
1488
1744
|
this.log(job, `Agent "${generated.agentDeclaration.name}" re-registered`);
|
|
1745
|
+
// MEMORY.md must be written *after* registration: restoreMemory resolves
|
|
1746
|
+
// the workspace through config.json, so on a rename the new name is not
|
|
1747
|
+
// resolvable until upsertAgent has written its entry.
|
|
1748
|
+
if (memoryBackup !== null) {
|
|
1749
|
+
this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
|
|
1750
|
+
this.log(job, 'MEMORY.md restored');
|
|
1751
|
+
}
|
|
1489
1752
|
await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
|
|
1490
1753
|
}
|
|
1491
1754
|
// ── Re-register proxy routes + sockets ───────────────────────────────
|
|
@@ -1534,6 +1797,7 @@ class AppInstaller {
|
|
|
1534
1797
|
this.log(job, `Update complete → ${newVersion}`);
|
|
1535
1798
|
}
|
|
1536
1799
|
catch (err) {
|
|
1800
|
+
this.dropPreservedImageTags(preservedImages);
|
|
1537
1801
|
if (fs.existsSync(tmpDir)) {
|
|
1538
1802
|
this.safeRmrf(tmpDir, job, 'update temp dir');
|
|
1539
1803
|
}
|
|
@@ -1950,7 +2214,7 @@ class AppInstaller {
|
|
|
1950
2214
|
const code = err.code;
|
|
1951
2215
|
// Containers running as root leave root-owned files (e.g. postgres pgdata)
|
|
1952
2216
|
// that the gateway user cannot delete — fs.rmSync surfaces EACCES or EPERM.
|
|
1953
|
-
if (
|
|
2217
|
+
if (isPermissionError(err)) {
|
|
1954
2218
|
console.warn(`[installer] ${code} removing "${dirPath}" — falling back to sudo rm -rf`);
|
|
1955
2219
|
this.run(['sudo', 'rm', '-rf', dirPath]);
|
|
1956
2220
|
}
|
|
@@ -1975,6 +2239,247 @@ class AppInstaller {
|
|
|
1975
2239
|
}
|
|
1976
2240
|
}
|
|
1977
2241
|
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Create `targetDir` under `baseDir`, refusing to traverse or create through a
|
|
2244
|
+
* symlink. The check runs **before** each segment is created — a `mkdir -p`
|
|
2245
|
+
* that ran first would already have materialised directories on the far side
|
|
2246
|
+
* of a symlink, leaving the guard with nothing left to prevent.
|
|
2247
|
+
*
|
|
2248
|
+
* Scope: this guards the **destination** side — the freshly checked-out
|
|
2249
|
+
* release, which is the side an app repo controls. A bind path that was
|
|
2250
|
+
* already a symlink in the *previous* app dir is carried across as-is; that
|
|
2251
|
+
* preserves an escape the operator set up themselves rather than creating
|
|
2252
|
+
* one, and is the behaviour every release before this one had.
|
|
2253
|
+
*/
|
|
2254
|
+
ensureDirWithinNoSymlink(baseDir, targetDir) {
|
|
2255
|
+
const relative = path.relative(baseDir, targetDir);
|
|
2256
|
+
if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) {
|
|
2257
|
+
throw new Error(`Bind-mount path escapes the app directory: "${targetDir}"`);
|
|
2258
|
+
}
|
|
2259
|
+
let current = baseDir;
|
|
2260
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
2261
|
+
current = path.join(current, segment);
|
|
2262
|
+
let stat;
|
|
2263
|
+
try {
|
|
2264
|
+
stat = fs.lstatSync(current);
|
|
2265
|
+
}
|
|
2266
|
+
catch {
|
|
2267
|
+
stat = null;
|
|
2268
|
+
}
|
|
2269
|
+
if (stat === null) {
|
|
2270
|
+
fs.mkdirSync(current);
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
if (stat.isSymbolicLink()) {
|
|
2274
|
+
throw new Error(`Updated app bind-mount source must not be a symlink: "${current}"`);
|
|
2275
|
+
}
|
|
2276
|
+
if (!stat.isDirectory()) {
|
|
2277
|
+
throw new Error(`Updated app bind-mount path is not a directory: "${current}"`);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
/** Reject a discovered bind path that does not stay inside both app roots. */
|
|
2282
|
+
isSafeBindRel(fromDir, toDir, rel) {
|
|
2283
|
+
if (rel.length === 0 || path.isAbsolute(rel))
|
|
2284
|
+
return false;
|
|
2285
|
+
const fromBase = fromDir.endsWith(path.sep) ? fromDir : fromDir + path.sep;
|
|
2286
|
+
const toBase = toDir.endsWith(path.sep) ? toDir : toDir + path.sep;
|
|
2287
|
+
return path.resolve(fromDir, rel).startsWith(fromBase)
|
|
2288
|
+
&& path.resolve(toDir, rel).startsWith(toBase);
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Move app-owned relative bind data across an update directory swap.
|
|
2292
|
+
* Renaming preserves the database's ownership and inode, unlike copying.
|
|
2293
|
+
*
|
|
2294
|
+
* A release legitimately ships content at a bind path (a `.gitkeep`, seed
|
|
2295
|
+
* files, a tracked `init.sql`), so a collision is normal, not an error. Live
|
|
2296
|
+
* state always wins — it is the data the issue exists to protect — but a
|
|
2297
|
+
* directory collision is **merged** entry by entry so release-provided files
|
|
2298
|
+
* the previous version never had still land. Every rename performed is
|
|
2299
|
+
* recorded, app-relative and in order, so a rollback can replay it backwards.
|
|
2300
|
+
*/
|
|
2301
|
+
moveBindMounts(fromDir, toDir, rels, job, moved) {
|
|
2302
|
+
for (const rel of rels) {
|
|
2303
|
+
if (!this.isSafeBindRel(fromDir, toDir, rel)) {
|
|
2304
|
+
this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
if (!fs.existsSync(path.resolve(fromDir, rel)))
|
|
2308
|
+
continue; // never created
|
|
2309
|
+
this.moveBindEntry(fromDir, toDir, rel, job, moved);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
moveBindEntry(fromDir, toDir, rel, job, moved) {
|
|
2313
|
+
const source = path.resolve(fromDir, rel);
|
|
2314
|
+
const destination = path.resolve(toDir, rel);
|
|
2315
|
+
this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
|
|
2316
|
+
let destStat;
|
|
2317
|
+
try {
|
|
2318
|
+
destStat = fs.lstatSync(destination);
|
|
2319
|
+
}
|
|
2320
|
+
catch {
|
|
2321
|
+
destStat = null;
|
|
2322
|
+
}
|
|
2323
|
+
if (destStat === null) {
|
|
2324
|
+
this.moveBindPath(source, destination, rel, job);
|
|
2325
|
+
moved?.push(rel);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
if (destStat.isDirectory() && fs.lstatSync(source).isDirectory()) {
|
|
2329
|
+
// Merge: recurse so a release-only file inside the directory survives.
|
|
2330
|
+
//
|
|
2331
|
+
// Entry-by-entry on purpose. Swapping the trees (move the live dir over
|
|
2332
|
+
// wholesale, then re-apply the release's own files on top) would be one
|
|
2333
|
+
// rename instead of one per live file, but `moved` would no longer be an
|
|
2334
|
+
// exact inverse: a rollback would carry those re-applied release files
|
|
2335
|
+
// into the restored previous app dir. Exact rollback beats the renames,
|
|
2336
|
+
// which are same-filesystem and only walk a directory the release also
|
|
2337
|
+
// ships content in.
|
|
2338
|
+
let entries;
|
|
2339
|
+
try {
|
|
2340
|
+
entries = fs.readdirSync(source);
|
|
2341
|
+
}
|
|
2342
|
+
catch (err) {
|
|
2343
|
+
// The live directory is one the app's container owns and the gateway
|
|
2344
|
+
// user cannot even list (postgres leaves pgdata 0700). There is no way
|
|
2345
|
+
// to merge into it entry by entry, so preserve it wholesale — the same
|
|
2346
|
+
// trade the non-directory collision below already makes.
|
|
2347
|
+
if (!isPermissionError(err))
|
|
2348
|
+
throw err;
|
|
2349
|
+
entries = null;
|
|
2350
|
+
}
|
|
2351
|
+
if (entries !== null) {
|
|
2352
|
+
// Each child that needs the root helper costs its own container. That
|
|
2353
|
+
// is bounded by the live directory's own entry count, and only reached
|
|
2354
|
+
// when a release ships tracked content *inside* a path whose children
|
|
2355
|
+
// the gateway user cannot rename — rare enough not to trade the exact
|
|
2356
|
+
// rollback above for one wholesale move.
|
|
2357
|
+
for (const name of entries) {
|
|
2358
|
+
this.moveBindEntry(fromDir, toDir, `${rel}/${name}`, job, moved);
|
|
2359
|
+
}
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
this.log(job, `Warning: preserved existing bind-mount data at "${rel}" — the updated release's copy of that path `
|
|
2364
|
+
+ `was discarded (${this.describeDiscarded(destination)})`);
|
|
2365
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
2366
|
+
this.moveBindPath(source, destination, rel, job);
|
|
2367
|
+
moved?.push(rel);
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Name what a preserve-the-live-copy collision is about to delete.
|
|
2371
|
+
*
|
|
2372
|
+
* The discarded side is the release's own checkout, so it is always readable
|
|
2373
|
+
* by the gateway user even when the live side is not. Without this the log
|
|
2374
|
+
* says only that "the release's copy was discarded" — which reads as a
|
|
2375
|
+
* `.gitkeep` and hides the case that matters: a release shipping a new
|
|
2376
|
+
* `init.sql` or entrypoint script *inside* a path whose live copy wins, where
|
|
2377
|
+
* the file is dropped on every update and nothing ever says which.
|
|
2378
|
+
*/
|
|
2379
|
+
describeDiscarded(destination, limit = 10) {
|
|
2380
|
+
let names;
|
|
2381
|
+
try {
|
|
2382
|
+
names = fs.statSync(destination).isDirectory()
|
|
2383
|
+
? fs.readdirSync(destination).sort()
|
|
2384
|
+
: [path.basename(destination)];
|
|
2385
|
+
}
|
|
2386
|
+
catch {
|
|
2387
|
+
return 'contents unreadable';
|
|
2388
|
+
}
|
|
2389
|
+
if (names.length === 0)
|
|
2390
|
+
return 'it was empty';
|
|
2391
|
+
const shown = names.slice(0, limit).map((n) => `"${n}"`).join(', ');
|
|
2392
|
+
return names.length > limit
|
|
2393
|
+
? `${shown} and ${names.length - limit} more`
|
|
2394
|
+
: shown;
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Undo {@link moveBindMounts}. Returns the paths it could **not** move back:
|
|
2398
|
+
* those still live only under `fromDir`, so the caller must keep that
|
|
2399
|
+
* directory rather than clean it up.
|
|
2400
|
+
*
|
|
2401
|
+
* A path that is missing at the source, or already present at the
|
|
2402
|
+
* destination, is not a failure — it needs no move and is not reported.
|
|
2403
|
+
*/
|
|
2404
|
+
restoreBindMounts(fromDir, toDir, moved, job) {
|
|
2405
|
+
const unrestored = [];
|
|
2406
|
+
for (const rel of [...moved].reverse()) {
|
|
2407
|
+
if (!this.isSafeBindRel(fromDir, toDir, rel)) {
|
|
2408
|
+
this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
|
|
2409
|
+
unrestored.push(rel);
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
const source = path.resolve(fromDir, rel);
|
|
2413
|
+
const destination = path.resolve(toDir, rel);
|
|
2414
|
+
if (!fs.existsSync(source) || fs.existsSync(destination))
|
|
2415
|
+
continue;
|
|
2416
|
+
try {
|
|
2417
|
+
this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
|
|
2418
|
+
this.moveBindPath(source, destination, rel, job);
|
|
2419
|
+
}
|
|
2420
|
+
catch (err) {
|
|
2421
|
+
this.log(job, `Warning: could not restore bind-mount path "${rel}": ${err.message}`);
|
|
2422
|
+
unrestored.push(rel);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
return unrestored;
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Move one bind-mount path between app directories.
|
|
2429
|
+
*
|
|
2430
|
+
* `rename(2)` that moves a **directory** to a different parent needs write
|
|
2431
|
+
* permission on the directory itself — the kernel has to update its `..`
|
|
2432
|
+
* entry — not just on the two parents, which is exactly what the swap does. A bind mount the app's own container created is owned by that
|
|
2433
|
+
* image's uid (postgres initdb leaves its data directory mode 0700), so the
|
|
2434
|
+
* gateway user cannot rename it and the update swap failed with EACCES on
|
|
2435
|
+
* every attempt.
|
|
2436
|
+
*
|
|
2437
|
+
* The same host-vs-container ownership split is already handled elsewhere in
|
|
2438
|
+
* this file — backups tar through a root helper container, {@link rmrf} falls
|
|
2439
|
+
* back to `sudo rm -rf`. The swap was the one path still doing it bare, so
|
|
2440
|
+
* the fallback here matches the backup mechanism: a throwaway root container.
|
|
2441
|
+
*
|
|
2442
|
+
* The helper mounts the **nearest common ancestor** of the two paths rather
|
|
2443
|
+
* than each parent separately, and that is load-bearing: two bind mounts are
|
|
2444
|
+
* two mount points, so `rename(2)` across them fails EXDEV and busybox `mv`
|
|
2445
|
+
* silently degrades to copy-then-unlink. Measured by inode, two mounts give a
|
|
2446
|
+
* new inode (a copy) where one mount preserves it (a real rename). For a
|
|
2447
|
+
* database directory the degraded path means duplicating it on disk and
|
|
2448
|
+
* losing the atomicity the swap depends on.
|
|
2449
|
+
*
|
|
2450
|
+
* The single mount is therefore the apps root, not one app's directory — the
|
|
2451
|
+
* two app directories are siblings under it. The helper is a throwaway
|
|
2452
|
+
* container whose only command is the `mv`, and it is reached only after the
|
|
2453
|
+
* gateway user's own rename was refused.
|
|
2454
|
+
*/
|
|
2455
|
+
moveBindPath(source, destination, rel, job) {
|
|
2456
|
+
try {
|
|
2457
|
+
fs.renameSync(source, destination);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
if (!isPermissionError(err))
|
|
2462
|
+
throw err;
|
|
2463
|
+
}
|
|
2464
|
+
const base = commonAncestorDir(source, destination);
|
|
2465
|
+
const from = path.relative(base, source);
|
|
2466
|
+
const to = path.relative(base, destination);
|
|
2467
|
+
if (base === path.parse(base).root || base.includes(':') || from === '' || to === ''
|
|
2468
|
+
|| from.split(path.sep).includes('..') || to.split(path.sep).includes('..')) {
|
|
2469
|
+
// A colon would be read as the mount separator by `docker -v`, silently
|
|
2470
|
+
// mounting something other than what was asked for.
|
|
2471
|
+
throw new Error(`Cannot move bind-mount path "${rel}" as root: unsafe mount base "${base}"`);
|
|
2472
|
+
}
|
|
2473
|
+
this.log(job, `Bind-mount path "${rel}" is not writable by the gateway user (created by the app's container) `
|
|
2474
|
+
+ '— moving it with a root helper container');
|
|
2475
|
+
this.run([
|
|
2476
|
+
'docker', 'run', '--rm',
|
|
2477
|
+
'-v', `${base}:/mnt`,
|
|
2478
|
+
BACKUP_HELPER_IMAGE,
|
|
2479
|
+
'mv', path.posix.join('/mnt', from.split(path.sep).join('/')),
|
|
2480
|
+
path.posix.join('/mnt', to.split(path.sep).join('/')),
|
|
2481
|
+
], undefined, BIND_MOVE_TIMEOUT_MS);
|
|
2482
|
+
}
|
|
1978
2483
|
/**
|
|
1979
2484
|
* Stop conflicting containers then run `docker compose up -d --wait`.
|
|
1980
2485
|
* Captures container logs into the job on failure before rethrowing.
|
|
@@ -2076,6 +2581,104 @@ class AppInstaller {
|
|
|
2076
2581
|
* container back down (issue #283). Returns a de-duplicated list of image
|
|
2077
2582
|
* IDs, or `[]` on any error (nothing to reclaim / docker unavailable).
|
|
2078
2583
|
*/
|
|
2584
|
+
/**
|
|
2585
|
+
* Tag every image the app is *currently* running under a private
|
|
2586
|
+
* `<repo>:cg-rollback-<id>` name, and return the pairs.
|
|
2587
|
+
*
|
|
2588
|
+
* A `build:` service's new image reuses the tag of the one in production
|
|
2589
|
+
* (`<project>-<service>:latest`), so once the update's build succeeds that
|
|
2590
|
+
* tag names the new release. Rolling only the source directory back would
|
|
2591
|
+
* bring the app up on the failed release's image. Holding a second tag keeps
|
|
2592
|
+
* the old image addressable *and* alive — under the containerd image store an
|
|
2593
|
+
* untagged image is not kept around as a `<none>` image to find later.
|
|
2594
|
+
*
|
|
2595
|
+
* Only images this app builds are preserved: compose names them
|
|
2596
|
+
* `<project>-<service>`, and a pulled tag (`postgres:16-alpine`) is never
|
|
2597
|
+
* overwritten by a build, so it needs no protection. Best-effort throughout —
|
|
2598
|
+
* anything that cannot be preserved is simply absent from the result, and the
|
|
2599
|
+
* rollback rebuilds from the restored source instead.
|
|
2600
|
+
*/
|
|
2601
|
+
preserveRunningImages(appName, dir, job) {
|
|
2602
|
+
let rows;
|
|
2603
|
+
try {
|
|
2604
|
+
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--format', 'json'], dir, 15000);
|
|
2605
|
+
rows = parseJsonRows(stdout);
|
|
2606
|
+
}
|
|
2607
|
+
catch {
|
|
2608
|
+
return [];
|
|
2609
|
+
}
|
|
2610
|
+
const preserved = [];
|
|
2611
|
+
const seen = new Set();
|
|
2612
|
+
for (const row of rows) {
|
|
2613
|
+
if (typeof row !== 'object' || row === null)
|
|
2614
|
+
continue;
|
|
2615
|
+
const r = row;
|
|
2616
|
+
const id = typeof r.ID === 'string' ? r.ID.trim() : '';
|
|
2617
|
+
const repo = typeof r.Repository === 'string' ? r.Repository.trim() : '';
|
|
2618
|
+
const tag = typeof r.Tag === 'string' && r.Tag.trim() ? r.Tag.trim() : 'latest';
|
|
2619
|
+
if (!id || !repo.startsWith(`${appName}-`))
|
|
2620
|
+
continue;
|
|
2621
|
+
const ref = `${repo}:${tag}`;
|
|
2622
|
+
if (seen.has(ref))
|
|
2623
|
+
continue;
|
|
2624
|
+
seen.add(ref);
|
|
2625
|
+
const backupRef = `${repo}:${ROLLBACK_TAG_PREFIX}${id.replace(/^sha256:/, '').slice(0, 12)}`;
|
|
2626
|
+
try {
|
|
2627
|
+
this.run(['docker', 'image', 'tag', id, backupRef], dir, 15000);
|
|
2628
|
+
preserved.push({ ref, backupRef });
|
|
2629
|
+
}
|
|
2630
|
+
catch (err) {
|
|
2631
|
+
// Recorded with no backup reference: the rollback must know this tag is
|
|
2632
|
+
// unprotected and rebuild, rather than read an empty list as "no built
|
|
2633
|
+
// images, nothing at risk".
|
|
2634
|
+
preserved.push({ ref, backupRef: '' });
|
|
2635
|
+
this.log(job, `Warning: could not preserve image "${ref}" for rollback (${err instanceof Error ? err.message : String(err)})`);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
return preserved;
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Put each preserved image back on the reference the update's build took
|
|
2642
|
+
* over. Returns true when nothing is at risk (an empty list — the app builds
|
|
2643
|
+
* no images) or every reference is back on its original image; false when at
|
|
2644
|
+
* least one could not be, so the caller rebuilds from the rolled-back source
|
|
2645
|
+
* rather than starting the failed release's build.
|
|
2646
|
+
*/
|
|
2647
|
+
restorePreservedImages(images, dir, job) {
|
|
2648
|
+
let allRestored = true;
|
|
2649
|
+
for (const { ref, backupRef } of images) {
|
|
2650
|
+
if (!backupRef) {
|
|
2651
|
+
allRestored = false;
|
|
2652
|
+
this.log(job, `Image "${ref}" was not preserved — rebuilding from the rolled-back source`);
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
try {
|
|
2656
|
+
this.run(['docker', 'image', 'tag', backupRef, ref], dir, 15000);
|
|
2657
|
+
this.log(job, `Restored image "${ref}" to the pre-update build`);
|
|
2658
|
+
}
|
|
2659
|
+
catch (err) {
|
|
2660
|
+
allRestored = false;
|
|
2661
|
+
this.log(job, `Warning: could not restore image "${ref}" (${err instanceof Error ? err.message : String(err)}) — rebuilding from the rolled-back source instead`);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
return allRestored;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Drop the private rollback tags. Removing a reference only untags the image
|
|
2668
|
+
* while another tag remains, so this never deletes an image the app still
|
|
2669
|
+
* uses. Must run before the post-update image reclaim: an extra tag would
|
|
2670
|
+
* make `docker image rm <id>` refuse.
|
|
2671
|
+
*/
|
|
2672
|
+
dropPreservedImageTags(images) {
|
|
2673
|
+
for (const { backupRef } of images) {
|
|
2674
|
+
if (!backupRef)
|
|
2675
|
+
continue;
|
|
2676
|
+
try {
|
|
2677
|
+
this.run(['docker', 'image', 'rm', backupRef], os.tmpdir(), 15000);
|
|
2678
|
+
}
|
|
2679
|
+
catch { /* already gone — non-fatal */ }
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2079
2682
|
captureComposeImageIds(appName, dir) {
|
|
2080
2683
|
try {
|
|
2081
2684
|
const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--quiet'], dir, 15000);
|