@podosoft/podokit 1.6.0 → 1.6.2

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 (50) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +17 -9
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy-schema.d.ts +7 -3
  5. package/dist/deploy-schema.js +16 -5
  6. package/dist/deploy.d.ts +9 -0
  7. package/dist/deploy.js +83 -17
  8. package/dist/index.js +9 -2
  9. package/dist/templates/fullstack/AGENTS.md +3 -1
  10. package/dist/templates/fullstack/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
  12. package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
  13. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
  15. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  16. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +5 -2
  17. package/dist/templates/fullstack/tests/README.md +20 -10
  18. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  19. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  20. package/dist/templates/fullstack/tests/package.json +3 -3
  21. package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
  22. package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
  23. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  27. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  28. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  29. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
  30. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
  31. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
  32. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
  33. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
  34. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  35. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  36. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  37. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  38. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  39. package/dist/templates/modules/auth/module.manifest.json +1 -0
  40. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
  41. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
  42. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
  43. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
  44. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  45. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  46. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
  47. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  48. package/dist/update.d.ts +25 -5
  49. package/dist/update.js +86 -10
  50. package/package.json +2 -2
@@ -24,7 +24,10 @@ export interface ConnectionBudget {
24
24
  perProcessTerms: string[];
25
25
  steadyProcesses: number;
26
26
  steady: number;
27
- /** Processes and connections while a rollout runs old and new pods side by side. */
27
+ /**
28
+ * Processes and connections while a rollout runs old and new pods side by side,
29
+ * counting replaced pods that still hold their pools while they terminate.
30
+ */
28
31
  rolloutProcesses: number;
29
32
  rollout: number;
30
33
  migration: number;
@@ -32,10 +35,18 @@ export interface ConnectionBudget {
32
35
  }
33
36
  /**
34
37
  * The most PostgreSQL connections the deployment can open: every API and worker
35
- * process with each pool full, the Kubernetes rollout's surge pod per workload
36
- * (the rendered strategy allows one), and the migration that runs beside the
37
- * release still serving. Compose replaces containers without a surge. Null when
38
- * the deployment uses no PostgreSQL.
38
+ * process with each pool full, a Kubernetes rollout's old and new pods together,
39
+ * and the migration that runs beside the release still serving. Null when the
40
+ * deployment uses no PostgreSQL.
41
+ *
42
+ * A replaced pod keeps its pools until its process exits, up to its grace period
43
+ * after the scale-down (the preStop sleep plus the drain for API pods), and the
44
+ * Deployment controller does not count terminating pods against maxSurge: it
45
+ * starts the next surge pod as soon as the previous replacement is ready. When
46
+ * replacements become ready faster than replaced pods exit, every old pod of a
47
+ * 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.
39
50
  */
40
51
  export declare function connectionBudget(profile: AnyDeployProfile, modules: Set<string>): ConnectionBudget | null;
41
52
  /** The plan warning for a budget over the limit, or null. */
@@ -104,10 +104,18 @@ function poolSize(runtimeConfig, key, fallback) {
104
104
  }
105
105
  /**
106
106
  * The most PostgreSQL connections the deployment can open: every API and worker
107
- * process with each pool full, the Kubernetes rollout's surge pod per workload
108
- * (the rendered strategy allows one), and the migration that runs beside the
109
- * release still serving. Compose replaces containers without a surge. Null when
110
- * the deployment uses no PostgreSQL.
107
+ * process with each pool full, a Kubernetes rollout's old and new pods together,
108
+ * and the migration that runs beside the release still serving. Null when the
109
+ * deployment uses no PostgreSQL.
110
+ *
111
+ * A replaced pod keeps its pools until its process exits, up to its grace period
112
+ * after the scale-down (the preStop sleep plus the drain for API pods), and the
113
+ * Deployment controller does not count terminating pods against maxSurge: it
114
+ * starts the next surge pod as soon as the previous replacement is ready. When
115
+ * replacements become ready faster than replaced pods exit, every old pod of a
116
+ * 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.
111
119
  */
112
120
  function connectionBudget(profile, modules) {
113
121
  const postgres = profile.dependencies.postgres;
@@ -123,14 +131,14 @@ function connectionBudget(profile, modules) {
123
131
  perProcessTerms.push(`DB_AUTH_POOL_MAX ${authPool}`);
124
132
  perProcess += authPool;
125
133
  }
126
- for (const pool of postgres.appPools ?? []) {
127
- const size = poolSize(config, pool.sizeKey, 0);
128
- perProcessTerms.push(`${pool.sizeKey} ${size}${pool.extraConnections ? ` + ${pool.extraConnections}` : ""}`);
134
+ for (const [index, pool] of (postgres.appPools ?? []).entries()) {
135
+ const [label, size] = "sizeKey" in pool ? [pool.sizeKey, poolSize(config, pool.sizeKey, 0)] : [`appPools[${index}]`, pool.size];
136
+ perProcessTerms.push(`${label} ${size}${pool.extraConnections ? ` + ${pool.extraConnections}` : ""}`);
129
137
  perProcess += size + pool.extraConnections;
130
138
  }
131
139
  const worker = profile.workloads.worker;
132
140
  const steadyProcesses = profile.workloads.api.replicas + (worker?.replicas ?? 0);
133
- const rolloutProcesses = steadyProcesses + (profile.driver === "kubernetes-helm" ? 1 + (worker ? 1 : 0) : 0);
141
+ const rolloutProcesses = profile.driver === "kubernetes-helm" ? 2 * steadyProcesses : steadyProcesses;
134
142
  const maxConnections = postgres.maxConnections ?? exports.DEFAULT_MAX_CONNECTIONS;
135
143
  const steady = steadyProcesses * perProcess;
136
144
  const rollout = rolloutProcesses * perProcess;
@@ -151,7 +159,7 @@ function budgetSummary(budget) {
151
159
  return (`each API or worker process holds up to ${budget.perProcess} (${budget.perProcessTerms.join(" + ")}); ` +
152
160
  `${budget.steadyProcesses} process(es) hold ${budget.steady}` +
153
161
  (budget.rolloutProcesses > budget.steadyProcesses
154
- ? `, ${budget.rolloutProcesses} during a rollout hold ${budget.rollout}`
162
+ ? `, ${budget.rolloutProcesses} during a rollout, counting replaced pods that have not exited, hold ${budget.rollout}`
155
163
  : "") +
156
164
  `, and the migration adds ${budget.migration} to the ${budget.steady}`);
157
165
  }
@@ -396,7 +396,27 @@ ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(va
396
396
  // of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
397
397
  // web server.
398
398
  const PROBE_TIMEOUT_SECONDS = 2;
399
- function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
399
+ // A terminating pod still receives new connections until every node and the
400
+ // ingress controller drop it from the Service's endpoints, while the API and web
401
+ // servers stop accepting them on SIGTERM. The kubelet runs this sleep before it
402
+ // sends SIGTERM, so the pod keeps serving meanwhile. PodoKit's API and web images
403
+ // are Alpine-based and ship /bin/sleep; an exec hook works on every Kubernetes
404
+ // version, unlike the sleep action.
405
+ const PRE_STOP_SLEEP_SECONDS = 5;
406
+ // The web server drains for SHUTDOWN_TIMEOUT seconds (30 unless set) and then
407
+ // force-closes for up to one more. The API's stop waits for in-flight requests
408
+ // without a bound of its own, so it gets the same window, which covers the /api
409
+ // proxy's default 30 s wait. The grace period also counts the preStop sleep.
410
+ const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30;
411
+ const SHUTDOWN_MARGIN_SECONDS = 5;
412
+ function terminationGracePeriodSeconds(runtimeConfig) {
413
+ const configured = runtimeConfig.SHUTDOWN_TIMEOUT;
414
+ const drain = configured !== undefined && /^\d+$/.test(configured)
415
+ ? Number(configured)
416
+ : DEFAULT_SHUTDOWN_TIMEOUT_SECONDS;
417
+ return PRE_STOP_SLEEP_SECONDS + drain + SHUTDOWN_MARGIN_SECONDS;
418
+ }
419
+ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds) {
400
420
  const name = `${profile.target.release}-${kind}`;
401
421
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
402
422
  const secretEnvFrom = workloadSecret
@@ -456,6 +476,7 @@ ${labels(name, 8)} spec:
456
476
  runAsUser: 100
457
477
  runAsGroup: 101
458
478
  seccompProfile: { type: RuntimeDefault }
479
+ terminationGracePeriodSeconds: ${gracePeriodSeconds}
459
480
  containers:
460
481
  - name: ${kind}
461
482
  image: ${quote(image)}
@@ -467,6 +488,9 @@ ${labels(name, 8)} spec:
467
488
  - configMapRef: { name: ${profile.target.release}-runtime }
468
489
  ${secretEnvFrom} ports:
469
490
  - { name: http, containerPort: 3000 }
491
+ lifecycle:
492
+ preStop:
493
+ exec: { command: [sleep, "${PRE_STOP_SLEEP_SECONDS}"] }
470
494
  ${probe}${resources(workload.resources)}---
471
495
  apiVersion: v1
472
496
  kind: Service
@@ -634,10 +658,11 @@ ${apiPathIngress(profile, tls)}`;
634
658
  }
635
659
  function applicationManifest(profile, images, rolloutStateDigest, workerCommand, runtimeConfig) {
636
660
  const runtimeConfigDigest = digest(["runtime-config", JSON.stringify(sortedRuntimeConfig(runtimeConfig))]);
661
+ const gracePeriodSeconds = terminationGracePeriodSeconds(runtimeConfig);
637
662
  return [
638
663
  configMap(profile, runtimeConfig),
639
- deployment(profile, "api", profile.workloads.api, images.api, rolloutStateDigest, runtimeConfigDigest),
640
- deployment(profile, "web", profile.workloads.web, images.web, rolloutStateDigest, runtimeConfigDigest),
664
+ deployment(profile, "api", profile.workloads.api, images.api, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds),
665
+ deployment(profile, "web", profile.workloads.web, images.web, rolloutStateDigest, runtimeConfigDigest, gracePeriodSeconds),
641
666
  workerDeployment(profile, images.api, rolloutStateDigest, runtimeConfigDigest, workerCommand),
642
667
  disruptionBudget(profile, "api"),
643
668
  disruptionBudget(profile, "web"),
@@ -70,14 +70,18 @@ export declare function parseReleaseProfile(release: Record<string, unknown>): R
70
70
  export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
71
71
  /**
72
72
  * An application-owned PostgreSQL pool each API or worker process opens beside
73
- * PodoKit's own: `sizeKey` names the runtimeConfig key that caps it, and
73
+ * PodoKit's own. Either `sizeKey` names the runtimeConfig key that caps it, or
74
+ * `size` is the cap the application's code sets, such as a driver's default.
74
75
  * `extraConnections` counts connections it opens beyond that cap, such as one to
75
76
  * check a server that stopped answering.
76
77
  */
77
- export interface AppConnectionPool {
78
+ export type AppConnectionPool = {
78
79
  sizeKey: string;
79
80
  extraConnections: number;
80
- }
81
+ } | {
82
+ size: number;
83
+ extraConnections: number;
84
+ };
81
85
  /** What the connection budget needs to know about the database, beyond its mode. */
82
86
  export interface PostgresBudgetProfile {
83
87
  /** The server's max_connections; PodoKit sets it on a database it runs. */
@@ -270,7 +270,22 @@ function parsePostgresBudget(value, field, runtimeConfig) {
270
270
  const entryField = `${field}.appPools[${index}]`;
271
271
  if (!isRecord(entry))
272
272
  throw new Error(`Deployment profile field "${entryField}" must be an object.`);
273
- assertOnlyKeys(entry, ["sizeKey", "extraConnections"], entryField);
273
+ assertOnlyKeys(entry, ["sizeKey", "size", "extraConnections"], entryField);
274
+ if ((entry.sizeKey === undefined) === (entry.size === undefined)) {
275
+ throw new Error(`Deployment profile field "${entryField}" must set either sizeKey, the runtimeConfig key that caps the pool, ` +
276
+ "or size, the cap the application sets itself.");
277
+ }
278
+ const extraConnections = entry.extraConnections ?? 0;
279
+ if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
280
+ throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
281
+ }
282
+ if (entry.size !== undefined) {
283
+ const size = entry.size;
284
+ if (typeof size !== "number" || !Number.isInteger(size) || size < 1 || size > MAX_POSTGRES_CONNECTIONS) {
285
+ throw new Error(`Deployment profile field "${entryField}.size" must be an integer from 1 to ${MAX_POSTGRES_CONNECTIONS}.`);
286
+ }
287
+ return { size, extraConnections };
288
+ }
274
289
  const sizeKey = requiredString(entry, "sizeKey");
275
290
  if (!/^[A-Z][A-Z0-9_]*$/.test(sizeKey) || PODOKIT_POOL_KEYS.includes(sizeKey) || seen.has(sizeKey)) {
276
291
  throw new Error(`Deployment profile field "${entryField}.sizeKey" must name another pool's runtimeConfig key, once; ` +
@@ -280,10 +295,6 @@ function parsePostgresBudget(value, field, runtimeConfig) {
280
295
  if (!/^[1-9][0-9]*$/.test(runtimeConfig[sizeKey] ?? "")) {
281
296
  throw new Error(`Deployment profile field "${entryField}.sizeKey" names ${sizeKey}, which runtimeConfig must set to a positive integer.`);
282
297
  }
283
- const extraConnections = entry.extraConnections ?? 0;
284
- if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
285
- throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
286
- }
287
298
  return { sizeKey, extraConnections };
288
299
  });
289
300
  }
package/dist/deploy.d.ts CHANGED
@@ -38,7 +38,16 @@ export interface DeploymentStatus {
38
38
  readyReplicas: number;
39
39
  replicas: number;
40
40
  images: string[];
41
+ /** Container restarts of the running pods of the Deployment's current revision. */
41
42
  restartCount: number;
43
+ /**
44
+ * Pods still listed that are not current: from an earlier revision, or
45
+ * terminating. They linger for the termination grace period after a rollout.
46
+ */
47
+ previousPods: {
48
+ count: number;
49
+ restartCount: number;
50
+ };
42
51
  }>;
43
52
  }
44
53
  export interface VerificationResult {
package/dist/deploy.js CHANGED
@@ -423,15 +423,86 @@ function parseDeployment(output, workloadName) {
423
423
  }
424
424
  return deployment;
425
425
  }
426
- function restartCount(output, workloadName) {
427
- const values = output
428
- .split(/\s+/)
429
- .map((value) => value.trim())
430
- .filter(Boolean);
431
- if (values.some((value) => !/^(0|[1-9][0-9]*)$/.test(value))) {
432
- throw new Error(`Kubernetes pod restart counts for ${workloadName} are invalid.`);
426
+ const REVISION_ANNOTATION = "deployment.kubernetes.io/revision";
427
+ const POD_TEMPLATE_HASH_LABEL = "pod-template-hash";
428
+ function deploymentRevision(deployment, workloadName) {
429
+ const revision = deployment.metadata?.annotations?.[REVISION_ANNOTATION];
430
+ if (typeof revision !== "string" || !/^[1-9][0-9]*$/.test(revision)) {
431
+ throw new Error(`Kubernetes Deployment ${workloadName} has no valid ${REVISION_ANNOTATION} annotation.`);
433
432
  }
434
- return values.reduce((sum, value) => sum + Number(value), 0);
433
+ return revision;
434
+ }
435
+ function kubernetesListItems(output, description) {
436
+ const list = parseJson(output, description);
437
+ if (!isRecord(list) || !Array.isArray(list.items)) {
438
+ throw new Error(`${description} returned an invalid list.`);
439
+ }
440
+ return list.items;
441
+ }
442
+ function optionalRecord(value) {
443
+ return value === undefined || isRecord(value);
444
+ }
445
+ /** The pod-template-hash of the ReplicaSet that carries the Deployment's revision. */
446
+ function currentPodTemplateHash(output, workloadName, revision) {
447
+ const description = `Kubernetes ReplicaSets of ${workloadName}`;
448
+ const matching = kubernetesListItems(output, description).map((item) => {
449
+ if (!isRecord(item) ||
450
+ !isRecord(item.metadata) ||
451
+ !optionalRecord(item.metadata.annotations) ||
452
+ !optionalRecord(item.metadata.labels)) {
453
+ throw new Error(`${description} have an invalid schema.`);
454
+ }
455
+ return {
456
+ revision: item.metadata.annotations?.[REVISION_ANNOTATION],
457
+ hash: item.metadata.labels?.[POD_TEMPLATE_HASH_LABEL],
458
+ };
459
+ }).filter((replicaSet) => replicaSet.revision === revision);
460
+ if (matching.length !== 1) {
461
+ throw new Error(`Kubernetes Deployment ${workloadName} has ${matching.length} ReplicaSets for revision ${revision}; expected one.`);
462
+ }
463
+ const hash = matching[0].hash;
464
+ if (typeof hash !== "string" || hash.length === 0) {
465
+ throw new Error(`${description}: revision ${revision} has no ${POD_TEMPLATE_HASH_LABEL} label.`);
466
+ }
467
+ return hash;
468
+ }
469
+ /**
470
+ * Restarts of the current revision's running pods, and of the rest separately.
471
+ * Pods of every revision share the workload label, and an earlier ReplicaSet's
472
+ * pods stay listed with a deletionTimestamp until they finish terminating.
473
+ */
474
+ function podRestarts(output, workloadName, currentHash) {
475
+ const description = `Kubernetes pods of ${workloadName}`;
476
+ const result = { restartCount: 0, previousPods: { count: 0, restartCount: 0 } };
477
+ for (const pod of kubernetesListItems(output, description)) {
478
+ if (!isRecord(pod) ||
479
+ !isRecord(pod.metadata) ||
480
+ !optionalRecord(pod.metadata.labels) ||
481
+ (pod.status !== undefined && !isRecord(pod.status))) {
482
+ throw new Error(`${description} have an invalid schema.`);
483
+ }
484
+ // A pod that has not started a container yet reports no statuses.
485
+ const statuses = pod.status?.containerStatuses ?? [];
486
+ if (!Array.isArray(statuses))
487
+ throw new Error(`${description} have an invalid schema.`);
488
+ let restarts = 0;
489
+ for (const status of statuses) {
490
+ const count = isRecord(status) ? status.restartCount : undefined;
491
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) {
492
+ throw new Error(`Kubernetes pod restart counts for ${workloadName} are invalid.`);
493
+ }
494
+ restarts += count;
495
+ }
496
+ const terminating = pod.metadata.deletionTimestamp !== undefined && pod.metadata.deletionTimestamp !== null;
497
+ if (!terminating && pod.metadata.labels?.[POD_TEMPLATE_HASH_LABEL] === currentHash) {
498
+ result.restartCount += restarts;
499
+ }
500
+ else {
501
+ result.previousPods.count += 1;
502
+ result.previousPods.restartCount += restarts;
503
+ }
504
+ }
505
+ return result;
435
506
  }
436
507
  function planDigest(input, runtime) {
437
508
  return (0, node_crypto_1.createHash)("sha256")
@@ -867,14 +938,9 @@ function getDeploymentStatus(projectRoot, profileName, runner = defaultRunner) {
867
938
  });
868
939
  const deployments = items.map((item) => {
869
940
  const name = item.metadata.name;
870
- const observedRestartCount = restartCount(checked(runner, "kubectl", kubectlArgs(profile, [
871
- "get",
872
- "pods",
873
- "-l",
874
- `app.kubernetes.io/name=${name}`,
875
- "-o",
876
- "jsonpath={range .items[*].status.containerStatuses[*]}{.restartCount}{\"\\n\"}{end}",
877
- ])), name);
941
+ const selector = `app.kubernetes.io/name=${name}`;
942
+ const currentHash = currentPodTemplateHash(checked(runner, "kubectl", kubectlArgs(profile, ["get", "replicasets", "-l", selector, "-o", "json"])), name, deploymentRevision(item, name));
943
+ const restarts = podRestarts(checked(runner, "kubectl", kubectlArgs(profile, ["get", "pods", "-l", selector, "-o", "json"])), name, currentHash);
878
944
  return {
879
945
  name,
880
946
  readyReplicas: item.status?.readyReplicas ?? 0,
@@ -882,7 +948,7 @@ function getDeploymentStatus(projectRoot, profileName, runner = defaultRunner) {
882
948
  images: (item.spec?.template?.spec?.containers ?? [])
883
949
  .map((container) => container.image)
884
950
  .filter((image) => typeof image === "string"),
885
- restartCount: observedRestartCount,
951
+ ...restarts,
886
952
  };
887
953
  });
888
954
  return {
package/dist/index.js CHANGED
@@ -716,7 +716,8 @@ async function main(argv) {
716
716
  }
717
717
  process.stdout.write(`Applied: ${result.written.length} written, ${result.moved.length} moved, ` +
718
718
  `${result.removed.length} removed, ` +
719
- `${result.merged.length} merged, ${result.conflicts.length} conflict.\n`);
719
+ `${result.merged.length} merged, ${result.conflicts.length} conflict, ` +
720
+ `${result.skipped.length} skipped.\n`);
720
721
  writeApiPaths("Direct API paths:", result.apiPaths, "add");
721
722
  if (result.conflicts.length) {
722
723
  process.stdout.write("\nResolve the following, then commit:\n" +
@@ -729,6 +730,11 @@ async function main(argv) {
729
730
  result.review.map((f) => ` ${f}`).join("\n") +
730
731
  "\n");
731
732
  }
733
+ if (result.skipped.length) {
734
+ process.stdout.write("\nSkipped edited files, left as they are:\n" +
735
+ result.skipped.map((file) => ` ${file.path} (${file.reason})`).join("\n") +
736
+ "\n");
737
+ }
732
738
  return;
733
739
  }
734
740
  const plan = (0, update_1.planUpdate)(process.cwd(), templatesDir);
@@ -759,7 +765,8 @@ async function main(argv) {
759
765
  }
760
766
  process.stdout.write(`${shown.length ? "\n" : ""}${counts.update} update, ${counts.add} add, ${counts.move} move, ` +
761
767
  `${counts.remove} remove, ${counts.conflict} conflict, ${counts.review} review. ` +
762
- `Dry-run — nothing was written. Re-run with --apply to write (use --from <dir> for a 3-way merge).\n`);
768
+ `Dry-run — nothing was written. Re-run with --apply to write; it 3-way merges each conflict ` +
769
+ `against the ${plan.fromVersion} templates, fetched from npm unless --from <dir> gives them.\n`);
763
770
  }
764
771
  writeOwnedTemplateNotes(plan, templatesDir, args.from);
765
772
  }
@@ -105,7 +105,9 @@ Merge added module settings from `.env.example` into an existing `.env`.
105
105
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
106
106
  workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
107
107
  it changes state other tests rely on (a server-wide setting, a seeded account's profile);
108
- tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
108
+ tagged tests run alone after the rest, in a second Playwright run that `test:e2e` starts
109
+ even when the first one failed, so run the suite through `test:e2e`, not
110
+ `bunx playwright test`, on more workers. Give each test its own mail and SMS recipients and
109
111
  never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`. The managed specs
110
112
  read the app's first screen, public pages, and seeded accounts from the owned
111
113
  `tests/helpers/app-paths.ts` (and `app-accounts.ts` with admin-dashboard): change those
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@podosoft/podokit-contracts": "^0.6.0",
21
- "@podosoft/podokit-runtime": "^0.5.0",
21
+ "@podosoft/podokit-runtime": "^0.5.1",
22
22
  "@elysia/cors": "^1.4.2",
23
23
  "@elysia/openapi": "^1.4.15",
24
24
  "dotenv": "^16.4.5",
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createApp } from "../src/app";
4
4
  import { validateEnv } from "../src/config/env.validation";
5
+ import { PROVIDERS } from "../src/config/providers";
5
6
  import {
6
7
  assertApiContract,
7
8
  assertApplicationRoutes,
@@ -30,7 +31,7 @@ try {
30
31
  }
31
32
  const document: unknown = await response.json();
32
33
  const modules = manifest.modules.map((module) => module.name);
33
- assertApiContract(document, manifest.template, modules);
34
+ assertApiContract(document, manifest.template, modules, PROVIDERS);
34
35
  assertApplicationRoutes(document, app.routes, APP_DOCUMENTED_ROUTES);
35
36
  process.stdout.write(
36
37
  `Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}, ` +
@@ -4,14 +4,17 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  const BUILD = join(__dirname, "../scripts/build.mjs");
7
- const EXTENSIONS = join(__dirname, "../scripts/build.extensions.mjs");
7
+ // The application's own build.extensions.mjs names entry points this temporary
8
+ // layout does not contain, so each build starts without application targets. A
9
+ // test that needs some passes its own scripts/build.extensions.mjs.
10
+ const NO_APP_TARGETS = "export const appBuildTargets = [];\n";
8
11
 
9
12
  /** Builds an API layout made of `files` with the app's build script. */
10
13
  async function build(files: Record<string, string>): Promise<{ root: string; code: number; stderr: string }> {
11
14
  const root = mkdtempSync(join(tmpdir(), "api-build-"));
12
15
  mkdirSync(join(root, "scripts"));
13
16
  cpSync(BUILD, join(root, "scripts/build.mjs"));
14
- cpSync(EXTENSIONS, join(root, "scripts/build.extensions.mjs"));
17
+ writeFileSync(join(root, "scripts/build.extensions.mjs"), NO_APP_TARGETS);
15
18
  for (const [path, content] of Object.entries(files)) {
16
19
  mkdirSync(dirname(join(root, path)), { recursive: true });
17
20
  writeFileSync(join(root, path), content);
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { LOCAL_PROVIDER_SELECTIONS, SERVER_PROVIDER_SELECTIONS } from "@podosoft/podokit-runtime";
2
3
  import {
3
4
  assertApiContract,
4
5
  assertApplicationRoutes,
@@ -12,7 +13,7 @@ import {
12
13
 
13
14
  describe("API contract inventory", () => {
14
15
  it("covers the core, Todo, module, and Better Auth routes", () => {
15
- const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"]);
16
+ const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"], SERVER_PROVIDER_SELECTIONS);
16
17
 
17
18
  expect(expected).toContain("GET /health/ready");
18
19
  expect(expected).toContain("PATCH /todos/{id}");
@@ -23,7 +24,7 @@ describe("API contract inventory", () => {
23
24
  });
24
25
 
25
26
  it("uses one structural parameter name for every public blog post route", () => {
26
- const expected = expectedApiRoutes("fullstack", ["blog"]);
27
+ const expected = expectedApiRoutes("fullstack", ["blog"], SERVER_PROVIDER_SELECTIONS);
27
28
 
28
29
  expect(expected).toContain("GET /blog/{postRef}");
29
30
  expect(expected).toContain("PATCH /blog/{postRef}");
@@ -32,15 +33,53 @@ describe("API contract inventory", () => {
32
33
  expect(expected).not.toContain("PATCH /blog/{id}");
33
34
  });
34
35
 
36
+ it("expects a provider module's routes only while its provider is selected", () => {
37
+ const modules = [
38
+ "object-storage-s3",
39
+ "object-storage-local",
40
+ "redis",
41
+ "cache-memory",
42
+ "bullmq",
43
+ "jobs-local",
44
+ ];
45
+ const server = expectedApiRoutes("fullstack", modules, SERVER_PROVIDER_SELECTIONS);
46
+ const local = expectedApiRoutes("fullstack", modules, LOCAL_PROVIDER_SELECTIONS);
47
+
48
+ expect(server).toContain("PUT /storage/{key}");
49
+ expect(server).not.toContain("GET /files/content/*");
50
+ expect(server).toContain("PUT /cache/{key}");
51
+ expect(server).toContain("POST /jobs");
52
+ expect(local).toContain("PUT /storage/{key}");
53
+ expect(local).toContain("GET /files/content/*");
54
+ expect(local).not.toContain("PUT /cache/{key}");
55
+ expect(local).not.toContain("GET /cache/{key}");
56
+ expect(local).toContain("GET /jobs/{id}");
57
+
58
+ const s3Document = {
59
+ paths: {
60
+ "/health": { get: {} },
61
+ "/health/ready": { get: {} },
62
+ "/storage/{key}": { get: {}, put: {} },
63
+ "/storage/{key}/presigned": { get: {} },
64
+ },
65
+ };
66
+ const storage = ["object-storage-s3", "object-storage-local"];
67
+ expect(() => assertApiContract(s3Document, "fullstack", storage, SERVER_PROVIDER_SELECTIONS)).not.toThrow();
68
+ expect(() => assertApiContract(s3Document, "fullstack", storage, {
69
+ ...SERVER_PROVIDER_SELECTIONS,
70
+ "object-storage": "local",
71
+ })).toThrow("GET /files/content/*");
72
+ });
73
+
35
74
  it("normalizes OpenAPI operations and reports an exact missing route", () => {
36
75
  const document = { paths: { "/health": { get: {} }, "/health/ready": { get: {} } } };
37
76
 
38
77
  expect(documentedApiRoutes(document)).toEqual(new Set(["GET /health", "GET /health/ready"]));
39
- expect(missingApiRoutes(document, "fullstack", ["redis"])).toEqual([
78
+ expect(missingApiRoutes(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toEqual([
40
79
  "GET /cache/{key}",
41
80
  "PUT /cache/{key}",
42
81
  ]);
43
- expect(() => assertApiContract(document, "fullstack", ["redis"])).toThrow(
82
+ expect(() => assertApiContract(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toThrow(
44
83
  "OpenAPI contract is missing routes",
45
84
  );
46
85
  });
@@ -1,3 +1,5 @@
1
+ import type { ProviderCapability, ProviderSelections } from "@podosoft/podokit-runtime";
2
+
1
3
  const OPERATIONS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
2
4
 
3
5
  const CORE_ROUTES = [
@@ -81,6 +83,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
81
83
  "DELETE /files/uploads/{id}",
82
84
  ],
83
85
  "job-progress": ["POST /progress"],
86
+ "jobs-local": ["POST /jobs", "GET /jobs/{id}"],
84
87
  "object-storage-local": [
85
88
  "GET /files/content/*",
86
89
  "PUT /storage/{key}",
@@ -96,6 +99,22 @@ const MODULE_ROUTES: Record<string, string[]> = {
96
99
  sse: ["GET /events/stream", "POST /events"],
97
100
  };
98
101
 
102
+ /**
103
+ * The provider each provider module implements. Such a module registers its
104
+ * routes only while its provider is selected, so an app may keep, say, both
105
+ * object-storage modules installed and serve the routes of one.
106
+ */
107
+ export const MODULE_PROVIDERS: Readonly<Record<string, readonly [ProviderCapability, string]>> = {
108
+ bullmq: ["jobs", "bullmq"],
109
+ "cache-memory": ["cache", "memory"],
110
+ "events-memory": ["events", "memory"],
111
+ "events-redis": ["events", "redis"],
112
+ "jobs-local": ["jobs", "local"],
113
+ "object-storage-local": ["object-storage", "local"],
114
+ "object-storage-s3": ["object-storage", "s3"],
115
+ redis: ["cache", "redis"],
116
+ };
117
+
99
118
  interface RuntimeRoute {
100
119
  method?: string;
101
120
  path: string;
@@ -195,10 +214,20 @@ export function documentedApiRoutes(document: unknown): Set<string> {
195
214
  return routes;
196
215
  }
197
216
 
198
- export function expectedApiRoutes(template: string, modules: string[]): Set<string> {
217
+ /**
218
+ * The routes the template and the installed modules serve. A provider module
219
+ * counts only when `providers`, the app's PROVIDERS, selects its provider.
220
+ */
221
+ export function expectedApiRoutes(
222
+ template: string,
223
+ modules: string[],
224
+ providers: Readonly<ProviderSelections>,
225
+ ): Set<string> {
199
226
  const routes = new Set(CORE_ROUTES);
200
227
  for (const route of TEMPLATE_ROUTES[template] ?? []) routes.add(route);
201
228
  for (const module of modules) {
229
+ const provider = MODULE_PROVIDERS[module];
230
+ if (provider && providers[provider[0]] !== provider[1]) continue;
202
231
  for (const route of MODULE_ROUTES[module] ?? []) routes.add(route);
203
232
  }
204
233
  return routes;
@@ -208,15 +237,21 @@ export function missingApiRoutes(
208
237
  document: unknown,
209
238
  template: string,
210
239
  modules: string[],
240
+ providers: Readonly<ProviderSelections>,
211
241
  ): string[] {
212
242
  const documented = documentedApiRoutes(document);
213
- return [...expectedApiRoutes(template, modules)]
243
+ return [...expectedApiRoutes(template, modules, providers)]
214
244
  .filter((route) => !documented.has(route))
215
245
  .sort();
216
246
  }
217
247
 
218
- export function assertApiContract(document: unknown, template: string, modules: string[]): void {
219
- const missing = missingApiRoutes(document, template, modules);
248
+ export function assertApiContract(
249
+ document: unknown,
250
+ template: string,
251
+ modules: string[],
252
+ providers: Readonly<ProviderSelections>,
253
+ ): void {
254
+ const missing = missingApiRoutes(document, template, modules, providers);
220
255
  if (missing.length > 0) {
221
256
  throw new Error(`OpenAPI contract is missing routes:\n${missing.join("\n")}`);
222
257
  }
@@ -22,7 +22,7 @@ Read the profile's `driver` first and say which one you are operating.
22
22
  7. Do not apply until the user explicitly approves that exact plan.
23
23
  8. Apply with `{{packageExecutor}} @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
24
24
  9. Run `{{packageExecutor}} @podosoft/podokit deploy status --profile <name> --json` and `{{packageExecutor}} @podosoft/podokit deploy verify --profile <name> --json`.
25
- 10. Report the exact images, the revision, running replicas, restart totals, and the verification result. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
25
+ 10. Report the exact images, the revision, running replicas, restart counts, and the verification result. On Kubernetes, each Deployment's `restartCount` covers only the running pods of its current revision, and `previousPods` gives the `count` and `restartCount` of the pods still listed from an earlier revision or terminating. Right after `apply` or `rollback` these are usually the replaced pods finishing their grace period: report them separately and never attribute their restarts to the new release. Report `syncDrift` too: a non-empty value means containers are running locally synced artifacts and the release tag does not describe what is serving.
26
26
 
27
27
  ## Iterating against a deployment is a different command
28
28
 
@@ -32,8 +32,11 @@ Review these fields before planning:
32
32
  - `workloads`: API/web replicas and resources plus an optional worker
33
33
  - `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage.
34
34
  PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
35
- application opens itself; the doctor and the plan check the connection budget
36
- against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
35
+ application opens itself, each sized by a `runtimeConfig` key (`sizeKey`) or a
36
+ fixed `size` when the code sets it; the doctor and the plan check the connection budget
37
+ against them, counting every API and worker replica twice during a Kubernetes rollout
38
+ because replaced pods keep their pools until they exit, so lower
39
+ `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
37
40
  - `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
38
41
  - `runtimeConfig`: non-sensitive environment values only
39
42
  - `verification`: HTTPS origin and checks with exact statuses and optional JSON fields