@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.
Files changed (28) hide show
  1. package/dist/deploy-checks.d.ts +20 -2
  2. package/dist/deploy-checks.js +83 -4
  3. package/dist/deploy-compose-render.js +15 -4
  4. package/dist/deploy-compose.js +211 -27
  5. package/dist/deploy.js +16 -0
  6. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  7. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
  8. package/dist/templates/fullstack/tests/README.md +11 -0
  9. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
  10. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
  11. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  12. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  13. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
  14. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  15. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  19. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
  20. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
  22. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
  23. package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
  24. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
  25. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
  26. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
  27. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
  28. package/package.json +1 -1
@@ -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 stops a container before it starts the
49
- * replacement, so it never runs both.
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;
@@ -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 stops a container before it starts the
118
- * replacement, so it never runs both.
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" ? 2 * steadyProcesses : steadyProcesses;
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 replaced pods that have not exited, hold ${budget.rollout}`
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, release, stateDigest) {
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
- labelsBlock(profile, release, stateDigest, " "));
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
- services.push(applicationService(profile, "web", images.web, release, stateDigest, environment, {
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, release, stateDigest));
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
@@ -63,32 +63,21 @@ function inspectComposeEndpointFingerprint(context, runner = deploy_compose_exec
63
63
  return digest(["docker-endpoint", host, daemonId]);
64
64
  }
65
65
  /**
66
- * Reads key NAMES from an env file on the target host.
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 and no value is captured: a throwaway container
69
- * mounts it read-only and prints the left-hand sides only. This mirrors the
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 readEnvFileKeys(profile, path, image, runner) {
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
- const remote = (0, deploy_compose_exec_1.checked)(runner, "ssh", [
83
- destination,
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
- script,
96
+ command("/podokit-env"),
108
97
  ]));
109
- return output
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: `Roll out api, web${profile.workloads.worker ? ", worker" : ""}${(0, deploy_compose_profile_1.composeHasGateway)(profile) ? ", gateway" : ""} and wait for health.${(0, api_paths_1.directApiRoutes)(profile.exposure.apiPaths)}`,
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
- (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--no-start"], runner, false);
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
- (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", "--remove-orphans"], runner, false);
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
- (0, deploy_compose_exec_1.runCompose)(profile, runtime, "compose", ["up", "-d", "--wait", "--remove-orphans"], runner, false);
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
- // .invalid never resolves (RFC 2606). A resolver that does not answer at all
270
- // fails with ETIMEOUT after about 16 seconds, which is recognized too.
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
- }, 30_000);
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();