@podosoft/podokit 1.6.2 → 1.6.4
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-checks.d.ts +20 -2
- package/dist/deploy-checks.js +83 -4
- package/dist/deploy-compose-render.js +15 -4
- package/dist/deploy-compose.js +211 -27
- package/dist/deploy.js +16 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- package/package.json +1 -1
package/dist/deploy-checks.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface ConnectionBudget {
|
|
|
30
30
|
*/
|
|
31
31
|
rolloutProcesses: number;
|
|
32
32
|
rollout: number;
|
|
33
|
+
/** What the rollout count includes beyond the steady processes. */
|
|
34
|
+
rolloutOverlap: string;
|
|
33
35
|
migration: number;
|
|
34
36
|
peak: number;
|
|
35
37
|
}
|
|
@@ -45,11 +47,27 @@ export interface ConnectionBudget {
|
|
|
45
47
|
* starts the next surge pod as soon as the previous replacement is ready. When
|
|
46
48
|
* replacements become ready faster than replaced pods exit, every old pod of a
|
|
47
49
|
* workload can still hold its pools while all of its new pods run, twice its
|
|
48
|
-
* replicas whatever the surge. Compose
|
|
49
|
-
*
|
|
50
|
+
* replicas whatever the surge. Compose starts the new API containers beside the
|
|
51
|
+
* old ones and stops the old ones once the new ones are healthy, so it runs twice
|
|
52
|
+
* the API replicas; it stops the worker before it starts the replacement.
|
|
50
53
|
*/
|
|
51
54
|
export declare function connectionBudget(profile: AnyDeployProfile, modules: Set<string>): ConnectionBudget | null;
|
|
52
55
|
/** The plan warning for a budget over the limit, or null. */
|
|
53
56
|
export declare function connectionBudgetWarning(profile: AnyDeployProfile, modules: Set<string>): string | null;
|
|
57
|
+
/** The ADMIN_EMAILS entries the API reads, from a value that may still carry an env file's quotes. */
|
|
58
|
+
export declare function adminEmailEntries(value: string): string[];
|
|
59
|
+
/** Why an ADMIN_EMAILS entry cannot be an administrator's real address, or null when it can be. */
|
|
60
|
+
export declare function adminEmailProblem(entry: string): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Whether ADMIN_EMAILS names real addresses. A placeholder such as
|
|
63
|
+
* admin@example.com names an administrator nobody can be, and a generated API
|
|
64
|
+
* refuses to start in production with one, so the doctor fails before a rollout.
|
|
65
|
+
*/
|
|
66
|
+
export declare function adminEmailsFinding(value: string | undefined, source: string): DeployFinding;
|
|
67
|
+
/**
|
|
68
|
+
* Whether a driver must read ADMIN_EMAILS from the API secret on the target: the
|
|
69
|
+
* auth module reads it, and runtimeConfig, which profileFindings checks, does not set it.
|
|
70
|
+
*/
|
|
71
|
+
export declare function adminEmailsOnTarget(projectRoot: string, profile: AnyDeployProfile): boolean;
|
|
54
72
|
export declare function profileFindings(projectRoot: string, profile: AnyDeployProfile): DeployFinding[];
|
|
55
73
|
export declare function formatFinding(finding: DeployFinding): string;
|
package/dist/deploy-checks.js
CHANGED
|
@@ -4,6 +4,10 @@ exports.DEFAULT_MAX_CONNECTIONS = void 0;
|
|
|
4
4
|
exports.installedModules = installedModules;
|
|
5
5
|
exports.connectionBudget = connectionBudget;
|
|
6
6
|
exports.connectionBudgetWarning = connectionBudgetWarning;
|
|
7
|
+
exports.adminEmailEntries = adminEmailEntries;
|
|
8
|
+
exports.adminEmailProblem = adminEmailProblem;
|
|
9
|
+
exports.adminEmailsFinding = adminEmailsFinding;
|
|
10
|
+
exports.adminEmailsOnTarget = adminEmailsOnTarget;
|
|
7
11
|
exports.profileFindings = profileFindings;
|
|
8
12
|
exports.formatFinding = formatFinding;
|
|
9
13
|
const app_proxy_timeouts_1 = require("./app-proxy-timeouts");
|
|
@@ -114,8 +118,9 @@ function poolSize(runtimeConfig, key, fallback) {
|
|
|
114
118
|
* starts the next surge pod as soon as the previous replacement is ready. When
|
|
115
119
|
* replacements become ready faster than replaced pods exit, every old pod of a
|
|
116
120
|
* workload can still hold its pools while all of its new pods run, twice its
|
|
117
|
-
* replicas whatever the surge. Compose
|
|
118
|
-
*
|
|
121
|
+
* replicas whatever the surge. Compose starts the new API containers beside the
|
|
122
|
+
* old ones and stops the old ones once the new ones are healthy, so it runs twice
|
|
123
|
+
* the API replicas; it stops the worker before it starts the replacement.
|
|
119
124
|
*/
|
|
120
125
|
function connectionBudget(profile, modules) {
|
|
121
126
|
const postgres = profile.dependencies.postgres;
|
|
@@ -138,7 +143,9 @@ function connectionBudget(profile, modules) {
|
|
|
138
143
|
}
|
|
139
144
|
const worker = profile.workloads.worker;
|
|
140
145
|
const steadyProcesses = profile.workloads.api.replicas + (worker?.replicas ?? 0);
|
|
141
|
-
const rolloutProcesses = profile.driver === "kubernetes-helm"
|
|
146
|
+
const rolloutProcesses = profile.driver === "kubernetes-helm"
|
|
147
|
+
? 2 * steadyProcesses
|
|
148
|
+
: steadyProcesses + profile.workloads.api.replicas;
|
|
142
149
|
const maxConnections = postgres.maxConnections ?? exports.DEFAULT_MAX_CONNECTIONS;
|
|
143
150
|
const steady = steadyProcesses * perProcess;
|
|
144
151
|
const rollout = rolloutProcesses * perProcess;
|
|
@@ -151,6 +158,9 @@ function connectionBudget(profile, modules) {
|
|
|
151
158
|
steady,
|
|
152
159
|
rolloutProcesses,
|
|
153
160
|
rollout,
|
|
161
|
+
rolloutOverlap: profile.driver === "kubernetes-helm"
|
|
162
|
+
? "replaced pods that have not exited"
|
|
163
|
+
: "old API containers that have not stopped",
|
|
154
164
|
migration: MIGRATION_CONNECTIONS,
|
|
155
165
|
peak: Math.max(rollout, steady + MIGRATION_CONNECTIONS),
|
|
156
166
|
};
|
|
@@ -159,7 +169,7 @@ function budgetSummary(budget) {
|
|
|
159
169
|
return (`each API or worker process holds up to ${budget.perProcess} (${budget.perProcessTerms.join(" + ")}); ` +
|
|
160
170
|
`${budget.steadyProcesses} process(es) hold ${budget.steady}` +
|
|
161
171
|
(budget.rolloutProcesses > budget.steadyProcesses
|
|
162
|
-
? `, ${budget.rolloutProcesses} during a rollout, counting
|
|
172
|
+
? `, ${budget.rolloutProcesses} during a rollout, counting ${budget.rolloutOverlap}, hold ${budget.rollout}`
|
|
163
173
|
: "") +
|
|
164
174
|
`, and the migration adds ${budget.migration} to the ${budget.steady}`);
|
|
165
175
|
}
|
|
@@ -257,6 +267,72 @@ function clientAddressFinding(profile) {
|
|
|
257
267
|
: "";
|
|
258
268
|
return { code, ok: true, message: `With exposure.edgeProxies ${edge}, ${summary}.${traefik}` };
|
|
259
269
|
}
|
|
270
|
+
/** Domains and top-level names reserved for documentation and testing (RFC 2606, RFC 6761), which receive no mail. */
|
|
271
|
+
const PLACEHOLDER_DOMAINS = ["example.com", "example.net", "example.org"];
|
|
272
|
+
const PLACEHOLDER_TOP_LEVEL_NAMES = new Set(["example", "test", "invalid", "localhost"]);
|
|
273
|
+
/** The ADMIN_EMAILS entries the API reads, from a value that may still carry an env file's quotes. */
|
|
274
|
+
function adminEmailEntries(value) {
|
|
275
|
+
return value
|
|
276
|
+
.trim()
|
|
277
|
+
.replace(/^(["'])([\s\S]*)\1$/, "$2")
|
|
278
|
+
.split(",")
|
|
279
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
/** Why an ADMIN_EMAILS entry cannot be an administrator's real address, or null when it can be. */
|
|
283
|
+
function adminEmailProblem(entry) {
|
|
284
|
+
const at = entry.lastIndexOf("@");
|
|
285
|
+
if (at >= 0) {
|
|
286
|
+
const domain = entry.slice(at + 1);
|
|
287
|
+
const topLevel = domain.split(".").at(-1) ?? "";
|
|
288
|
+
if (PLACEHOLDER_DOMAINS.some((reserved) => domain === reserved || domain.endsWith(`.${reserved}`)) ||
|
|
289
|
+
PLACEHOLDER_TOP_LEVEL_NAMES.has(topLevel)) {
|
|
290
|
+
return `a placeholder: ${domain} is reserved and receives no mail`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry) ? null : "not an email address";
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Whether ADMIN_EMAILS names real addresses. A placeholder such as
|
|
297
|
+
* admin@example.com names an administrator nobody can be, and a generated API
|
|
298
|
+
* refuses to start in production with one, so the doctor fails before a rollout.
|
|
299
|
+
*/
|
|
300
|
+
function adminEmailsFinding(value, source) {
|
|
301
|
+
const code = "admin-emails";
|
|
302
|
+
const entries = adminEmailEntries(value ?? "");
|
|
303
|
+
if (entries.length === 0) {
|
|
304
|
+
return {
|
|
305
|
+
code,
|
|
306
|
+
ok: true,
|
|
307
|
+
message: `${source} does not set ADMIN_EMAILS, so no account becomes an administrator through it; ` +
|
|
308
|
+
"administrators come from admin:bootstrap or the admin console.",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const problems = entries.flatMap((entry) => {
|
|
312
|
+
const problem = adminEmailProblem(entry);
|
|
313
|
+
return problem ? [`${entry} (${problem})`] : [];
|
|
314
|
+
});
|
|
315
|
+
if (problems.length) {
|
|
316
|
+
return {
|
|
317
|
+
code,
|
|
318
|
+
ok: false,
|
|
319
|
+
message: `ADMIN_EMAILS in ${source} lists ${problems.join(", ")}. In production it must list the administrators' ` +
|
|
320
|
+
"real addresses or stay empty; a generated API refuses to start in production with such an entry.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
code,
|
|
325
|
+
ok: true,
|
|
326
|
+
message: `ADMIN_EMAILS in ${source} lists ${entries.length} administrator address${entries.length === 1 ? "" : "es"}.`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Whether a driver must read ADMIN_EMAILS from the API secret on the target: the
|
|
331
|
+
* auth module reads it, and runtimeConfig, which profileFindings checks, does not set it.
|
|
332
|
+
*/
|
|
333
|
+
function adminEmailsOnTarget(projectRoot, profile) {
|
|
334
|
+
return installedModules(projectRoot).has("auth") && profile.runtimeConfig.ADMIN_EMAILS === undefined;
|
|
335
|
+
}
|
|
260
336
|
function profileFindings(projectRoot, profile) {
|
|
261
337
|
const modules = installedModules(projectRoot);
|
|
262
338
|
const findings = [
|
|
@@ -268,6 +344,9 @@ function profileFindings(projectRoot, profile) {
|
|
|
268
344
|
findings.push(budget);
|
|
269
345
|
if (modules.has("audit-log"))
|
|
270
346
|
findings.push(auditLogFinding(profile.runtimeConfig));
|
|
347
|
+
if (modules.has("auth") && profile.runtimeConfig.ADMIN_EMAILS !== undefined) {
|
|
348
|
+
findings.push(adminEmailsFinding(profile.runtimeConfig.ADMIN_EMAILS, "runtimeConfig"));
|
|
349
|
+
}
|
|
271
350
|
return findings;
|
|
272
351
|
}
|
|
273
352
|
function formatFinding(finding) {
|
|
@@ -19,6 +19,13 @@ const lockfile_1 = require("./lockfile");
|
|
|
19
19
|
const toolchain_1 = require("./toolchain");
|
|
20
20
|
/** The internal port every generated application service listens on. */
|
|
21
21
|
const CONTAINER_PORT = 3000;
|
|
22
|
+
/**
|
|
23
|
+
* Bun caches a host name's addresses for 30 seconds and keeps connecting to a
|
|
24
|
+
* cached address after it stops answering. The web server reaches the API by its
|
|
25
|
+
* service name, which a rollout moves to new containers, so with the cache it
|
|
26
|
+
* would keep calling the old API's address after that container is gone.
|
|
27
|
+
*/
|
|
28
|
+
const WEB_DNS_CONFIG = { BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS: "0" };
|
|
22
29
|
function quote(value) {
|
|
23
30
|
return JSON.stringify(value);
|
|
24
31
|
}
|
|
@@ -262,7 +269,7 @@ function applicationService(profile, role, image, release, stateDigest, environm
|
|
|
262
269
|
" healthcheck:\n disable: true\n") +
|
|
263
270
|
labelsBlock(profile, release, stateDigest, " "));
|
|
264
271
|
}
|
|
265
|
-
function gatewayService(profile, image
|
|
272
|
+
function gatewayService(profile, image) {
|
|
266
273
|
const globalOptions = profile.exposure.trustedProxyCidrs.length
|
|
267
274
|
? `{
|
|
268
275
|
servers {
|
|
@@ -318,7 +325,10 @@ function gatewayService(profile, image, release, stateDigest) {
|
|
|
318
325
|
` healthcheck:\n` +
|
|
319
326
|
` test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]\n` +
|
|
320
327
|
` interval: 10s\n timeout: 5s\n retries: 6\n start_period: 10s\n` +
|
|
321
|
-
|
|
328
|
+
// No release or rollout-state label: the gateway runs none of the release's
|
|
329
|
+
// code, and a label that changes with every release would make Compose
|
|
330
|
+
// recreate it, which closes the published port until the new one starts.
|
|
331
|
+
` labels:\n io.podokit.project: ${quote(profile.target.project)}\n`);
|
|
322
332
|
}
|
|
323
333
|
function renderComposeDocument(profile, release, images, stateDigest, proxyTimeouts, workerCommand = ["bun", "dist/main-worker.js"]) {
|
|
324
334
|
const environment = derivedRuntimeConfig(profile, proxyTimeouts);
|
|
@@ -339,7 +349,8 @@ function renderComposeDocument(profile, release, images, stateDigest, proxyTimeo
|
|
|
339
349
|
services.push(applicationService(profile, "api", images.api, release, stateDigest, environment, {
|
|
340
350
|
healthcheckPath: "/health/ready",
|
|
341
351
|
}));
|
|
342
|
-
|
|
352
|
+
const webEnvironment = { ...WEB_DNS_CONFIG, ...environment };
|
|
353
|
+
services.push(applicationService(profile, "web", images.web, release, stateDigest, webEnvironment, {
|
|
343
354
|
// The web server's own route: an API outage must not mark it unhealthy.
|
|
344
355
|
healthcheckPath: "/healthz",
|
|
345
356
|
publish: !hasGateway,
|
|
@@ -348,7 +359,7 @@ function renderComposeDocument(profile, release, images, stateDigest, proxyTimeo
|
|
|
348
359
|
if (!images.gateway) {
|
|
349
360
|
throw new Error("A resolved gateway image is required when WebSocket or API paths are configured.");
|
|
350
361
|
}
|
|
351
|
-
services.push(gatewayService(profile, images.gateway
|
|
362
|
+
services.push(gatewayService(profile, images.gateway));
|
|
352
363
|
}
|
|
353
364
|
if (profile.workloads.worker) {
|
|
354
365
|
// The worker runs the API image with a different entry point, exactly as the
|
package/dist/deploy-compose.js
CHANGED
|
@@ -63,32 +63,21 @@ function inspectComposeEndpointFingerprint(context, runner = deploy_compose_exec
|
|
|
63
63
|
return digest(["docker-endpoint", host, daemonId]);
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
66
|
+
* Runs a read-only shell command against an env file on the target host, as the
|
|
67
|
+
* identity that will actually deploy, and returns its output.
|
|
67
68
|
*
|
|
68
|
-
* The file never leaves the host
|
|
69
|
-
*
|
|
70
|
-
* Kubernetes doctor asking `kubectl` for key names rather than Secret contents.
|
|
69
|
+
* The file never leaves the host: over SSH the command reads it in place, and
|
|
70
|
+
* otherwise a throwaway container mounts it read-only.
|
|
71
71
|
*/
|
|
72
|
-
function
|
|
73
|
-
const script = `sed -n 's/^[[:space:]]*\\([A-Z][A-Z0-9_]*\\)=.*/\\1/p' /podokit-env | sort -u`;
|
|
74
|
-
// Read it as the identity that will actually deploy.
|
|
75
|
-
//
|
|
72
|
+
function readEnvFile(profile, path, image, runner, command) {
|
|
76
73
|
// A container running as root proves only that root can read the file, and
|
|
77
74
|
// `docker compose` on a remote target runs as the SSH user. Checking with the
|
|
78
75
|
// wrong identity is how a doctor passes and the apply then dies on
|
|
79
76
|
// "permission denied" -- which is exactly what happened.
|
|
80
77
|
const destination = (0, deploy_compose_exec_1.sshDestination)(profile, runner);
|
|
81
|
-
if (destination)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`sed -n 's/^[[:space:]]*\\([A-Z][A-Z0-9_]*\\)=.*/\\1/p' ${path} | sort -u`,
|
|
85
|
-
]);
|
|
86
|
-
return remote
|
|
87
|
-
.split("\n")
|
|
88
|
-
.map((line) => line.trim())
|
|
89
|
-
.filter(Boolean);
|
|
90
|
-
}
|
|
91
|
-
const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
|
|
78
|
+
if (destination)
|
|
79
|
+
return (0, deploy_compose_exec_1.checked)(runner, "ssh", [destination, command(path)]);
|
|
80
|
+
return (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
|
|
92
81
|
"run",
|
|
93
82
|
"--rm",
|
|
94
83
|
"--network",
|
|
@@ -104,13 +93,36 @@ function readEnvFileKeys(profile, path, image, runner) {
|
|
|
104
93
|
"sh",
|
|
105
94
|
image,
|
|
106
95
|
"-c",
|
|
107
|
-
|
|
96
|
+
command("/podokit-env"),
|
|
108
97
|
]));
|
|
109
|
-
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Reads key NAMES from an env file on the target host. No value is captured: the
|
|
101
|
+
* command prints the left-hand sides only, which mirrors the Kubernetes doctor
|
|
102
|
+
* asking `kubectl` for key names rather than Secret contents.
|
|
103
|
+
*/
|
|
104
|
+
function readEnvFileKeys(profile, path, image, runner) {
|
|
105
|
+
return readEnvFile(profile, path, image, runner, (file) => `sed -n 's/^[[:space:]]*\\([A-Z][A-Z0-9_]*\\)=.*/\\1/p' ${file} | sort -u`)
|
|
110
106
|
.split("\n")
|
|
111
107
|
.map((line) => line.trim())
|
|
112
108
|
.filter(Boolean);
|
|
113
109
|
}
|
|
110
|
+
// ADMIN_EMAILS is an allowlist of addresses, not a credential, so the doctor reads
|
|
111
|
+
// this one value of the API env file to catch a placeholder. Every other value stays unread.
|
|
112
|
+
function envFileAdminEmails(profile, image, runner) {
|
|
113
|
+
const path = profile.secrets.api.path;
|
|
114
|
+
try {
|
|
115
|
+
const lines = readEnvFile(profile, path, image, runner, (file) => `sed -n 's/^[[:space:]]*ADMIN_EMAILS=//p' ${file}`)
|
|
116
|
+
.split("\n")
|
|
117
|
+
.map((line) => line.trim())
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
// Docker Compose applies the last assignment of a key in an env file.
|
|
120
|
+
return (0, deploy_checks_1.adminEmailsFinding)(lines.at(-1), path);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return { code: "admin-emails", ok: false, message: `Cannot read ADMIN_EMAILS from ${path}: ${error.message}` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
114
126
|
function writeLedger(profile, image, ledger, runner) {
|
|
115
127
|
const encoded = Buffer.from(JSON.stringify(ledger)).toString("base64");
|
|
116
128
|
(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}`));
|
|
@@ -171,6 +183,30 @@ function managedDependencyServices(profile) {
|
|
|
171
183
|
: []),
|
|
172
184
|
];
|
|
173
185
|
}
|
|
186
|
+
/** The one-shot job that creates the bucket and the application user, when storage is managed. */
|
|
187
|
+
function objectStorageInit(profile) {
|
|
188
|
+
return profile.dependencies.objectStorage.mode === "managed"
|
|
189
|
+
? (0, deploy_compose_render_1.serviceName)(profile, "object-storage-init")
|
|
190
|
+
: null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Runs the object storage init job and fails unless it exits 0.
|
|
194
|
+
*
|
|
195
|
+
* It runs with the dependencies, not in the application rollout: new API
|
|
196
|
+
* containers start without their dependencies, so the bucket and the application
|
|
197
|
+
* user must be reconciled before they do. `up --wait` cannot run it, because it
|
|
198
|
+
* reports a listed service that exits as a failure even with status 0, and an
|
|
199
|
+
* attached `up` would print the job's output, which names the access key.
|
|
200
|
+
*/
|
|
201
|
+
function runObjectStorageInit(profile, runtime, service, runner) {
|
|
202
|
+
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--no-deps", service], runner, false);
|
|
203
|
+
for (const container of serviceContainers(profile, service, runner)) {
|
|
204
|
+
const status = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["wait", container.id])).trim();
|
|
205
|
+
if (status !== "0") {
|
|
206
|
+
throw new Error(`${service} exited with status ${status}; inspect it with docker logs ${container.id}.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
function doctorComposeDeployment(projectRoot, profileName, runner = deploy_compose_exec_1.defaultRunner, release) {
|
|
175
211
|
const profile = (0, deploy_driver_1.loadComposeProfile)(projectRoot, profileName);
|
|
176
212
|
const findings = (0, deploy_checks_1.profileFindings)(projectRoot, profile);
|
|
@@ -267,6 +303,8 @@ function doctorComposeDeployment(projectRoot, profileName, runner = deploy_compo
|
|
|
267
303
|
record("env-file-" + file.label, false, `Cannot read ${file.path}: ${error.message}`);
|
|
268
304
|
}
|
|
269
305
|
}
|
|
306
|
+
if ((0, deploy_checks_1.adminEmailsOnTarget)(projectRoot, profile))
|
|
307
|
+
findings.push(envFileAdminEmails(profile, probe, runner));
|
|
270
308
|
if (profile.secrets.registryLogin && release) {
|
|
271
309
|
const result = runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["run", "--rm", "--entrypoint", "true", probe]), { capture: true });
|
|
272
310
|
record("registry-access", result.status === 0, result.status === 0
|
|
@@ -359,12 +397,13 @@ function planComposeDeployment(projectRoot, profileName, release, runner = deplo
|
|
|
359
397
|
const psOutput = composePs(profile, runner);
|
|
360
398
|
const actions = [];
|
|
361
399
|
const dependencies = managedDependencyServices(profile);
|
|
400
|
+
const init = objectStorageInit(profile);
|
|
362
401
|
let order = 1;
|
|
363
402
|
if (dependencies.length) {
|
|
364
403
|
actions.push({
|
|
365
404
|
order: order++,
|
|
366
405
|
kind: "dependencies",
|
|
367
|
-
description: `Reconcile and wait for ${dependencies.join(", ")}.`,
|
|
406
|
+
description: `Reconcile and wait for ${dependencies.join(", ")}${init ? `, then run ${init}` : ""}.`,
|
|
368
407
|
});
|
|
369
408
|
}
|
|
370
409
|
actions.push({
|
|
@@ -376,7 +415,7 @@ function planComposeDeployment(projectRoot, profileName, release, runner = deplo
|
|
|
376
415
|
actions.push({
|
|
377
416
|
order: order++,
|
|
378
417
|
kind: "application",
|
|
379
|
-
description:
|
|
418
|
+
description: `${rolloutDescription(profile)}${(0, api_paths_1.directApiRoutes)(profile.exposure.apiPaths)}`,
|
|
380
419
|
});
|
|
381
420
|
actions.push({
|
|
382
421
|
order: order++,
|
|
@@ -418,6 +457,146 @@ function planComposeDeployment(projectRoot, profileName, release, runner = deplo
|
|
|
418
457
|
warnings,
|
|
419
458
|
};
|
|
420
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Which application services are replaced side by side.
|
|
462
|
+
*
|
|
463
|
+
* The API always is. Web is only behind the gateway: without one it publishes the
|
|
464
|
+
* host port itself, and a second container cannot bind it.
|
|
465
|
+
*/
|
|
466
|
+
function sideBySideServices(profile) {
|
|
467
|
+
return [
|
|
468
|
+
{ service: (0, deploy_compose_render_1.serviceName)(profile, "api"), replicas: profile.workloads.api.replicas },
|
|
469
|
+
...((0, deploy_compose_profile_1.composeHasGateway)(profile)
|
|
470
|
+
? [{ service: (0, deploy_compose_render_1.serviceName)(profile, "web"), replicas: profile.workloads.web.replicas }]
|
|
471
|
+
: []),
|
|
472
|
+
];
|
|
473
|
+
}
|
|
474
|
+
function rolloutDescription(profile) {
|
|
475
|
+
const replacedInPlace = [
|
|
476
|
+
...((0, deploy_compose_profile_1.composeHasGateway)(profile) ? [] : ["web"]),
|
|
477
|
+
...(profile.workloads.worker ? ["worker"] : []),
|
|
478
|
+
];
|
|
479
|
+
return (`Start the new ${(0, deploy_compose_profile_1.composeHasGateway)(profile) ? "api and web" : "api"} beside the running containers and wait for health, ` +
|
|
480
|
+
(replacedInPlace.length ? `replace ${replacedInPlace.join(" and ")} in place, ` : "") +
|
|
481
|
+
`then stop the old containers${(0, deploy_compose_profile_1.composeHasGateway)(profile) ? "; the gateway is replaced only when its definition changes" : ""}.`);
|
|
482
|
+
}
|
|
483
|
+
function serviceContainers(profile, service, runner) {
|
|
484
|
+
const output = (0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, [
|
|
485
|
+
"ps",
|
|
486
|
+
"-a",
|
|
487
|
+
"--filter",
|
|
488
|
+
`label=com.docker.compose.project=${profile.target.project}`,
|
|
489
|
+
"--filter",
|
|
490
|
+
`label=com.docker.compose.service=${service}`,
|
|
491
|
+
"--format",
|
|
492
|
+
'{{.ID}}\t{{.State}}\t{{.Label "com.docker.compose.config-hash"}}',
|
|
493
|
+
]));
|
|
494
|
+
return output
|
|
495
|
+
.split("\n")
|
|
496
|
+
.map((line) => line.trim().split("\t"))
|
|
497
|
+
.flatMap(([id, state, configHash]) => id ? [{ id, running: state === "running", configHash: configHash ?? "" }] : []);
|
|
498
|
+
}
|
|
499
|
+
/** The hash Compose stamps on a container of this service as the project file now defines it. */
|
|
500
|
+
function composeConfigHash(profile, runtime, service, runner) {
|
|
501
|
+
const output = (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["config", "--hash", service], runner, true);
|
|
502
|
+
const line = output.split("\n").find((entry) => entry.trim().startsWith(`${service} `));
|
|
503
|
+
const hash = line?.trim().split(/\s+/)[1];
|
|
504
|
+
if (!hash)
|
|
505
|
+
throw new Error(`docker compose config --hash did not report a hash for ${service}.`);
|
|
506
|
+
return hash;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Starts a service's new containers next to the running ones and waits for them.
|
|
510
|
+
*
|
|
511
|
+
* Compose recreates a changed service by stopping its container before it starts
|
|
512
|
+
* the replacement, and it stops every changed service before it starts any, so a
|
|
513
|
+
* single-host deployment had no API and no web server until the new ones passed
|
|
514
|
+
* their healthchecks. Scaling up with `--no-recreate` keeps the old containers
|
|
515
|
+
* serving while Compose creates new ones from the new definition, and both answer
|
|
516
|
+
* to the service's name on the project network until the old ones are stopped.
|
|
517
|
+
*
|
|
518
|
+
* A service with nothing running, or already running its new definition, is left
|
|
519
|
+
* to the final `compose up`: there is no traffic to keep, or nothing to replace.
|
|
520
|
+
*/
|
|
521
|
+
function startBeside(profile, runtime, service, replicas, runner) {
|
|
522
|
+
const before = serviceContainers(profile, service, runner);
|
|
523
|
+
const running = before.filter((container) => container.running);
|
|
524
|
+
if (!running.length)
|
|
525
|
+
return { service, replaced: [], started: [] };
|
|
526
|
+
const expected = composeConfigHash(profile, runtime, service, runner);
|
|
527
|
+
if (running.length === replicas && running.every((container) => container.configHash === expected)) {
|
|
528
|
+
return { service, replaced: [], started: [] };
|
|
529
|
+
}
|
|
530
|
+
// A stopped container serves nothing, and `up` would start it again with the
|
|
531
|
+
// old definition.
|
|
532
|
+
const stopped = before.filter((container) => !container.running).map((container) => container.id);
|
|
533
|
+
if (stopped.length)
|
|
534
|
+
(0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["rm", "-f", ...stopped]));
|
|
535
|
+
const replaced = running.map((container) => container.id);
|
|
536
|
+
try {
|
|
537
|
+
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", [
|
|
538
|
+
"up",
|
|
539
|
+
"-d",
|
|
540
|
+
"--no-deps",
|
|
541
|
+
"--no-recreate",
|
|
542
|
+
"--wait",
|
|
543
|
+
"--scale",
|
|
544
|
+
`${service}=${running.length + replicas}`,
|
|
545
|
+
service,
|
|
546
|
+
], runner, false);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
removeContainers(profile, startedSince(profile, service, replaced, runner), runner);
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
return { service, replaced, started: startedSince(profile, service, replaced, runner) };
|
|
553
|
+
}
|
|
554
|
+
function startedSince(profile, service, replaced, runner) {
|
|
555
|
+
return serviceContainers(profile, service, runner)
|
|
556
|
+
.map((container) => container.id)
|
|
557
|
+
.filter((id) => !replaced.includes(id));
|
|
558
|
+
}
|
|
559
|
+
function removeContainers(profile, ids, runner) {
|
|
560
|
+
if (ids.length)
|
|
561
|
+
runner("docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["rm", "-f", ...ids]), { capture: true });
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Replaces the application containers without leaving a moment with nothing to
|
|
565
|
+
* answer.
|
|
566
|
+
*
|
|
567
|
+
* The new API, and the new web server behind the gateway, start beside the running
|
|
568
|
+
* ones. Only when all of them are healthy do the old ones get a graceful stop, web
|
|
569
|
+
* before API so an old web server never outlives the API it was talking to. The
|
|
570
|
+
* worker, a web server that publishes the port itself, and a changed gateway are
|
|
571
|
+
* replaced in place by the final `compose up`, which also removes orphans. If a new
|
|
572
|
+
* container fails its healthcheck, the containers this rollout started are removed
|
|
573
|
+
* and the old ones keep serving.
|
|
574
|
+
*/
|
|
575
|
+
function rollOutApplication(profile, runtime, runner) {
|
|
576
|
+
const sideBySide = [];
|
|
577
|
+
try {
|
|
578
|
+
for (const { service, replicas } of sideBySideServices(profile)) {
|
|
579
|
+
sideBySide.push(startBeside(profile, runtime, service, replicas, runner));
|
|
580
|
+
}
|
|
581
|
+
if (!(0, deploy_compose_profile_1.composeHasGateway)(profile)) {
|
|
582
|
+
// `--remove-orphans`: a gateway left from a profile that had one still holds
|
|
583
|
+
// the port this web server publishes.
|
|
584
|
+
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--no-deps", "--wait", "--remove-orphans", (0, deploy_compose_render_1.serviceName)(profile, "web")], runner, false);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
for (const entry of sideBySide)
|
|
589
|
+
removeContainers(profile, entry.started, runner);
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
for (const entry of [...sideBySide].reverse()) {
|
|
593
|
+
if (!entry.replaced.length)
|
|
594
|
+
continue;
|
|
595
|
+
(0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["stop", ...entry.replaced]));
|
|
596
|
+
(0, deploy_compose_exec_1.checked)(runner, "docker", (0, deploy_compose_exec_1.dockerArgs)(profile, ["rm", ...entry.replaced]));
|
|
597
|
+
}
|
|
598
|
+
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", "--remove-orphans"], runner, false);
|
|
599
|
+
}
|
|
421
600
|
async function applyComposeDeployment(projectRoot, profileName, release, confirm, runner = deploy_compose_exec_1.defaultRunner, fetcher = fetch) {
|
|
422
601
|
const findings = doctorComposeDeployment(projectRoot, profileName, runner, release);
|
|
423
602
|
const failed = findings.filter((finding) => !finding.ok);
|
|
@@ -455,14 +634,19 @@ async function applyComposeDeployment(projectRoot, profileName, release, confirm
|
|
|
455
634
|
const dependencies = managedDependencyServices(profile);
|
|
456
635
|
if (dependencies.length) {
|
|
457
636
|
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", ...dependencies], runner, false);
|
|
637
|
+
const init = objectStorageInit(profile);
|
|
638
|
+
if (init)
|
|
639
|
+
runObjectStorageInit(profile, runtime, init, runner);
|
|
458
640
|
}
|
|
459
641
|
else {
|
|
460
642
|
// The application network still has to exist before the migration project,
|
|
461
|
-
// which joins it as an external network, can start.
|
|
462
|
-
|
|
643
|
+
// which joins it as an external network, can start. `--no-recreate` keeps
|
|
644
|
+
// this from stopping the running containers of a changed service before the
|
|
645
|
+
// migration has even run.
|
|
646
|
+
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--no-start", "--no-recreate"], runner, false);
|
|
463
647
|
}
|
|
464
648
|
(0, deploy_compose_exec_1.runCompose)(profile, runtime, "migration", ["run", "--rm", "--no-deps", "migrate"], runner, false);
|
|
465
|
-
(
|
|
649
|
+
rollOutApplication(profile, runtime, runner);
|
|
466
650
|
const ledger = (0, deploy_compose_exec_1.readLedger)(profile, plan.images.api, runner);
|
|
467
651
|
ledger.entries.push({
|
|
468
652
|
revision: ((0, deploy_compose_exec_1.currentLedgerEntry)(ledger)?.revision ?? 0) + 1,
|
|
@@ -652,7 +836,7 @@ async function rollbackComposeDeployment(projectRoot, profileName, revision, con
|
|
|
652
836
|
acquireComposeLock(profile, preflight.images.api, holder, runner);
|
|
653
837
|
try {
|
|
654
838
|
const runtime = (0, deploy_compose_render_1.renderComposeDeployment)(projectRoot, profileName, profile, preflight.release, preflight.images, preflight.rolloutStateDigest);
|
|
655
|
-
(
|
|
839
|
+
rollOutApplication(profile, runtime, runner);
|
|
656
840
|
const ledger = (0, deploy_compose_exec_1.readLedger)(profile, preflight.images.api, runner);
|
|
657
841
|
ledger.entries.push({
|
|
658
842
|
revision: ((0, deploy_compose_exec_1.currentLedgerEntry)(ledger)?.revision ?? 0) + 1,
|
package/dist/deploy.js
CHANGED
|
@@ -100,6 +100,20 @@ function secretKeys(profile, secretName, runner) {
|
|
|
100
100
|
.filter(Boolean)
|
|
101
101
|
.sort();
|
|
102
102
|
}
|
|
103
|
+
// ADMIN_EMAILS is an allowlist of addresses, not a credential, so the doctor reads
|
|
104
|
+
// this one value of the API Secret to catch a placeholder. Every other value stays unread.
|
|
105
|
+
function secretAdminEmails(profile, runner) {
|
|
106
|
+
const name = profile.secrets.api.name;
|
|
107
|
+
const result = runner("kubectl", kubectlArgs(profile, ["get", "secret", name, "-o", 'go-template={{with index .data "ADMIN_EMAILS"}}{{.}}{{end}}']), { capture: true });
|
|
108
|
+
if (result.status !== 0) {
|
|
109
|
+
return {
|
|
110
|
+
code: "admin-emails",
|
|
111
|
+
ok: false,
|
|
112
|
+
message: `Cannot read ADMIN_EMAILS from Secret ${name}: ${result.stderr.trim() || "the Secret is missing or inaccessible"}.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return (0, deploy_checks_1.adminEmailsFinding)(Buffer.from(result.stdout.trim(), "base64").toString("utf8"), `Secret ${name}`);
|
|
116
|
+
}
|
|
103
117
|
function dependencySecretRequirements(profile) {
|
|
104
118
|
const requirements = [];
|
|
105
119
|
if (profile.dependencies.postgres.mode === "inCluster") {
|
|
@@ -270,6 +284,8 @@ function doctorDeployment(projectRoot, profileName, runner = defaultRunner) {
|
|
|
270
284
|
: `Secret ${requirement.name} contains the required key names.`,
|
|
271
285
|
});
|
|
272
286
|
}
|
|
287
|
+
if ((0, deploy_checks_1.adminEmailsOnTarget)(projectRoot, profile))
|
|
288
|
+
findings.push(secretAdminEmails(profile, runner));
|
|
273
289
|
const imagePullType = runner("kubectl", kubectlArgs(profile, [
|
|
274
290
|
"get",
|
|
275
291
|
"secret",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { createSocket } from "node:dgram";
|
|
4
|
+
import { Resolver } from "node:dns/promises";
|
|
3
5
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
6
|
import { connect, createServer, type AddressInfo } from "node:net";
|
|
5
7
|
import { tmpdir } from "node:os";
|
|
@@ -62,6 +64,45 @@ function probedSql(): ProbedSql {
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
interface LocalDns {
|
|
68
|
+
resolver: Resolver;
|
|
69
|
+
close: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A resolver that asks a DNS server on a local UDP port instead of the system's,
|
|
73
|
+
// whose answers depend on the machine. The server answers every query with
|
|
74
|
+
// NXDOMAIN, or never answers when `answers` is false.
|
|
75
|
+
async function localDns(answers: boolean): Promise<LocalDns> {
|
|
76
|
+
const server = createSocket("udp4");
|
|
77
|
+
if (answers) {
|
|
78
|
+
server.on("message", (query, peer) => {
|
|
79
|
+
// The header and the question, flagged as a response with RCODE 3 and no records.
|
|
80
|
+
let end = 12;
|
|
81
|
+
while (end < query.length && query[end] !== 0) end += (query[end] ?? 0) + 1;
|
|
82
|
+
const response = Buffer.from(query.subarray(0, end + 5));
|
|
83
|
+
response[2] = 0x80 | ((query[2] ?? 0) & 0x01);
|
|
84
|
+
response[3] = 0x83;
|
|
85
|
+
response.writeUInt16BE(1, 4);
|
|
86
|
+
response.fill(0, 6, 12);
|
|
87
|
+
server.send(response, peer.port, peer.address);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
|
91
|
+
// Bun waits at least a second for an answer; a single try keeps that the whole wait.
|
|
92
|
+
const resolver = new Resolver({ timeout: 200, tries: 1 });
|
|
93
|
+
resolver.setServers([`127.0.0.1:${server.address().port}`]);
|
|
94
|
+
return { resolver, close: () => server.close() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function lookupError(answers: boolean): Promise<unknown> {
|
|
98
|
+
const dns = await localDns(answers);
|
|
99
|
+
try {
|
|
100
|
+
return await dns.resolver.resolve4("database.invalid").then(() => undefined, (reason: unknown) => reason);
|
|
101
|
+
} finally {
|
|
102
|
+
dns.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
describe("database provider", () => {
|
|
66
107
|
it("uses DATABASE_URL as the canonical connection setting", () => {
|
|
67
108
|
const provider = databaseProvider();
|
|
@@ -266,11 +307,16 @@ describe("isDatabaseUnavailable", () => {
|
|
|
266
307
|
});
|
|
267
308
|
|
|
268
309
|
it("recognizes a real lookup of a host that does not exist", async () => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
|
|
310
|
+
const error = await lookupError(true);
|
|
311
|
+
expect((error as { code?: unknown }).code).toBe("ENOTFOUND");
|
|
272
312
|
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
273
|
-
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("recognizes a real lookup that the resolver never answers", async () => {
|
|
316
|
+
const error = await lookupError(false);
|
|
317
|
+
expect((error as { code?: unknown }).code).toBe("ETIMEOUT");
|
|
318
|
+
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
319
|
+
});
|
|
274
320
|
|
|
275
321
|
it("recognizes a real refused connection to localhost", async () => {
|
|
276
322
|
const server = createServer();
|