@podosoft/podokit 1.6.1 → 1.6.3
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 +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -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 +3 -1
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- 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/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
- 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/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +1 -1
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;
|
|
@@ -130,7 +138,7 @@ function connectionBudget(profile, modules) {
|
|
|
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.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
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { createSocket } from "node:dgram";
|
|
4
|
+
import { Resolver } from "node:dns/promises";
|
|
3
5
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
6
|
import { connect, createServer, type AddressInfo } from "node:net";
|
|
5
7
|
import { tmpdir } from "node:os";
|
|
@@ -62,6 +64,45 @@ function probedSql(): ProbedSql {
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
interface LocalDns {
|
|
68
|
+
resolver: Resolver;
|
|
69
|
+
close: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A resolver that asks a DNS server on a local UDP port instead of the system's,
|
|
73
|
+
// whose answers depend on the machine. The server answers every query with
|
|
74
|
+
// NXDOMAIN, or never answers when `answers` is false.
|
|
75
|
+
async function localDns(answers: boolean): Promise<LocalDns> {
|
|
76
|
+
const server = createSocket("udp4");
|
|
77
|
+
if (answers) {
|
|
78
|
+
server.on("message", (query, peer) => {
|
|
79
|
+
// The header and the question, flagged as a response with RCODE 3 and no records.
|
|
80
|
+
let end = 12;
|
|
81
|
+
while (end < query.length && query[end] !== 0) end += (query[end] ?? 0) + 1;
|
|
82
|
+
const response = Buffer.from(query.subarray(0, end + 5));
|
|
83
|
+
response[2] = 0x80 | ((query[2] ?? 0) & 0x01);
|
|
84
|
+
response[3] = 0x83;
|
|
85
|
+
response.writeUInt16BE(1, 4);
|
|
86
|
+
response.fill(0, 6, 12);
|
|
87
|
+
server.send(response, peer.port, peer.address);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
|
91
|
+
// Bun waits at least a second for an answer; a single try keeps that the whole wait.
|
|
92
|
+
const resolver = new Resolver({ timeout: 200, tries: 1 });
|
|
93
|
+
resolver.setServers([`127.0.0.1:${server.address().port}`]);
|
|
94
|
+
return { resolver, close: () => server.close() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function lookupError(answers: boolean): Promise<unknown> {
|
|
98
|
+
const dns = await localDns(answers);
|
|
99
|
+
try {
|
|
100
|
+
return await dns.resolver.resolve4("database.invalid").then(() => undefined, (reason: unknown) => reason);
|
|
101
|
+
} finally {
|
|
102
|
+
dns.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
describe("database provider", () => {
|
|
66
107
|
it("uses DATABASE_URL as the canonical connection setting", () => {
|
|
67
108
|
const provider = databaseProvider();
|
|
@@ -266,11 +307,16 @@ describe("isDatabaseUnavailable", () => {
|
|
|
266
307
|
});
|
|
267
308
|
|
|
268
309
|
it("recognizes a real lookup of a host that does not exist", async () => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
|
|
310
|
+
const error = await lookupError(true);
|
|
311
|
+
expect((error as { code?: unknown }).code).toBe("ENOTFOUND");
|
|
272
312
|
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
273
|
-
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("recognizes a real lookup that the resolver never answers", async () => {
|
|
316
|
+
const error = await lookupError(false);
|
|
317
|
+
expect((error as { code?: unknown }).code).toBe("ETIMEOUT");
|
|
318
|
+
expect(isDatabaseUnavailable(error)).toBe(true);
|
|
319
|
+
});
|
|
274
320
|
|
|
275
321
|
it("recognizes a real refused connection to localhost", async () => {
|
|
276
322
|
const server = createServer();
|
|
@@ -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
|
|
|
@@ -34,7 +34,9 @@ Review these fields before planning:
|
|
|
34
34
|
PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
|
|
35
35
|
application opens itself, each sized by a `runtimeConfig` key (`sizeKey`) or a
|
|
36
36
|
fixed `size` when the code sets it; the doctor and the plan check the connection budget
|
|
37
|
-
against them,
|
|
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.
|
|
38
40
|
- `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
|
|
39
41
|
- `runtimeConfig`: non-sensitive environment values only
|
|
40
42
|
- `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
|
|
@@ -68,3 +68,14 @@ tests not run, and selecting an exclusive test runs every other test first. Use
|
|
|
68
68
|
|
|
69
69
|
Application-owned projects in `playwright.projects.cjs` get twins too. A project
|
|
70
70
|
that another project depends on is a setup project and keeps a single copy.
|
|
71
|
+
|
|
72
|
+
## Email tests
|
|
73
|
+
|
|
74
|
+
The email specs that the `admin-dashboard` module adds read messages from the
|
|
75
|
+
Mailpit at `MAILPIT_URL` (default `http://localhost:8025`). Before the first one,
|
|
76
|
+
each worker has the app send a password reset email to a throwaway account and
|
|
77
|
+
waits up to five seconds for it there. When it does not arrive, because the app
|
|
78
|
+
has no SMTP or sends to a catcher other than the one on that port, the email
|
|
79
|
+
tests skip with that cause. Set `MAILPIT_URL` to the Mailpit that receives the
|
|
80
|
+
app's mail to run them, and `E2E_REQUIRE_MAIL=true` to make a missing probe
|
|
81
|
+
message fail them instead of skipping.
|
|
@@ -20,9 +20,15 @@ export interface AppPaths {
|
|
|
20
20
|
* `AccountMenu` for a signed-in user, or null when none does.
|
|
21
21
|
*/
|
|
22
22
|
accountMenu: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* A page that renders the managed `ThemeToggle` for the signed-in
|
|
25
|
+
* administrator, or null when none does. Defaults to `accountMenu`, and to
|
|
26
|
+
* the admin console, whose header renders the toggle, when that is null.
|
|
27
|
+
*/
|
|
28
|
+
themeToggle: string | null;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
const defaults: AppPaths = {
|
|
31
|
+
const defaults: Omit<AppPaths, "themeToggle"> = {
|
|
26
32
|
home: "/",
|
|
27
33
|
homeReady: (page) => page.getByRole("heading", { level: 1 }),
|
|
28
34
|
indexable: ["/"],
|
|
@@ -30,4 +36,9 @@ const defaults: AppPaths = {
|
|
|
30
36
|
accountMenu: "/",
|
|
31
37
|
};
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
const merged = { ...defaults, ...appPaths };
|
|
40
|
+
|
|
41
|
+
export const paths: AppPaths = {
|
|
42
|
+
...merged,
|
|
43
|
+
themeToggle: appPaths.themeToggle === undefined ? (merged.accountMenu ?? "/admin") : appPaths.themeToggle,
|
|
44
|
+
};
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import type { APIRequestContext } from "@playwright/test";
|
|
1
2
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
3
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
|
+
import { totpCode } from "../helpers/totp";
|
|
3
6
|
|
|
4
7
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
8
|
const origin = { origin: base };
|
|
9
|
+
const PASSWORD = "Podokit3e-Str0ng!pw";
|
|
6
10
|
|
|
7
|
-
type Entry = {
|
|
11
|
+
type Entry = {
|
|
12
|
+
id: string;
|
|
13
|
+
action: string;
|
|
14
|
+
actorEmail: string | null;
|
|
15
|
+
targetLabel: string | null;
|
|
16
|
+
metadata: Record<string, unknown> | null;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
};
|
|
8
19
|
|
|
9
20
|
async function signedIn(
|
|
10
21
|
playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
|
|
@@ -19,6 +30,13 @@ function auditQuery(params: Record<string, string>): string {
|
|
|
19
30
|
return `/api/audit-logs?${new URLSearchParams(params).toString()}`;
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
// Sign-in methods recorded for the account, oldest first.
|
|
34
|
+
async function signInMethods(admin: APIRequestContext, email: string): Promise<unknown[]> {
|
|
35
|
+
const entries = (await (await admin.get(auditQuery({ action: "auth.login", target: email, order: "asc" }))).json()) as Entry[];
|
|
36
|
+
expect(entries.every((entry) => entry.actorEmail === email)).toBe(true);
|
|
37
|
+
return entries.map((entry) => entry.metadata?.method);
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
|
|
23
41
|
const admin = await signedIn(playwright, ADMIN);
|
|
24
42
|
const probe = await admin.get("/api/audit-logs");
|
|
@@ -58,13 +76,22 @@ test("sending a verification email is audited", async ({ playwright, disposableU
|
|
|
58
76
|
const admin = await signedIn(playwright, ADMIN);
|
|
59
77
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
60
78
|
const email = `verif-audit-${Date.now()}@example.com`;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
79
|
+
await disposableUsers.create({ email, password: PASSWORD, name: "VA" });
|
|
80
|
+
const sent = async (): Promise<Entry[]> =>
|
|
81
|
+
(await (await admin.get(auditQuery({ action: "auth.verification_sent", target: email }))).json()) as Entry[];
|
|
82
|
+
|
|
83
|
+
// Better Auth sends a signed-in user's verification email only to their own address.
|
|
84
|
+
const refused = await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
|
|
85
|
+
expect(refused.status()).toBe(400);
|
|
86
|
+
expect(await sent(), "a refused send is not audited").toEqual([]);
|
|
87
|
+
|
|
88
|
+
const user = await signedIn(playwright, { email, password: PASSWORD });
|
|
89
|
+
const own = await user.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/account` } });
|
|
90
|
+
expect(own.ok()).toBeTruthy();
|
|
91
|
+
const entries = await sent();
|
|
67
92
|
expect(entries, "verification send should be audited").toHaveLength(1);
|
|
93
|
+
expect(entries[0]?.actorEmail).toBe(email);
|
|
94
|
+
await user.dispose();
|
|
68
95
|
await admin.dispose();
|
|
69
96
|
});
|
|
70
97
|
|
|
@@ -126,6 +153,68 @@ test("the audit log rejects bad paging parameters", async ({ playwright }) => {
|
|
|
126
153
|
await admin.dispose();
|
|
127
154
|
});
|
|
128
155
|
|
|
156
|
+
test("a wrong password is not audited as a sign-in", async ({ playwright, disposableUsers }) => {
|
|
157
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
158
|
+
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
159
|
+
const email = `audit-password-${Date.now()}@example.com`;
|
|
160
|
+
await disposableUsers.create({ email, password: PASSWORD });
|
|
161
|
+
|
|
162
|
+
const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
163
|
+
const wrong = await visitor.post("/api/auth/sign-in/email", { data: { email, password: "Wrong-password-1" } });
|
|
164
|
+
expect(wrong.status()).toBe(401);
|
|
165
|
+
expect(await signInMethods(admin, email), "a failed sign-in leaves no auth.login").toEqual([]);
|
|
166
|
+
|
|
167
|
+
expect((await visitor.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok()).toBeTruthy();
|
|
168
|
+
expect(await signInMethods(admin, email)).toEqual(["password"]);
|
|
169
|
+
await visitor.dispose();
|
|
170
|
+
await admin.dispose();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("an email code sign-in is audited once", async ({ playwright, disposableUsers }) => {
|
|
174
|
+
await skipUnlessMailReachesMailpit();
|
|
175
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
176
|
+
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
177
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
178
|
+
test.skip(!caps?.emailOtp, "email OTP not enabled");
|
|
179
|
+
const email = `audit-otp-${Date.now()}@example.com`;
|
|
180
|
+
await disposableUsers.create({ email, password: PASSWORD });
|
|
181
|
+
|
|
182
|
+
const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
183
|
+
const sent = await visitor.post("/api/auth/email-otp/send-verification-otp", { data: { email, type: "sign-in" } });
|
|
184
|
+
expect(sent.ok()).toBeTruthy();
|
|
185
|
+
const otp = await waitForOtp(email);
|
|
186
|
+
expect((await visitor.post("/api/auth/sign-in/email-otp", { data: { email, otp } })).ok()).toBeTruthy();
|
|
187
|
+
expect(await signInMethods(admin, email)).toEqual(["email-otp"]);
|
|
188
|
+
await visitor.dispose();
|
|
189
|
+
await admin.dispose();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a two-factor sign-in is audited when the code completes it", async ({ playwright, disposableUsers }) => {
|
|
193
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
194
|
+
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
195
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
196
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
197
|
+
const email = `audit-2fa-${Date.now()}@example.com`;
|
|
198
|
+
await disposableUsers.create({ email, password: PASSWORD });
|
|
199
|
+
|
|
200
|
+
const owner = await signedIn(playwright, { email, password: PASSWORD });
|
|
201
|
+
const enable = await (await owner.post("/api/auth/two-factor/enable", { data: { password: PASSWORD } })).json();
|
|
202
|
+
expect((await owner.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } })).ok()).toBeTruthy();
|
|
203
|
+
expect(await signInMethods(admin, email), "turning on two-factor is not a sign-in").toEqual(["password"]);
|
|
204
|
+
|
|
205
|
+
const challenged = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
206
|
+
const pending = await (await challenged.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).json();
|
|
207
|
+
expect(pending.twoFactorRedirect).toBe(true);
|
|
208
|
+
expect(await signInMethods(admin, email), "the password step alone is not a sign-in").toEqual(["password"]);
|
|
209
|
+
|
|
210
|
+
const verified = await challenged.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
211
|
+
expect(verified.ok()).toBeTruthy();
|
|
212
|
+
expect(await signInMethods(admin, email)).toEqual(["password", "two-factor-totp"]);
|
|
213
|
+
await owner.dispose();
|
|
214
|
+
await challenged.dispose();
|
|
215
|
+
await admin.dispose();
|
|
216
|
+
});
|
|
217
|
+
|
|
129
218
|
// Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
|
|
130
219
|
// Runs after the recording tests and restores the flag so nothing else is affected.
|
|
131
220
|
test("audit logging honours the DB on/off toggle @smoke", { tag: "@exclusive" }, async ({ playwright, disposableUsers }) => {
|
|
@@ -339,6 +339,17 @@ test("a user can create, list and delete an organization @smoke", async ({ playw
|
|
|
339
339
|
const list = (await (await ctx.get("/api/auth/organization/list")).json()) as Array<{ id: string; slug: string }>;
|
|
340
340
|
expect(list.some((o) => o.slug === slug)).toBeTruthy();
|
|
341
341
|
|
|
342
|
+
// A filter or a page that matches no member is an empty list, not a server error.
|
|
343
|
+
const userId = ((await (await ctx.get("/api/auth/get-session")).json()) as { user: { id: string } }).user.id;
|
|
344
|
+
const members = async (query: string): Promise<unknown> => {
|
|
345
|
+
const response = await ctx.get(`/api/auth/organization/list-members?organizationId=${orgId}&${query}`);
|
|
346
|
+
expect(response.status(), query).toBe(200);
|
|
347
|
+
return response.json();
|
|
348
|
+
};
|
|
349
|
+
expect(await members(`filterField=userId&filterValue=${userId}`)).toMatchObject({ members: [{ userId }], total: 1 });
|
|
350
|
+
expect(await members("filterField=userId&filterValue=not-a-member")).toEqual({ members: [], total: 0 });
|
|
351
|
+
expect(await members("offset=1")).toEqual({ members: [], total: 1 });
|
|
352
|
+
|
|
342
353
|
const del = await ctx.post("/api/auth/organization/delete", { data: { organizationId: orgId } });
|
|
343
354
|
expect(del.ok()).toBeTruthy();
|
|
344
355
|
await ctx.dispose();
|