@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,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
|
+
}
|