@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,412 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.composeSyncArtifacts = composeSyncArtifacts;
4
+ exports.excludesWithin = excludesWithin;
5
+ exports.runtimeDependenciesOf = runtimeDependenciesOf;
6
+ exports.planComposeSync = planComposeSync;
7
+ exports.syncComposeDeployment = syncComposeDeployment;
8
+ exports.readComposeSyncDrift = readComposeSyncDrift;
9
+ exports.revertComposeSync = revertComposeSync;
10
+ const node_fs_1 = require("node:fs");
11
+ const node_os_1 = require("node:os");
12
+ const node_path_1 = require("node:path");
13
+ const deploy_compose_exec_1 = require("./deploy-compose-exec");
14
+ const deploy_compose_render_1 = require("./deploy-compose-render");
15
+ const deploy_driver_1 = require("./deploy-driver");
16
+ const lockfile_1 = require("./lockfile");
17
+ /** Where the marker lands. `/app` is the image's root for every generated service. */
18
+ const MARKER_PATH = "/app/.podokit-sync.json";
19
+ const MARKER_NOTE = "Locally built artifacts were copied into this container. It is not running the code its image tag describes. Recreate the container to restore the image.";
20
+ const DEFAULT_HEALTH_TIMEOUT_MS = 120_000;
21
+ const HEALTH_POLL_INTERVAL_MS = 2_000;
22
+ /**
23
+ * What the images ship outside their dependency tree.
24
+ *
25
+ * Every entry mirrors a `COPY --from=build` line in the generated Dockerfiles, which
26
+ * is what makes the copy equivalent to a rebuild for code-only changes. Two entries
27
+ * exist for a reason worth keeping:
28
+ *
29
+ * - `apps/web/server.js` and `apps/web/src/lib/server` ship as SOURCE, outside the
30
+ * bundle, because the entry point has to run before the adapter does. Syncing only
31
+ * `build/` would leave a stale entry running new routes.
32
+ * - `packages/*` are copied as real directories rather than the symlinks npm leaves
33
+ * behind, so their `dist/` is a separate destination from the apps'.
34
+ *
35
+ * `node_modules` is deliberately absent: the image installs a production-only,
36
+ * workspace-scoped tree for its own platform, and a developer's tree is neither.
37
+ */
38
+ function composeSyncArtifacts(projectRoot) {
39
+ const artifacts = [];
40
+ const push = (source, destination, kind, roles) => {
41
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(projectRoot, source))) {
42
+ artifacts.push({ source, destination, kind, roles });
43
+ }
44
+ };
45
+ const packagesDir = (0, node_path_1.join)(projectRoot, "packages");
46
+ if ((0, node_fs_1.existsSync)(packagesDir)) {
47
+ for (const entry of (0, node_fs_1.readdirSync)(packagesDir).sort()) {
48
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(packagesDir, entry, "package.json")))
49
+ continue;
50
+ push(`packages/${entry}/dist`, `/app/packages/${entry}/dist`, "directory", ["api", "web", "worker"]);
51
+ }
52
+ }
53
+ push("apps/api/dist", "/app/apps/api/dist", "directory", ["api", "worker"]);
54
+ push("apps/api/scripts", "/app/apps/api/scripts", "directory", ["api", "worker"]);
55
+ push("apps/web/build", "/app/apps/web/build", "directory", ["web"]);
56
+ push("apps/web/server.js", "/app/apps/web/server.js", "file", ["web"]);
57
+ push("apps/web/src/lib/server", "/app/apps/web/src/lib/server", "directory", ["web"]);
58
+ return artifacts;
59
+ }
60
+ /** The excludes that fall inside one artifact, as paths relative to it. */
61
+ function excludesWithin(artifact, excludes) {
62
+ const prefix = `${artifact.source}/`;
63
+ return excludes
64
+ .filter((entry) => entry.startsWith(prefix))
65
+ .map((entry) => entry.slice(prefix.length));
66
+ }
67
+ /**
68
+ * Whether the image's dependency tree can still run this code.
69
+ *
70
+ * Only the runtime dependency sets matter: `npm ci --omit=dev` installed what these
71
+ * declare and nothing else, so a change to any of them means the container is
72
+ * missing a package the new code imports -- a failure that surfaces as a crash loop
73
+ * after the restart rather than as a copy error. `devDependencies` are absent from
74
+ * the image by design and a `version` bump changes nothing at runtime, so neither
75
+ * takes part.
76
+ */
77
+ function runtimeDependenciesOf(manifest) {
78
+ return JSON.stringify({
79
+ dependencies: manifest.dependencies ?? {},
80
+ optionalDependencies: manifest.optionalDependencies ?? {},
81
+ peerDependencies: manifest.peerDependencies ?? {},
82
+ });
83
+ }
84
+ /** The manifests whose runtime dependencies a role's container was built from. */
85
+ function manifestPathsFor(projectRoot, role) {
86
+ const app = role === "web" ? "web" : "api";
87
+ const pairs = [
88
+ [(0, node_path_1.join)(projectRoot, "apps", app, "package.json"), `/app/apps/${app}/package.json`],
89
+ ];
90
+ const packagesDir = (0, node_path_1.join)(projectRoot, "packages");
91
+ if ((0, node_fs_1.existsSync)(packagesDir)) {
92
+ for (const entry of (0, node_fs_1.readdirSync)(packagesDir).sort()) {
93
+ const local = (0, node_path_1.join)(packagesDir, entry, "package.json");
94
+ if ((0, node_fs_1.existsSync)(local))
95
+ pairs.push([local, `/app/packages/${entry}/package.json`]);
96
+ }
97
+ }
98
+ return pairs;
99
+ }
100
+ function inspectContainer(profile, container, runner) {
101
+ const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["inspect", container, "--format", "{{json .}}"]));
102
+ return (0, deploy_compose_exec_1.parseJson)(output, `docker inspect ${container}`);
103
+ }
104
+ /**
105
+ * The running container for a Compose service, found by label rather than by name.
106
+ *
107
+ * Compose derives container names from the project, the service and an index, and a
108
+ * project whose name is also the service prefix produces names that look duplicated.
109
+ * Reconstructing that string is guesswork; the labels are what Compose itself
110
+ * matches on.
111
+ */
112
+ function findContainer(profile, service, runner) {
113
+ const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
114
+ "ps",
115
+ "--filter",
116
+ `label=com.docker.compose.project=${profile.target.project}`,
117
+ "--filter",
118
+ `label=com.docker.compose.service=${service}`,
119
+ "--format",
120
+ "{{.Names}}",
121
+ ]));
122
+ const names = output
123
+ .split("\n")
124
+ .map((line) => line.trim())
125
+ .filter(Boolean);
126
+ return names[0] ?? null;
127
+ }
128
+ function rolesOf(profile) {
129
+ return profile.workloads.worker ? ["api", "web", "worker"] : ["api", "web"];
130
+ }
131
+ function planComposeSync(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner) {
132
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
133
+ const excluded = profile.sync?.exclude ?? [];
134
+ const artifacts = composeSyncArtifacts(projectRoot);
135
+ const warnings = [];
136
+ const targets = [];
137
+ for (const role of rolesOf(profile)) {
138
+ const service = (0, deploy_compose_render_1.serviceName)(profile, role);
139
+ const container = findContainer(profile, service, runner);
140
+ if (!container) {
141
+ warnings.push(`Service ${service} has no running container; it is skipped.`);
142
+ continue;
143
+ }
144
+ const inspected = inspectContainer(profile, container, runner);
145
+ targets.push({ role, service, container, user: inspected.Config?.User?.trim() || null });
146
+ }
147
+ warnings.push("Restarting a container drops every connection it is serving, including WebSocket sessions.");
148
+ warnings.push("This does not run migrations. A change that needs one belongs in a release, not a sync.");
149
+ return {
150
+ profile: profileName,
151
+ project: profile.target.project,
152
+ artifacts,
153
+ excluded,
154
+ targets,
155
+ warnings,
156
+ };
157
+ }
158
+ /**
159
+ * Build what the images build, in the order the images build it.
160
+ *
161
+ * Not the root `build` script: npm runs workspaces in the order the root manifest
162
+ * lists them, and `apps/*` is conventionally listed before `packages/*`. An app then
163
+ * compiles against a workspace package's previous output, or against none at all.
164
+ * The Dockerfiles build every package first for exactly this reason.
165
+ */
166
+ function buildArtifacts(projectRoot, runner) {
167
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
168
+ const pm = manifest?.packageManager ?? "npm";
169
+ const packagesDir = (0, node_path_1.join)(projectRoot, "packages");
170
+ if ((0, node_fs_1.existsSync)(packagesDir)) {
171
+ for (const entry of (0, node_fs_1.readdirSync)(packagesDir).sort()) {
172
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(packagesDir, entry, "package.json")))
173
+ continue;
174
+ (0, deploy_compose_exec_1.checked)(runner, pm, ["run", "build", "--if-present", "--workspace", `packages/${entry}`], false);
175
+ }
176
+ }
177
+ for (const app of ["api", "web"]) {
178
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(projectRoot, "apps", app, "package.json")))
179
+ continue;
180
+ (0, deploy_compose_exec_1.checked)(runner, pm, ["run", "build", "--if-present", "--workspace", `apps/${app}`], false);
181
+ }
182
+ }
183
+ function assertNoManifestDrift(projectRoot, profile, targets, runner) {
184
+ const drifted = [];
185
+ for (const target of targets) {
186
+ for (const [local, inContainer] of manifestPathsFor(projectRoot, target.role)) {
187
+ const result = runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["exec", target.container, "cat", inContainer]), { capture: true });
188
+ if (result.status !== 0) {
189
+ drifted.push(`${target.container}:${inContainer} could not be read`);
190
+ continue;
191
+ }
192
+ const deployed = runtimeDependenciesOf((0, deploy_compose_exec_1.parseJson)(result.stdout, `${target.container}:${inContainer}`));
193
+ const current = runtimeDependenciesOf(JSON.parse((0, node_fs_1.readFileSync)(local, "utf8")));
194
+ if (deployed !== current)
195
+ drifted.push(`${inContainer} (in ${target.container})`);
196
+ }
197
+ }
198
+ if (drifted.length) {
199
+ throw new Error("Runtime dependencies have changed since these containers were built, so their node_modules " +
200
+ `cannot run this code: ${drifted.join(", ")}. Build and deploy a release instead.`);
201
+ }
202
+ }
203
+ /**
204
+ * A copy of one artifact with the excluded paths removed.
205
+ *
206
+ * `docker cp` has no exclude, and it merges rather than replaces -- so filtering has
207
+ * to happen before the copy. Staging costs one local copy of a few megabytes and
208
+ * keeps the rule in one place.
209
+ */
210
+ function stageArtifact(projectRoot, artifact, excludes) {
211
+ const source = (0, node_path_1.join)(projectRoot, artifact.source);
212
+ const within = excludesWithin(artifact, excludes);
213
+ if (artifact.kind === "file" || within.length === 0) {
214
+ return { path: source, cleanup: () => undefined };
215
+ }
216
+ const staging = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-sync-"));
217
+ const staged = (0, node_path_1.join)(staging, "artifact");
218
+ (0, node_fs_1.cpSync)(source, staged, { recursive: true });
219
+ for (const relative of within)
220
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(staged, relative), { recursive: true, force: true });
221
+ return { path: staged, cleanup: () => (0, node_fs_1.rmSync)(staging, { recursive: true, force: true }) };
222
+ }
223
+ async function waitForContainer(profile, container, timeoutMs, runner) {
224
+ const deadline = Date.now() + timeoutMs;
225
+ for (;;) {
226
+ const inspected = inspectContainer(profile, container, runner);
227
+ const health = inspected.State?.Health?.Status;
228
+ const status = inspected.State?.Status ?? "unknown";
229
+ // A container without a HEALTHCHECK reports no health at all, and waiting for a
230
+ // status it will never publish would hang every sync on such an image.
231
+ if (health === "healthy")
232
+ return { state: health, ok: true };
233
+ if (!health && status === "running")
234
+ return { state: status, ok: true };
235
+ if (health === "unhealthy")
236
+ return { state: health, ok: false };
237
+ if (Date.now() >= deadline)
238
+ return { state: health ?? status, ok: false };
239
+ await new Promise((resolve) => setTimeout(resolve, HEALTH_POLL_INTERVAL_MS));
240
+ }
241
+ }
242
+ async function syncComposeDeployment(projectRoot, profileName, options = {}, runner = deploy_compose_exec_1.defaultRunner) {
243
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
244
+ const holder = (0, deploy_compose_exec_1.composeLockHolder)(profile, runner);
245
+ if (holder) {
246
+ throw new Error(`Deployment lock for ${profile.target.project} is held by ${holder}. A release is in progress; wait for it to finish.`);
247
+ }
248
+ if (options.build)
249
+ buildArtifacts(projectRoot, runner);
250
+ const plan = planComposeSync(projectRoot, profileName, runner);
251
+ if (!plan.targets.length) {
252
+ throw new Error(`No running container found for Compose project ${profile.target.project}. Deploy a release before syncing into it.`);
253
+ }
254
+ if (!plan.artifacts.length) {
255
+ throw new Error("No build output to sync. Build the project first, or pass --build to build it here.");
256
+ }
257
+ if (options.clean) {
258
+ const conflicting = plan.artifacts.filter((artifact) => excludesWithin(artifact, plan.excluded).length > 0);
259
+ if (conflicting.length) {
260
+ throw new Error(`--clean cannot be used while ${conflicting.map((a) => a.source).join(", ")} contains an excluded path: ` +
261
+ "emptying the destination would delete artifacts this machine cannot rebuild.");
262
+ }
263
+ }
264
+ assertNoManifestDrift(projectRoot, profile, plan.targets, runner);
265
+ const copied = [];
266
+ for (const artifact of plan.artifacts) {
267
+ const staged = stageArtifact(projectRoot, artifact, plan.excluded);
268
+ try {
269
+ for (const target of plan.targets) {
270
+ if (!artifact.roles.includes(target.role))
271
+ continue;
272
+ if (options.clean && artifact.kind === "directory") {
273
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
274
+ "exec",
275
+ "--user",
276
+ "0:0",
277
+ target.container,
278
+ "sh",
279
+ "-c",
280
+ `rm -rf ${artifact.destination}/* ${artifact.destination}/.[!.]* 2>/dev/null || true`,
281
+ ]));
282
+ }
283
+ // `<dir>/.` copies the CONTENTS into an existing directory; without it the
284
+ // directory would be nested one level deeper on every sync.
285
+ const source = artifact.kind === "directory" ? `${staged.path}/.` : staged.path;
286
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["cp", source, `${target.container}:${artifact.destination}`]));
287
+ // `docker cp` without --archive gives the files to the daemon's root, and
288
+ // these images run as an unprivileged user. Readable is not enough for a
289
+ // directory the application also has to traverse.
290
+ if (target.user) {
291
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
292
+ "exec",
293
+ "--user",
294
+ "0:0",
295
+ target.container,
296
+ "chown",
297
+ "-R",
298
+ target.user,
299
+ artifact.destination,
300
+ ]));
301
+ }
302
+ copied.push({
303
+ container: target.container,
304
+ source: artifact.source,
305
+ destination: artifact.destination,
306
+ });
307
+ }
308
+ }
309
+ finally {
310
+ staged.cleanup();
311
+ }
312
+ }
313
+ const now = options.now ?? (() => new Date());
314
+ const marker = {
315
+ syncedAt: now().toISOString(),
316
+ profile: profileName,
317
+ artifacts: plan.artifacts.map((artifact) => artifact.source),
318
+ note: MARKER_NOTE,
319
+ };
320
+ const markerDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-sync-marker-"));
321
+ const markerFile = (0, node_path_1.join)(markerDir, "marker.json");
322
+ (0, node_fs_1.writeFileSync)(markerFile, `${JSON.stringify(marker, null, 2)}\n`);
323
+ try {
324
+ for (const target of plan.targets) {
325
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["cp", markerFile, `${target.container}:${MARKER_PATH}`]));
326
+ }
327
+ }
328
+ finally {
329
+ (0, node_fs_1.rmSync)(markerDir, { recursive: true, force: true });
330
+ }
331
+ const restarted = [];
332
+ for (const target of plan.targets) {
333
+ (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["restart", target.container]));
334
+ restarted.push(target.container);
335
+ }
336
+ // A sync that leaves a container unable to come back is a failed sync, and saying
337
+ // so is the whole value of waiting: the copy already happened, so the operator has
338
+ // to know the deployment is now serving from artifacts that do not work.
339
+ const unhealthy = [];
340
+ for (const target of plan.targets) {
341
+ const health = await waitForContainer(profile, target.container, options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS, runner);
342
+ if (!health.ok)
343
+ unhealthy.push(`${target.container} (${health.state})`);
344
+ }
345
+ if (unhealthy.length) {
346
+ throw new Error(`Restarted container(s) did not become healthy: ${unhealthy.join(", ")}. ` +
347
+ "The synced artifacts are still in place; podo deploy sync --revert restores the image.");
348
+ }
349
+ return { plan, copied, restarted, marker };
350
+ }
351
+ /**
352
+ * Which containers are running synced artifacts rather than their image's.
353
+ *
354
+ * Reporting-only, so it never throws: `podo deploy status` folds this in, and a
355
+ * target that cannot answer right now is a reason to report less, not a reason for
356
+ * status to fail. An empty result therefore means "no drift found", which is also
357
+ * what an unreachable target produces -- the services list beside it already shows
358
+ * that case for what it is.
359
+ */
360
+ function readComposeSyncDrift(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner) {
361
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
362
+ const drift = [];
363
+ for (const role of rolesOf(profile)) {
364
+ try {
365
+ const container = findContainer(profile, (0, deploy_compose_render_1.serviceName)(profile, role), runner);
366
+ if (!container)
367
+ continue;
368
+ const result = runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["exec", container, "cat", MARKER_PATH]), {
369
+ capture: true,
370
+ });
371
+ if (result.status !== 0 || !result.stdout.trim())
372
+ continue;
373
+ drift.push({
374
+ container,
375
+ marker: (0, deploy_compose_exec_1.parseJson)(result.stdout, `${container}:${MARKER_PATH}`),
376
+ });
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ }
382
+ return drift;
383
+ }
384
+ /**
385
+ * Put the deployment back on its image.
386
+ *
387
+ * The release to restore comes from the ledger, and the project is rendered from
388
+ * that entry's own release, images and rollout state -- the same way a rollback
389
+ * reproduces a revision.
390
+ *
391
+ * ⚠ Not from the Compose file sitting on the target, which was the first attempt.
392
+ * That file is written by whichever command last ran a compose mutation, and one of
393
+ * them renders a placeholder release to obtain a file path. The revert then tried to
394
+ * pull an image tag that never existed and refused to recreate anything. Measured
395
+ * against a live deployment; the read path was fixed at the same time so a status
396
+ * call can no longer overwrite the target's project.
397
+ */
398
+ async function revertComposeSync(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner) {
399
+ const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
400
+ const probe = (0, deploy_compose_exec_1.probeImage)(profile);
401
+ if (!probe) {
402
+ throw new Error("Cannot read the release ledger: no managed dependency image to borrow a shell from.");
403
+ }
404
+ const current = (0, deploy_compose_exec_1.currentLedgerEntry)((0, deploy_compose_exec_1.readLedger)(profile, probe, runner));
405
+ if (!current) {
406
+ throw new Error("No release has been applied to this deployment, so there is no image state to restore.");
407
+ }
408
+ const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, current.release, current.images, current.rolloutStateDigest);
409
+ const services = rolesOf(profile).map((role) => (0, deploy_compose_render_1.serviceName)(profile, role));
410
+ (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--force-recreate", "--wait", ...services], runner, false);
411
+ return { services, project: profile.target.project, release: current.release };
412
+ }
@@ -0,0 +1,98 @@
1
+ import type { CommandRunner } from "./dev";
2
+ import { type ComposeSyncDrift } from "./deploy-compose-sync";
3
+ import { type ComposeImages } from "./deploy-compose-render";
4
+ /**
5
+ * The Docker Compose deployment driver.
6
+ *
7
+ * Every safety property the Kubernetes driver has is reproduced here with the
8
+ * primitives Compose offers: a fingerprint pins the target endpoint, a plan binds
9
+ * resolved image digests and rendered bytes to one hash that apply must echo back, a
10
+ * lock file in a state volume replaces the Kubernetes Lease, and a release ledger in
11
+ * that same volume replaces Helm revisions.
12
+ */
13
+ export interface ComposeDoctorFinding {
14
+ code: string;
15
+ ok: boolean;
16
+ message: string;
17
+ }
18
+ export interface ComposePlanAction {
19
+ order: number;
20
+ kind: "dependencies" | "migration" | "application" | "verification" | "rollback";
21
+ description: string;
22
+ }
23
+ export interface ComposePlan {
24
+ profile: string;
25
+ release: string;
26
+ planHash: string;
27
+ profileDigest: string;
28
+ currentRevision: number | null;
29
+ currentRelease: string | null;
30
+ hostStateDigest: string;
31
+ rolloutStateDigest: string;
32
+ composeDocumentDigest: string;
33
+ target: {
34
+ context: string;
35
+ endpointFingerprint: string;
36
+ project: string;
37
+ };
38
+ images: ComposeImages;
39
+ actions: ComposePlanAction[];
40
+ warnings: string[];
41
+ }
42
+ export interface ComposeStatus {
43
+ profile: string;
44
+ revision: number | null;
45
+ release: string | null;
46
+ services: Array<{
47
+ name: string;
48
+ running: number;
49
+ desired: number;
50
+ images: string[];
51
+ health: string;
52
+ restartCount: number;
53
+ }>;
54
+ /**
55
+ * Containers running locally synced artifacts instead of their image's.
56
+ *
57
+ * Reported beside the release because the two disagree when it is non-empty: the
58
+ * tag says one thing and the running code is another. Empty is the normal state.
59
+ */
60
+ syncDrift: ComposeSyncDrift[];
61
+ }
62
+ export interface ComposeVerificationResult {
63
+ profile: string;
64
+ baseUrl: string;
65
+ ok: boolean;
66
+ paths: Array<{
67
+ path: string;
68
+ status: number | null;
69
+ ok: boolean;
70
+ error?: string;
71
+ }>;
72
+ }
73
+ /**
74
+ * The endpoint identity a profile is pinned to.
75
+ *
76
+ * The Kubernetes driver fingerprints the cluster server and CA; the closest stable
77
+ * equivalent here is the context's Docker endpoint plus the daemon's own ID, so
78
+ * repointing a context at a different host invalidates the profile instead of
79
+ * silently deploying somewhere else.
80
+ */
81
+ export declare function inspectComposeEndpointFingerprint(context: string, runner?: CommandRunner): string;
82
+ export declare function doctorComposeDeployment(projectRoot: string, profileName: string, runner?: CommandRunner, release?: string): ComposeDoctorFinding[];
83
+ export declare function planComposeDeployment(projectRoot: string, profileName: string, release: string, runner?: CommandRunner): ComposePlan;
84
+ export declare function applyComposeDeployment(projectRoot: string, profileName: string, release: string, confirm: string, runner?: CommandRunner, fetcher?: typeof fetch): Promise<{
85
+ plan: ComposePlan;
86
+ status: ComposeStatus;
87
+ verification: ComposeVerificationResult;
88
+ }>;
89
+ export declare function getComposeStatus(projectRoot: string, profileName: string, runner?: CommandRunner): ComposeStatus;
90
+ export declare function verifyComposeDeployment(projectRoot: string, profileName: string, fetcher?: typeof fetch): Promise<ComposeVerificationResult>;
91
+ export declare function planComposeRollback(projectRoot: string, profileName: string, revision: number, runner?: CommandRunner): ComposePlan;
92
+ export declare function rollbackComposeDeployment(projectRoot: string, profileName: string, revision: number, confirm: string, runner?: CommandRunner, fetcher?: typeof fetch): Promise<{
93
+ plan: ComposePlan;
94
+ status: ComposeStatus;
95
+ verification: ComposeVerificationResult;
96
+ }>;
97
+ /** Where the rendered project lands, for callers that want to show it. */
98
+ export declare function composeRuntimeRoot(projectRoot: string, profileName: string): string;