@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.
- package/dist/deploy-checks.d.ts +16 -5
- package/dist/deploy-checks.js +17 -9
- package/dist/deploy-render.js +28 -3
- package/dist/deploy-schema.d.ts +7 -3
- package/dist/deploy-schema.js +16 -5
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/AGENTS.md +3 -1
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +5 -2
- package/dist/templates/fullstack/tests/README.md +20 -10
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
- package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +385 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +132 -43
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +163 -62
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +2 -2
package/dist/deploy-checks.d.ts
CHANGED
|
@@ -24,7 +24,10 @@ export interface ConnectionBudget {
|
|
|
24
24
|
perProcessTerms: string[];
|
|
25
25
|
steadyProcesses: number;
|
|
26
26
|
steady: number;
|
|
27
|
-
/**
|
|
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,
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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. */
|
package/dist/deploy-checks.js
CHANGED
|
@@ -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,
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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(`${
|
|
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 =
|
|
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
|
}
|
package/dist/deploy-render.js
CHANGED
|
@@ -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
|
-
|
|
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"),
|
package/dist/deploy-schema.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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. */
|
package/dist/deploy-schema.js
CHANGED
|
@@ -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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
219
|
-
|
|
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
|
|
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
|
|
36
|
-
|
|
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
|