@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.
- package/dist/deploy-compose-exec.d.ts +93 -0
- package/dist/deploy-compose-exec.js +198 -0
- package/dist/deploy-compose-profile.d.ts +107 -0
- package/dist/deploy-compose-profile.js +361 -0
- package/dist/deploy-compose-render.d.ts +48 -0
- package/dist/deploy-compose-render.js +333 -0
- package/dist/deploy-compose-sync.d.ts +144 -0
- package/dist/deploy-compose-sync.js +412 -0
- package/dist/deploy-compose.d.ts +98 -0
- package/dist/deploy-compose.js +667 -0
- package/dist/deploy-dispatch.d.ts +33 -0
- package/dist/deploy-dispatch.js +58 -0
- package/dist/deploy-driver.d.ts +25 -0
- package/dist/deploy-driver.js +78 -0
- package/dist/deploy-profile.d.ts +3 -10
- package/dist/deploy-profile.js +82 -291
- package/dist/deploy-schema.d.ts +56 -0
- package/dist/deploy-schema.js +261 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +178 -6
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +30 -1
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
- package/dist/templates/todo/AGENTS.md +2 -1
- package/dist/templates/todo/Dockerfile.dev +34 -3
- package/dist/templates/todo/apps/api/Dockerfile +68 -7
- package/dist/templates/todo/apps/web/Dockerfile +60 -8
- package/dist/templates/todo/apps/web/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/todo/apps/web/vite.config.ts +17 -7
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/todo/dot-dockerignore +7 -0
- package/dist/templates/todo/dot-env.example +7 -0
- package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
- package/dist/templates/todo/infra/k3s/README.md +18 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
- 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;
|