@podosoft/podokit 0.16.4 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/deploy-compose-exec.d.ts +93 -0
  2. package/dist/deploy-compose-exec.js +198 -0
  3. package/dist/deploy-compose-profile.d.ts +107 -0
  4. package/dist/deploy-compose-profile.js +361 -0
  5. package/dist/deploy-compose-render.d.ts +48 -0
  6. package/dist/deploy-compose-render.js +333 -0
  7. package/dist/deploy-compose-sync.d.ts +144 -0
  8. package/dist/deploy-compose-sync.js +412 -0
  9. package/dist/deploy-compose.d.ts +98 -0
  10. package/dist/deploy-compose.js +667 -0
  11. package/dist/deploy-dispatch.d.ts +33 -0
  12. package/dist/deploy-dispatch.js +58 -0
  13. package/dist/deploy-driver.d.ts +25 -0
  14. package/dist/deploy-driver.js +78 -0
  15. package/dist/deploy-profile.d.ts +3 -10
  16. package/dist/deploy-profile.js +82 -291
  17. package/dist/deploy-schema.d.ts +56 -0
  18. package/dist/deploy-schema.js +261 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +178 -6
  21. package/dist/lib.d.ts +9 -0
  22. package/dist/lib.js +30 -1
  23. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  24. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
  25. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
  26. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
  27. package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  38. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  39. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
  40. package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
  41. package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
  42. package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
  43. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
  44. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
  45. package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
  55. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  60. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
  61. package/dist/templates/todo/AGENTS.md +2 -1
  62. package/dist/templates/todo/Dockerfile.dev +34 -3
  63. package/dist/templates/todo/apps/api/Dockerfile +68 -7
  64. package/dist/templates/todo/apps/web/Dockerfile +60 -8
  65. package/dist/templates/todo/apps/web/server.js +31 -0
  66. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
  67. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
  68. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
  69. package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
  70. package/dist/templates/todo/apps/web/vite.config.ts +17 -7
  71. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
  72. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
  73. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
  74. package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
  75. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
  76. package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
  77. package/dist/templates/todo/dot-dockerignore +7 -0
  78. package/dist/templates/todo/dot-env.example +7 -0
  79. package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
  80. package/dist/templates/todo/infra/k3s/README.md +18 -0
  81. package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
  82. package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
  83. package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
  84. package/package.json +1 -1
@@ -0,0 +1,667 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectComposeEndpointFingerprint = inspectComposeEndpointFingerprint;
4
+ exports.doctorComposeDeployment = doctorComposeDeployment;
5
+ exports.planComposeDeployment = planComposeDeployment;
6
+ exports.applyComposeDeployment = applyComposeDeployment;
7
+ exports.getComposeStatus = getComposeStatus;
8
+ exports.verifyComposeDeployment = verifyComposeDeployment;
9
+ exports.planComposeRollback = planComposeRollback;
10
+ exports.rollbackComposeDeployment = rollbackComposeDeployment;
11
+ exports.composeRuntimeRoot = composeRuntimeRoot;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const node_path_1 = require("node:path");
14
+ const deploy_driver_1 = require("./deploy-driver");
15
+ const deploy_compose_exec_1 = require("./deploy-compose-exec");
16
+ const deploy_compose_sync_1 = require("./deploy-compose-sync");
17
+ const deploy_compose_profile_1 = require("./deploy-compose-profile");
18
+ const deploy_compose_render_1 = require("./deploy-compose-render");
19
+ const deploy_driver_2 = require("./deploy-driver");
20
+ /** How many rollback targets the ledger keeps. */
21
+ const LEDGER_DEPTH = 10;
22
+ function digest(parts) {
23
+ const hash = (0, node_crypto_1.createHash)("sha256");
24
+ for (const part of parts)
25
+ hash.update(part).update("\0");
26
+ return `sha256:${hash.digest("hex")}`;
27
+ }
28
+ /**
29
+ * The endpoint identity a profile is pinned to.
30
+ *
31
+ * The Kubernetes driver fingerprints the cluster server and CA; the closest stable
32
+ * equivalent here is the context's Docker endpoint plus the daemon's own ID, so
33
+ * repointing a context at a different host invalidates the profile instead of
34
+ * silently deploying somewhere else.
35
+ */
36
+ function inspectComposeEndpointFingerprint(context, runner = deploy_compose_exec_1.defaultRunner) {
37
+ const contextOutput = (0, deploy_compose_exec_1.checked)(runner, "docker", [
38
+ "context",
39
+ "inspect",
40
+ context,
41
+ "--format",
42
+ "{{json .}}",
43
+ ]);
44
+ const parsed = (0, deploy_compose_exec_1.parseJson)(contextOutput, `Docker context inspection for ${context}`);
45
+ const record = Array.isArray(parsed) ? parsed[0] : parsed;
46
+ const host = record?.Endpoints?.docker?.Host;
47
+ if (!host)
48
+ throw new Error(`Docker context "${context}" does not declare an endpoint host.`);
49
+ const daemonId = (0, deploy_compose_exec_1.checked)(runner, "docker", [
50
+ "--context",
51
+ context,
52
+ "info",
53
+ "--format",
54
+ "{{.ID}}",
55
+ ]).trim();
56
+ if (!daemonId)
57
+ throw new Error(`Docker context "${context}" did not report a daemon ID.`);
58
+ return digest(["docker-endpoint", host, daemonId]);
59
+ }
60
+ /**
61
+ * Reads key NAMES from an env file on the target host.
62
+ *
63
+ * The file never leaves the host and no value is captured: a throwaway container
64
+ * mounts it read-only and prints the left-hand sides only. This mirrors the
65
+ * Kubernetes doctor asking `kubectl` for key names rather than Secret contents.
66
+ */
67
+ function readEnvFileKeys(profile, path, image, runner) {
68
+ const script = `sed -n 's/^[[:space:]]*\\([A-Z][A-Z0-9_]*\\)=.*/\\1/p' /podokit-env | sort -u`;
69
+ // Read it as the identity that will actually deploy.
70
+ //
71
+ // A container running as root proves only that root can read the file, and
72
+ // `docker compose` on a remote target runs as the SSH user. Checking with the
73
+ // wrong identity is how a doctor passes and the apply then dies on
74
+ // "permission denied" -- which is exactly what happened.
75
+ const destination = (0, deploy_compose_exec_1.sshDestination)(profile, runner);
76
+ if (destination) {
77
+ const remote = (0, deploy_compose_exec_1.checked)(runner, "ssh", [
78
+ destination,
79
+ `sed -n 's/^[[:space:]]*\\([A-Z][A-Z0-9_]*\\)=.*/\\1/p' ${path} | sort -u`,
80
+ ]);
81
+ return remote
82
+ .split("\n")
83
+ .map((line) => line.trim())
84
+ .filter(Boolean);
85
+ }
86
+ const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
87
+ "run",
88
+ "--rm",
89
+ "--network",
90
+ "none",
91
+ // The env file is root-owned and 0600 on purpose, and the application images
92
+ // run as a non-root user. Without this the probe reads nothing and reports
93
+ // every key as missing.
94
+ "--user",
95
+ "0:0",
96
+ "-v",
97
+ `${path}:/podokit-env:ro`,
98
+ "--entrypoint",
99
+ "sh",
100
+ image,
101
+ "-c",
102
+ script,
103
+ ]));
104
+ return output
105
+ .split("\n")
106
+ .map((line) => line.trim())
107
+ .filter(Boolean);
108
+ }
109
+ function writeLedger(profile, image, ledger, runner) {
110
+ const encoded = Buffer.from(JSON.stringify(ledger)).toString("base64");
111
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.stateScript)(profile, image, `echo ${encoded} | base64 -d > ${deploy_compose_exec_1.LEDGER_PATH}.tmp && mv ${deploy_compose_exec_1.LEDGER_PATH}.tmp ${deploy_compose_exec_1.LEDGER_PATH}`));
112
+ }
113
+ /**
114
+ * A fail-closed lock. `set -C` makes the redirect fail if the file exists, which is
115
+ * the atomic create-if-absent this needs; a crashed apply deliberately leaves the
116
+ * lock behind so the interrupted deployment is inspected before anything else runs.
117
+ */
118
+ function acquireComposeLock(profile, image, holder, runner) {
119
+ const result = runner("docker", (0, deploy_compose_exec_1.stateScript)(profile, image, `set -C; echo ${JSON.stringify(holder)} > ${deploy_compose_exec_1.LOCK_PATH}`), { capture: true });
120
+ if (result.status !== 0) {
121
+ const current = runner("docker", (0, deploy_compose_exec_1.stateScript)(profile, image, `cat ${deploy_compose_exec_1.LOCK_PATH} 2>/dev/null || echo unknown`), { capture: true });
122
+ throw new Error(`Deployment lock for ${profile.target.project} is held by ${current.stdout.trim() || "another operation"}. ` +
123
+ "Inspect the interrupted deployment before removing it.");
124
+ }
125
+ }
126
+ function releaseComposeLock(profile, image, runner) {
127
+ runner("docker", (0, deploy_compose_exec_1.stateScript)(profile, image, `rm -f ${deploy_compose_exec_1.LOCK_PATH}`), { capture: true });
128
+ }
129
+ function resolveImage(reference, runner) {
130
+ if (/@sha256:[a-f0-9]{64}$/.test(reference))
131
+ return reference;
132
+ const output = (0, deploy_compose_exec_1.checked)(runner, "docker", [
133
+ "buildx",
134
+ "imagetools",
135
+ "inspect",
136
+ reference,
137
+ "--format",
138
+ "{{json .Manifest}}",
139
+ ]);
140
+ const manifest = (0, deploy_compose_exec_1.parseJson)(output, `Image manifest inspection for ${reference}`);
141
+ if (!manifest.digest || !/^sha256:[a-f0-9]{64}$/.test(manifest.digest)) {
142
+ throw new Error(`Unable to resolve immutable digest for image ${reference}.`);
143
+ }
144
+ return `${reference}@${manifest.digest}`;
145
+ }
146
+ function resolveComposeImages(profile, release, runner) {
147
+ const defaults = (0, deploy_compose_render_1.defaultComposeImages)(profile, release);
148
+ return {
149
+ api: resolveImage(defaults.api, runner),
150
+ web: resolveImage(defaults.web, runner),
151
+ postgres: defaults.postgres ? resolveImage(defaults.postgres, runner) : null,
152
+ redis: defaults.redis ? resolveImage(defaults.redis, runner) : null,
153
+ objectStorage: defaults.objectStorage ? resolveImage(defaults.objectStorage, runner) : null,
154
+ objectStorageClient: defaults.objectStorageClient
155
+ ? resolveImage(defaults.objectStorageClient, runner)
156
+ : null,
157
+ };
158
+ }
159
+ function managedDependencyServices(profile) {
160
+ return [
161
+ ...(profile.dependencies.postgres.mode === "managed" ? [(0, deploy_compose_render_1.serviceName)(profile, "postgres")] : []),
162
+ ...(profile.dependencies.redis.mode === "managed" ? [(0, deploy_compose_render_1.serviceName)(profile, "redis")] : []),
163
+ ...(profile.dependencies.objectStorage.mode === "managed"
164
+ ? [(0, deploy_compose_render_1.serviceName)(profile, "object-storage")]
165
+ : []),
166
+ ];
167
+ }
168
+ function doctorComposeDeployment(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner, release) {
169
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
170
+ const findings = [];
171
+ const record = (code, ok, message) => {
172
+ findings.push({ code, ok, message });
173
+ };
174
+ try {
175
+ (0, deploy_compose_exec_1.checked)(runner, "docker", ["compose", "version", "--short"]);
176
+ record("compose-cli", true, "Docker Compose v2 is available.");
177
+ }
178
+ catch (error) {
179
+ record("compose-cli", false, `Docker Compose v2 is required: ${error.message}`);
180
+ return findings;
181
+ }
182
+ try {
183
+ (0, deploy_compose_exec_1.checked)(runner, "docker", ["buildx", "version"]);
184
+ record("buildx", true, "Docker Buildx is available for digest resolution.");
185
+ }
186
+ catch (error) {
187
+ record("buildx", false, `Docker Buildx is required: ${error.message}`);
188
+ }
189
+ let endpointOk = false;
190
+ try {
191
+ const fingerprint = inspectComposeEndpointFingerprint(profile.target.context, runner);
192
+ endpointOk = fingerprint === profile.target.endpointFingerprint;
193
+ record("endpoint-fingerprint", endpointOk, endpointOk
194
+ ? `Docker context ${profile.target.context} matches the recorded endpoint fingerprint.`
195
+ : `Docker context ${profile.target.context} does not match the recorded endpoint fingerprint.`);
196
+ }
197
+ catch (error) {
198
+ record("endpoint-fingerprint", false, `Cannot inspect the Docker context: ${error.message}`);
199
+ }
200
+ if (!endpointOk)
201
+ return findings;
202
+ const probe = (0, deploy_compose_exec_1.probeImage)(profile, release);
203
+ if (!probe) {
204
+ record("env-files", false, "Cannot inspect the env files: no managed dependency image to borrow a shell from. Re-run with a release.");
205
+ return findings;
206
+ }
207
+ const envFiles = [
208
+ { label: "api", path: profile.secrets.api.path, requiredKeys: profile.secrets.api.requiredKeys },
209
+ ...(profile.secrets.web
210
+ ? [{ label: "web", path: profile.secrets.web.path, requiredKeys: profile.secrets.web.requiredKeys }]
211
+ : []),
212
+ ...(profile.dependencies.postgres.mode === "managed"
213
+ ? [
214
+ {
215
+ label: "postgres",
216
+ path: profile.dependencies.postgres.envFile,
217
+ requiredKeys: profile.dependencies.postgres.requiredKeys,
218
+ },
219
+ ]
220
+ : []),
221
+ ...(profile.dependencies.redis.mode === "managed"
222
+ ? [
223
+ {
224
+ label: "redis",
225
+ path: profile.dependencies.redis.envFile,
226
+ requiredKeys: profile.dependencies.redis.requiredKeys,
227
+ },
228
+ ]
229
+ : []),
230
+ ...(profile.dependencies.objectStorage.mode === "managed"
231
+ ? [
232
+ {
233
+ label: "object-storage",
234
+ path: profile.dependencies.objectStorage.envFile,
235
+ requiredKeys: profile.dependencies.objectStorage.requiredKeys,
236
+ },
237
+ ]
238
+ : []),
239
+ ];
240
+ // The API env file must also carry the connection credentials for every managed
241
+ // dependency, because the application reads them from its own environment.
242
+ const dependencyKeys = [
243
+ ...(profile.dependencies.postgres.mode === "managed"
244
+ ? ["POSTGRES_DB", "POSTGRES_PASSWORD", "POSTGRES_USER"]
245
+ : []),
246
+ ...(profile.dependencies.redis.mode === "managed" ? ["REDIS_PASSWORD"] : []),
247
+ ...(profile.dependencies.objectStorage.mode === "managed"
248
+ ? ["S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]
249
+ : []),
250
+ ];
251
+ for (const file of envFiles) {
252
+ const required = new Set(file.label === "api" ? [...file.requiredKeys, ...dependencyKeys] : file.requiredKeys);
253
+ try {
254
+ const present = new Set(readEnvFileKeys(profile, file.path, probe, runner));
255
+ const missing = [...required].filter((key) => !present.has(key)).sort();
256
+ record(`env-file-${file.label}`, missing.length === 0, missing.length === 0
257
+ ? `${file.path} provides every required key.`
258
+ : `${file.path} is missing required key(s): ${missing.join(", ")}.`);
259
+ }
260
+ catch (error) {
261
+ record("env-file-" + file.label, false, `Cannot read ${file.path}: ${error.message}`);
262
+ }
263
+ }
264
+ if (profile.secrets.registryLogin && release) {
265
+ const result = runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["run", "--rm", "--entrypoint", "true", probe]), { capture: true });
266
+ record("registry-access", result.status === 0, result.status === 0
267
+ ? "The target daemon can pull from the release registry."
268
+ : "The target daemon cannot pull the release image; run docker login on the target host.");
269
+ }
270
+ return findings;
271
+ }
272
+ /**
273
+ * What is deployed, without what it happens to be doing right now.
274
+ *
275
+ * `compose ps` reports restart counts, uptime strings and health transitions, and
276
+ * folding those into the plan hash makes the hash change every second something is
277
+ * flapping -- so a plan can never be confirmed on a target that is unhealthy, which
278
+ * is exactly when you most need to deploy. Keep the identity: which services exist
279
+ * and which image each one runs.
280
+ */
281
+ function deployedIdentity(psOutput) {
282
+ if (!psOutput.trim())
283
+ return "[]";
284
+ const entries = psOutput
285
+ .split("\n")
286
+ .filter(Boolean)
287
+ .map((line) => {
288
+ try {
289
+ const entry = JSON.parse(line);
290
+ return { service: entry.Service ?? entry.Name ?? "", image: entry.Image ?? "" };
291
+ }
292
+ catch {
293
+ return { service: "", image: "" };
294
+ }
295
+ })
296
+ .sort((left, right) => left.service.localeCompare(right.service) || left.image.localeCompare(right.image));
297
+ return JSON.stringify(entries);
298
+ }
299
+ function hostStateDigest(profile, ledger, psOutput) {
300
+ return digest([
301
+ "compose-host-state",
302
+ profile.target.endpointFingerprint,
303
+ JSON.stringify((0, deploy_compose_exec_1.currentLedgerEntry)(ledger)),
304
+ deployedIdentity(psOutput),
305
+ ]);
306
+ }
307
+ function composePs(profile, runner) {
308
+ return (0, deploy_compose_exec_1.composeProjectPs)(profile, runner);
309
+ }
310
+ function planComposeDeployment(projectRoot, profileName, release, runner = deploy_compose_exec_1.defaultRunner) {
311
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
312
+ (0, deploy_driver_2.assertAnyReleaseTag)(profile, release);
313
+ const images = resolveComposeImages(profile, release, runner);
314
+ const ledger = (0, deploy_compose_exec_1.readLedger)(profile, images.api, runner);
315
+ const current = (0, deploy_compose_exec_1.currentLedgerEntry)(ledger);
316
+ // Secret identity is the file's content hash, computed on the host so no value is
317
+ // transferred. A changed env file therefore changes the rollout state, which makes
318
+ // Compose recreate containers that would otherwise keep stale environment values.
319
+ const secretIdentities = {};
320
+ const identityTargets = [
321
+ ["api", profile.secrets.api.path],
322
+ ...(profile.secrets.web ? [["web", profile.secrets.web.path]] : []),
323
+ ];
324
+ const identityDestination = (0, deploy_compose_exec_1.sshDestination)(profile, runner);
325
+ for (const [label, path] of identityTargets) {
326
+ if (identityDestination) {
327
+ secretIdentities[label] = (0, deploy_compose_exec_1.checked)(runner, "ssh", [
328
+ identityDestination,
329
+ `sha256sum ${path} | cut -d' ' -f1`,
330
+ ]).trim();
331
+ continue;
332
+ }
333
+ const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
334
+ "run",
335
+ "--rm",
336
+ "--network",
337
+ "none",
338
+ "--user",
339
+ "0:0",
340
+ "-v",
341
+ `${path}:/podokit-env:ro`,
342
+ "--entrypoint",
343
+ "sh",
344
+ images.api,
345
+ "-c",
346
+ "sha256sum /podokit-env | cut -d' ' -f1",
347
+ ]));
348
+ secretIdentities[label] = output.trim();
349
+ }
350
+ const stateDigest = (0, deploy_compose_render_1.rolloutStateDigest)(profile, secretIdentities);
351
+ const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, release, images, stateDigest);
352
+ const composeDocumentDigest = digest(["compose-document", runtime.composeDocument]);
353
+ const psOutput = composePs(profile, runner);
354
+ const actions = [];
355
+ const dependencies = managedDependencyServices(profile);
356
+ let order = 1;
357
+ if (dependencies.length) {
358
+ actions.push({
359
+ order: order++,
360
+ kind: "dependencies",
361
+ description: `Reconcile and wait for ${dependencies.join(", ")}.`,
362
+ });
363
+ }
364
+ actions.push({
365
+ order: order++,
366
+ kind: "migration",
367
+ description: `Run ${(profile.migration?.command ?? ["npm", "run", "migrate:all"]).join(" ")} using ${images.api}.`,
368
+ });
369
+ actions.push({
370
+ order: order++,
371
+ kind: "application",
372
+ description: `Roll out api, web${profile.workloads.worker ? ", worker" : ""} and wait for health.`,
373
+ });
374
+ actions.push({
375
+ order: order++,
376
+ kind: "verification",
377
+ description: `Verify ${profile.verification.checks.length} public check(s) against ${profile.verification.baseUrl}.`,
378
+ });
379
+ const warnings = [];
380
+ if (current?.release === release) {
381
+ warnings.push(`Release ${release} is already the current revision; apply will recreate containers whose definition changed.`);
382
+ }
383
+ warnings.push("The migration runs before the new containers start, so it must be compatible with the release currently serving traffic.");
384
+ const profileDigest = (0, deploy_compose_profile_1.composeProfileDigest)(profile);
385
+ const planHash = digest([
386
+ "compose-plan",
387
+ profileName,
388
+ release,
389
+ profileDigest,
390
+ JSON.stringify(images),
391
+ composeDocumentDigest,
392
+ stateDigest,
393
+ hostStateDigest(profile, ledger, psOutput),
394
+ ]);
395
+ return {
396
+ profile: profileName,
397
+ release,
398
+ planHash,
399
+ profileDigest,
400
+ currentRevision: current?.revision ?? null,
401
+ currentRelease: current?.release ?? null,
402
+ hostStateDigest: hostStateDigest(profile, ledger, psOutput),
403
+ rolloutStateDigest: stateDigest,
404
+ composeDocumentDigest,
405
+ target: { ...profile.target },
406
+ images,
407
+ actions,
408
+ warnings,
409
+ };
410
+ }
411
+ async function applyComposeDeployment(projectRoot, profileName, release, confirm, runner = deploy_compose_exec_1.defaultRunner, fetcher = fetch) {
412
+ const findings = doctorComposeDeployment(projectRoot, profileName, runner, release);
413
+ const failed = findings.filter((finding) => !finding.ok);
414
+ if (failed.length) {
415
+ throw new Error(`Deployment doctor failed: ${failed.map((f) => f.message).join(" ")}`);
416
+ }
417
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
418
+ // Nothing is provisioned before the operator's hash is accepted: planning only
419
+ // reads, so an unapproved apply leaves the target exactly as it found it.
420
+ const preflight = planComposeDeployment(projectRoot, profileName, release, runner);
421
+ if (preflight.planHash !== confirm) {
422
+ throw new Error(`Confirmation hash does not match the current plan. Expected ${preflight.planHash}.`);
423
+ }
424
+ // The state volume holds the lock and the ledger, so it has to exist before either
425
+ // is touched. Data volumes are created below and are declared external in the
426
+ // rendered project, so `compose down -v` can never take the database with it.
427
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["volume", "create", (0, deploy_compose_exec_1.stateVolumeName)(profile)]));
428
+ const holder = `${(0, node_crypto_1.randomUUID)()} ${release}`;
429
+ acquireComposeLock(profile, preflight.images.api, holder, runner);
430
+ try {
431
+ const plan = planComposeDeployment(projectRoot, profileName, release, runner);
432
+ if (plan.planHash !== confirm) {
433
+ throw new Error("Target state changed between planning and apply; re-plan and confirm the new hash.");
434
+ }
435
+ const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, release, plan.images, plan.rolloutStateDigest);
436
+ for (const dependency of [
437
+ profile.dependencies.postgres,
438
+ profile.dependencies.redis,
439
+ profile.dependencies.objectStorage,
440
+ ]) {
441
+ if (dependency.mode === "managed") {
442
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["volume", "create", dependency.volume]));
443
+ }
444
+ }
445
+ const dependencies = managedDependencyServices(profile);
446
+ if (dependencies.length) {
447
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", ...dependencies], runner, false);
448
+ }
449
+ else {
450
+ // The application network still has to exist before the migration project,
451
+ // which joins it as an external network, can start.
452
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--no-start"], runner, false);
453
+ }
454
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "migration", ["run", "--rm", "--no-deps", "migrate"], runner, false);
455
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", "--remove-orphans"], runner, false);
456
+ const ledger = (0, deploy_compose_exec_1.readLedger)(profile, plan.images.api, runner);
457
+ ledger.entries.push({
458
+ revision: ((0, deploy_compose_exec_1.currentLedgerEntry)(ledger)?.revision ?? 0) + 1,
459
+ release,
460
+ images: plan.images,
461
+ composeDocumentDigest: plan.composeDocumentDigest,
462
+ rolloutStateDigest: plan.rolloutStateDigest,
463
+ });
464
+ ledger.entries = ledger.entries.slice(-LEDGER_DEPTH);
465
+ writeLedger(profile, plan.images.api, ledger, runner);
466
+ const status = getComposeStatus(projectRoot, profileName, runner);
467
+ const verification = await verifyComposeDeployment(projectRoot, profileName, fetcher);
468
+ return { plan, status, verification };
469
+ }
470
+ finally {
471
+ releaseComposeLock(profile, preflight.images.api, runner);
472
+ }
473
+ }
474
+ function getComposeStatus(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner) {
475
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
476
+ // No render here. This used to build one with a placeholder release purely to have
477
+ // a file path to pass to `compose ps`, on the reasoning that the tag inside it was
478
+ // never resolved -- but on a remote context every compose invocation copied that
479
+ // file to the target first, so reading the status replaced the applied project with
480
+ // one naming an image that does not exist. `compose -p <project> ps` matches on
481
+ // labels and needs no file at all.
482
+ const psOutput = composePs(profile, runner);
483
+ const entries = psOutput
484
+ ? psOutput
485
+ .split("\n")
486
+ .filter(Boolean)
487
+ .map((line) => (0, deploy_compose_exec_1.parseJson)(line, "docker compose ps"))
488
+ : [];
489
+ const byService = new Map();
490
+ for (const entry of entries) {
491
+ const key = entry.Service ?? entry.Name ?? "unknown";
492
+ byService.set(key, [...(byService.get(key) ?? []), entry]);
493
+ }
494
+ const services = [];
495
+ for (const [name, group] of [...byService.entries()].sort(([l], [r]) => l.localeCompare(r))) {
496
+ let restartCount = 0;
497
+ const images = [];
498
+ let health = "unknown";
499
+ for (const entry of group) {
500
+ if (!entry.Name)
501
+ continue;
502
+ const inspected = runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["inspect", entry.Name, "--format", "{{json .}}"]), { capture: true });
503
+ if (inspected.status !== 0)
504
+ continue;
505
+ const container = (0, deploy_compose_exec_1.parseJson)(inspected.stdout, `docker inspect ${entry.Name}`);
506
+ restartCount += container.RestartCount ?? 0;
507
+ const image = container.Image ?? container.Config?.Image;
508
+ if (image && !images.includes(image))
509
+ images.push(image);
510
+ health = container.State?.Health?.Status ?? container.State?.Status ?? health;
511
+ }
512
+ const desiredWorkload = name === (0, deploy_compose_render_1.serviceName)(profile, "api")
513
+ ? profile.workloads.api
514
+ : name === (0, deploy_compose_render_1.serviceName)(profile, "web")
515
+ ? profile.workloads.web
516
+ : name === (0, deploy_compose_render_1.serviceName)(profile, "worker")
517
+ ? profile.workloads.worker
518
+ : null;
519
+ services.push({
520
+ name,
521
+ running: group.filter((entry) => entry.State === "running").length,
522
+ desired: desiredWorkload?.replicas ?? group.length,
523
+ images,
524
+ health,
525
+ restartCount,
526
+ });
527
+ }
528
+ const apiImage = services.find((service) => service.name === (0, deploy_compose_render_1.serviceName)(profile, "api"))?.images[0];
529
+ const ledger = apiImage
530
+ ? (0, deploy_compose_exec_1.readLedger)(profile, apiImage, runner)
531
+ : { schemaVersion: 1, entries: [] };
532
+ const current = (0, deploy_compose_exec_1.currentLedgerEntry)(ledger);
533
+ return {
534
+ profile: profileName,
535
+ revision: current?.revision ?? null,
536
+ release: current?.release ?? null,
537
+ services,
538
+ syncDrift: (0, deploy_compose_sync_1.readComposeSyncDrift)(projectRoot, profileName, runner),
539
+ };
540
+ }
541
+ async function verifyComposeDeployment(projectRoot, profileName, fetcher = fetch) {
542
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
543
+ const paths = [];
544
+ for (const check of profile.verification.checks) {
545
+ try {
546
+ const response = await fetcher(new URL(check.path, profile.verification.baseUrl), {
547
+ redirect: "manual",
548
+ signal: AbortSignal.timeout(15_000),
549
+ });
550
+ let ok = response.status === check.expectedStatus;
551
+ if (ok && check.expectedJson) {
552
+ const body = (await response.json().catch(() => null));
553
+ ok =
554
+ typeof body === "object" &&
555
+ body !== null &&
556
+ Object.entries(check.expectedJson).every(([key, value]) => body[key] === value);
557
+ }
558
+ paths.push({ path: check.path, status: response.status, ok });
559
+ }
560
+ catch (error) {
561
+ paths.push({
562
+ path: check.path,
563
+ status: null,
564
+ ok: false,
565
+ error: error instanceof Error ? error.message : String(error),
566
+ });
567
+ }
568
+ }
569
+ return {
570
+ profile: profileName,
571
+ baseUrl: profile.verification.baseUrl,
572
+ ok: paths.every((entry) => entry.ok),
573
+ paths,
574
+ };
575
+ }
576
+ function planComposeRollback(projectRoot, profileName, revision, runner = deploy_compose_exec_1.defaultRunner) {
577
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
578
+ const probe = (0, deploy_compose_exec_1.probeImage)(profile);
579
+ if (!probe) {
580
+ throw new Error("Cannot read the release ledger: no managed dependency image to borrow a shell from.");
581
+ }
582
+ const ledger = (0, deploy_compose_exec_1.readLedger)(profile, probe, runner);
583
+ const target = ledger.entries.find((entry) => entry.revision === revision);
584
+ if (!target) {
585
+ const known = ledger.entries.map((entry) => entry.revision).join(", ") || "none";
586
+ throw new Error(`Revision ${revision} is not in the release ledger. Known revisions: ${known}.`);
587
+ }
588
+ const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, target.release, target.images, target.rolloutStateDigest);
589
+ const composeDocumentDigest = digest(["compose-document", runtime.composeDocument]);
590
+ if (composeDocumentDigest !== target.composeDocumentDigest) {
591
+ throw new Error("The profile has changed since that revision was applied, so it cannot be reproduced byte for byte. " +
592
+ "Roll forward with a new release instead.");
593
+ }
594
+ const psOutput = composePs(profile, runner);
595
+ const current = (0, deploy_compose_exec_1.currentLedgerEntry)(ledger);
596
+ const profileDigest = (0, deploy_compose_profile_1.composeProfileDigest)(profile);
597
+ const planHash = digest([
598
+ "compose-rollback",
599
+ profileName,
600
+ String(revision),
601
+ profileDigest,
602
+ JSON.stringify(target.images),
603
+ composeDocumentDigest,
604
+ hostStateDigest(profile, ledger, psOutput),
605
+ ]);
606
+ return {
607
+ profile: profileName,
608
+ release: target.release,
609
+ planHash,
610
+ profileDigest,
611
+ currentRevision: current?.revision ?? null,
612
+ currentRelease: current?.release ?? null,
613
+ hostStateDigest: hostStateDigest(profile, ledger, psOutput),
614
+ rolloutStateDigest: target.rolloutStateDigest,
615
+ composeDocumentDigest,
616
+ target: { ...profile.target },
617
+ images: target.images,
618
+ actions: [
619
+ {
620
+ order: 1,
621
+ kind: "rollback",
622
+ description: `Restore revision ${revision} (${target.release}) and wait for health.`,
623
+ },
624
+ {
625
+ order: 2,
626
+ kind: "verification",
627
+ description: `Verify ${profile.verification.checks.length} public check(s) against ${profile.verification.baseUrl}.`,
628
+ },
629
+ ],
630
+ warnings: [
631
+ "Rollback does not reverse database migrations. If the older image cannot read the current schema, roll forward instead.",
632
+ ],
633
+ };
634
+ }
635
+ async function rollbackComposeDeployment(projectRoot, profileName, revision, confirm, runner = deploy_compose_exec_1.defaultRunner, fetcher = fetch) {
636
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
637
+ const preflight = planComposeRollback(projectRoot, profileName, revision, runner);
638
+ if (preflight.planHash !== confirm) {
639
+ throw new Error(`Confirmation hash does not match the current rollback plan. Expected ${preflight.planHash}.`);
640
+ }
641
+ const holder = `${(0, node_crypto_1.randomUUID)()} rollback-${revision}`;
642
+ acquireComposeLock(profile, preflight.images.api, holder, runner);
643
+ try {
644
+ const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, preflight.release, preflight.images, preflight.rolloutStateDigest);
645
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", "--remove-orphans"], runner, false);
646
+ const ledger = (0, deploy_compose_exec_1.readLedger)(profile, preflight.images.api, runner);
647
+ ledger.entries.push({
648
+ revision: ((0, deploy_compose_exec_1.currentLedgerEntry)(ledger)?.revision ?? 0) + 1,
649
+ release: preflight.release,
650
+ images: preflight.images,
651
+ composeDocumentDigest: preflight.composeDocumentDigest,
652
+ rolloutStateDigest: preflight.rolloutStateDigest,
653
+ });
654
+ ledger.entries = ledger.entries.slice(-LEDGER_DEPTH);
655
+ writeLedger(profile, preflight.images.api, ledger, runner);
656
+ const status = getComposeStatus(projectRoot, profileName, runner);
657
+ const verification = await verifyComposeDeployment(projectRoot, profileName, fetcher);
658
+ return { plan: preflight, status, verification };
659
+ }
660
+ finally {
661
+ releaseComposeLock(profile, preflight.images.api, runner);
662
+ }
663
+ }
664
+ /** Where the rendered project lands, for callers that want to show it. */
665
+ function composeRuntimeRoot(projectRoot, profileName) {
666
+ return (0, node_path_1.join)(projectRoot, ".podokit", "runtime", "deploy", profileName);
667
+ }