@podosoft/podokit 0.15.0 → 0.16.0
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/README.md +17 -0
- package/dist/add.d.ts +4 -0
- package/dist/add.js +59 -1
- package/dist/create.js +8 -1
- package/dist/deploy-profile.d.ts +92 -0
- package/dist/deploy-profile.js +603 -0
- package/dist/deploy-render.d.ts +18 -0
- package/dist/deploy-render.js +651 -0
- package/dist/deploy.d.ts +65 -0
- package/dist/deploy.js +851 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +29 -5
- package/dist/index.d.ts +8 -0
- package/dist/index.js +147 -1
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +18 -1
- package/dist/lockfile.js +1 -0
- package/dist/remove.js +7 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
- package/dist/templates/fullstack-nest-svelte/README.md +19 -8
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
- package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
- package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
- package/dist/templates/modules/bullmq/module.manifest.json +4 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
- package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
- package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
- package/dist/templates/modules/redis/module.manifest.json +16 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
- package/dist/templates/modules/sse/module.manifest.json +16 -3
- package/dist/templates/todo/AGENTS.md +1 -0
- package/dist/templates/todo/README.md +12 -8
- package/dist/templates/todo/apps/api/Dockerfile +2 -2
- package/dist/templates/todo/apps/api/package.json +1 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
- package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
- package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
- package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
- package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
- package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
- package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
- package/dist/templates/todo/apps/web/Dockerfile +2 -2
- package/dist/templates/todo/compose.dev.yaml +3 -5
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
- package/dist/templates/todo/dot-env.example +6 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
- package/package.json +2 -2
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.inspectClusterFingerprint = inspectClusterFingerprint;
|
|
4
|
+
exports.doctorDeployment = doctorDeployment;
|
|
5
|
+
exports.planDeployment = planDeployment;
|
|
6
|
+
exports.applyDeployment = applyDeployment;
|
|
7
|
+
exports.getDeploymentStatus = getDeploymentStatus;
|
|
8
|
+
exports.verifyDeployment = verifyDeployment;
|
|
9
|
+
exports.planRollback = planRollback;
|
|
10
|
+
exports.rollbackDeployment = rollbackDeployment;
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_os_1 = require("node:os");
|
|
15
|
+
const node_path_1 = require("node:path");
|
|
16
|
+
const deploy_profile_1 = require("./deploy-profile");
|
|
17
|
+
const deploy_render_1 = require("./deploy-render");
|
|
18
|
+
function defaultRunner(command, args, options) {
|
|
19
|
+
const result = (0, node_child_process_1.spawnSync)(command, args, {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: options.capture ? "pipe" : "inherit",
|
|
22
|
+
});
|
|
23
|
+
if (result.error)
|
|
24
|
+
throw result.error;
|
|
25
|
+
return {
|
|
26
|
+
status: result.status,
|
|
27
|
+
stdout: options.capture ? (result.stdout ?? "") : "",
|
|
28
|
+
stderr: options.capture ? (result.stderr ?? "") : "",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function checked(runner, command, args, capture = true) {
|
|
32
|
+
const result = runner(command, args, { capture });
|
|
33
|
+
if (result.status !== 0) {
|
|
34
|
+
const detail = capture ? result.stderr.trim() || result.stdout.trim() : "";
|
|
35
|
+
throw new Error(`${command} ${args.join(" ")} failed with status ${String(result.status)}${detail ? `: ${detail}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
return result.stdout;
|
|
38
|
+
}
|
|
39
|
+
function parseJson(value, description) {
|
|
40
|
+
if (!value.trim())
|
|
41
|
+
throw new Error(`${description} returned empty JSON.`);
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(value);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new Error(`${description} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function kubectlArgs(profile, args) {
|
|
50
|
+
return ["--context", profile.target.context, "-n", profile.target.namespace, ...args];
|
|
51
|
+
}
|
|
52
|
+
function helmArgs(profile, args) {
|
|
53
|
+
return [
|
|
54
|
+
...args,
|
|
55
|
+
"--namespace",
|
|
56
|
+
profile.target.namespace,
|
|
57
|
+
"--kube-context",
|
|
58
|
+
profile.target.context,
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
function inspectClusterFingerprint(context, runner = defaultRunner) {
|
|
62
|
+
const output = checked(runner, "kubectl", [
|
|
63
|
+
"--context",
|
|
64
|
+
context,
|
|
65
|
+
"config",
|
|
66
|
+
"view",
|
|
67
|
+
"--raw",
|
|
68
|
+
"--flatten",
|
|
69
|
+
"--minify",
|
|
70
|
+
"-o",
|
|
71
|
+
'jsonpath={.clusters[0].cluster.server}{"\\n"}{.clusters[0].cluster.certificate-authority-data}',
|
|
72
|
+
]);
|
|
73
|
+
if (!output.trim())
|
|
74
|
+
throw new Error(`Kubernetes context "${context}" has no cluster endpoint.`);
|
|
75
|
+
return `sha256:${(0, node_crypto_1.createHash)("sha256").update(output.trim()).digest("hex")}`;
|
|
76
|
+
}
|
|
77
|
+
function assertClusterTarget(profile, runner) {
|
|
78
|
+
const observed = inspectClusterFingerprint(profile.target.context, runner);
|
|
79
|
+
if (observed !== profile.target.clusterFingerprint) {
|
|
80
|
+
throw new Error(`Cluster fingerprint mismatch: expected ${profile.target.clusterFingerprint}, received ${observed}.`);
|
|
81
|
+
}
|
|
82
|
+
return observed;
|
|
83
|
+
}
|
|
84
|
+
function secretKeys(profile, secretName, runner) {
|
|
85
|
+
const result = runner("kubectl", kubectlArgs(profile, [
|
|
86
|
+
"get",
|
|
87
|
+
"secret",
|
|
88
|
+
secretName,
|
|
89
|
+
"-o",
|
|
90
|
+
'go-template={{range $key, $value := .data}}{{$key}}{{"\\n"}}{{end}}',
|
|
91
|
+
]), { capture: true });
|
|
92
|
+
if (result.status !== 0)
|
|
93
|
+
return null;
|
|
94
|
+
return result.stdout
|
|
95
|
+
.split(/\r?\n/)
|
|
96
|
+
.map((entry) => entry.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.sort();
|
|
99
|
+
}
|
|
100
|
+
function dependencySecretRequirements(profile) {
|
|
101
|
+
const requirements = [];
|
|
102
|
+
if (profile.dependencies.postgres.mode === "inCluster") {
|
|
103
|
+
requirements.push({
|
|
104
|
+
name: profile.dependencies.postgres.secretName,
|
|
105
|
+
keys: ["POSTGRES_DB", "POSTGRES_PASSWORD", "POSTGRES_USER"],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (profile.dependencies.redis.mode === "inCluster") {
|
|
109
|
+
requirements.push({
|
|
110
|
+
name: profile.dependencies.redis.secretName,
|
|
111
|
+
keys: ["REDIS_PASSWORD"],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (profile.dependencies.objectStorage.mode === "inCluster") {
|
|
115
|
+
requirements.push({
|
|
116
|
+
name: profile.dependencies.objectStorage.secretName,
|
|
117
|
+
keys: [
|
|
118
|
+
"MINIO_ROOT_PASSWORD",
|
|
119
|
+
"MINIO_ROOT_USER",
|
|
120
|
+
"S3_ACCESS_KEY_ID",
|
|
121
|
+
"S3_SECRET_ACCESS_KEY",
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return requirements;
|
|
126
|
+
}
|
|
127
|
+
function apiSecretRequirements(profile) {
|
|
128
|
+
const keys = new Set(profile.secrets.api.requiredKeys);
|
|
129
|
+
if (profile.dependencies.postgres.mode === "inCluster") {
|
|
130
|
+
keys.add("POSTGRES_DB");
|
|
131
|
+
keys.add("POSTGRES_PASSWORD");
|
|
132
|
+
keys.add("POSTGRES_USER");
|
|
133
|
+
}
|
|
134
|
+
if (profile.dependencies.redis.mode === "inCluster") {
|
|
135
|
+
keys.add("REDIS_PASSWORD");
|
|
136
|
+
}
|
|
137
|
+
if (profile.dependencies.objectStorage.mode === "inCluster") {
|
|
138
|
+
keys.add("S3_ACCESS_KEY_ID");
|
|
139
|
+
keys.add("S3_SECRET_ACCESS_KEY");
|
|
140
|
+
}
|
|
141
|
+
return [...keys].sort();
|
|
142
|
+
}
|
|
143
|
+
function doctorDeployment(projectRoot, profileName, runner = defaultRunner) {
|
|
144
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
145
|
+
const findings = [];
|
|
146
|
+
const fingerprint = inspectClusterFingerprint(profile.target.context, runner);
|
|
147
|
+
findings.push({
|
|
148
|
+
code: "cluster-fingerprint",
|
|
149
|
+
ok: fingerprint === profile.target.clusterFingerprint,
|
|
150
|
+
message: fingerprint === profile.target.clusterFingerprint
|
|
151
|
+
? `Cluster fingerprint matches ${fingerprint}.`
|
|
152
|
+
: `Cluster fingerprint mismatch: expected ${profile.target.clusterFingerprint}, received ${fingerprint}.`,
|
|
153
|
+
});
|
|
154
|
+
if (profile.exposure.mode === "ingress") {
|
|
155
|
+
const ingressClass = runner("kubectl", [
|
|
156
|
+
"--context",
|
|
157
|
+
profile.target.context,
|
|
158
|
+
"get",
|
|
159
|
+
"ingressclass",
|
|
160
|
+
profile.exposure.ingressClassName,
|
|
161
|
+
"-o",
|
|
162
|
+
"name",
|
|
163
|
+
], { capture: true });
|
|
164
|
+
findings.push({
|
|
165
|
+
code: `ingress-class:${profile.exposure.ingressClassName}`,
|
|
166
|
+
ok: ingressClass.status === 0,
|
|
167
|
+
message: ingressClass.status === 0
|
|
168
|
+
? `IngressClass ${profile.exposure.ingressClassName} exists.`
|
|
169
|
+
: `IngressClass ${profile.exposure.ingressClassName} is missing or inaccessible.`,
|
|
170
|
+
});
|
|
171
|
+
if (profile.exposure.tlsSecretName) {
|
|
172
|
+
const tlsType = runner("kubectl", kubectlArgs(profile, [
|
|
173
|
+
"get",
|
|
174
|
+
"secret",
|
|
175
|
+
profile.exposure.tlsSecretName,
|
|
176
|
+
"-o",
|
|
177
|
+
"jsonpath={.type}",
|
|
178
|
+
]), { capture: true });
|
|
179
|
+
findings.push({
|
|
180
|
+
code: `secret:${profile.exposure.tlsSecretName}`,
|
|
181
|
+
ok: tlsType.status === 0 &&
|
|
182
|
+
tlsType.stdout.trim() === "kubernetes.io/tls",
|
|
183
|
+
message: tlsType.status !== 0
|
|
184
|
+
? `TLS Secret ${profile.exposure.tlsSecretName} is missing or inaccessible.`
|
|
185
|
+
: tlsType.stdout.trim() === "kubernetes.io/tls"
|
|
186
|
+
? `TLS Secret ${profile.exposure.tlsSecretName} has the expected type.`
|
|
187
|
+
: `TLS Secret ${profile.exposure.tlsSecretName} must have type kubernetes.io/tls.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const namespace = runner("kubectl", ["--context", profile.target.context, "get", "namespace", profile.target.namespace, "-o", "name"], { capture: true });
|
|
192
|
+
findings.push({
|
|
193
|
+
code: "namespace",
|
|
194
|
+
ok: namespace.status === 0,
|
|
195
|
+
message: namespace.status === 0
|
|
196
|
+
? `Namespace ${profile.target.namespace} exists.`
|
|
197
|
+
: `Namespace ${profile.target.namespace} is missing or inaccessible.`,
|
|
198
|
+
});
|
|
199
|
+
for (const verb of ["create", "delete"]) {
|
|
200
|
+
const leasePermission = runner("kubectl", kubectlArgs(profile, [
|
|
201
|
+
"auth",
|
|
202
|
+
"can-i",
|
|
203
|
+
verb,
|
|
204
|
+
"leases.coordination.k8s.io",
|
|
205
|
+
]), { capture: true });
|
|
206
|
+
findings.push({
|
|
207
|
+
code: `lease-rbac:${verb}`,
|
|
208
|
+
ok: leasePermission.status === 0 &&
|
|
209
|
+
leasePermission.stdout.trim().toLowerCase() === "yes",
|
|
210
|
+
message: leasePermission.status === 0 &&
|
|
211
|
+
leasePermission.stdout.trim().toLowerCase() === "yes"
|
|
212
|
+
? `The deployment identity can ${verb} Kubernetes Leases.`
|
|
213
|
+
: `The deployment identity cannot ${verb} Kubernetes Leases in namespace ${profile.target.namespace}.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const helmVersion = runner("helm", ["version", "--short"], { capture: true });
|
|
217
|
+
findings.push({
|
|
218
|
+
code: "helm",
|
|
219
|
+
ok: helmVersion.status === 0 && /^v[34]\./.test(helmVersion.stdout.trim()),
|
|
220
|
+
message: helmVersion.status === 0
|
|
221
|
+
? `Helm version is ${helmVersion.stdout.trim()}.`
|
|
222
|
+
: "Helm is unavailable.",
|
|
223
|
+
});
|
|
224
|
+
const imageResolver = runner("docker", ["buildx", "version"], { capture: true });
|
|
225
|
+
findings.push({
|
|
226
|
+
code: "image-resolver",
|
|
227
|
+
ok: imageResolver.status === 0,
|
|
228
|
+
message: imageResolver.status === 0
|
|
229
|
+
? "Docker Buildx is available for immutable image digest resolution."
|
|
230
|
+
: "Docker Buildx is unavailable; release image digests cannot be resolved.",
|
|
231
|
+
});
|
|
232
|
+
for (const requirement of [
|
|
233
|
+
{ name: profile.secrets.api.name, keys: apiSecretRequirements(profile) },
|
|
234
|
+
...(profile.secrets.web
|
|
235
|
+
? [{ name: profile.secrets.web.name, keys: profile.secrets.web.requiredKeys }]
|
|
236
|
+
: []),
|
|
237
|
+
...dependencySecretRequirements(profile),
|
|
238
|
+
]) {
|
|
239
|
+
const keys = secretKeys(profile, requirement.name, runner);
|
|
240
|
+
const missing = requirement.keys.filter((key) => !keys?.includes(key));
|
|
241
|
+
findings.push({
|
|
242
|
+
code: `secret:${requirement.name}`,
|
|
243
|
+
ok: keys !== null && missing.length === 0,
|
|
244
|
+
message: keys === null
|
|
245
|
+
? `Secret ${requirement.name} is missing or inaccessible.`
|
|
246
|
+
: missing.length
|
|
247
|
+
? `Secret ${requirement.name} is missing required key(s): ${missing.join(", ")}.`
|
|
248
|
+
: `Secret ${requirement.name} contains the required key names.`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const imagePullType = runner("kubectl", kubectlArgs(profile, [
|
|
252
|
+
"get",
|
|
253
|
+
"secret",
|
|
254
|
+
profile.secrets.imagePull,
|
|
255
|
+
"-o",
|
|
256
|
+
"jsonpath={.type}",
|
|
257
|
+
]), { capture: true });
|
|
258
|
+
const expectedImagePullType = "kubernetes.io/dockerconfigjson";
|
|
259
|
+
findings.push({
|
|
260
|
+
code: `secret:${profile.secrets.imagePull}`,
|
|
261
|
+
ok: imagePullType.status === 0 &&
|
|
262
|
+
imagePullType.stdout.trim() === expectedImagePullType,
|
|
263
|
+
message: imagePullType.status !== 0
|
|
264
|
+
? `Image pull Secret ${profile.secrets.imagePull} is missing or inaccessible.`
|
|
265
|
+
: imagePullType.stdout.trim() === expectedImagePullType
|
|
266
|
+
? `Image pull Secret ${profile.secrets.imagePull} has the expected type.`
|
|
267
|
+
: `Image pull Secret ${profile.secrets.imagePull} must have type ${expectedImagePullType}.`,
|
|
268
|
+
});
|
|
269
|
+
for (const dependency of [
|
|
270
|
+
profile.dependencies.postgres,
|
|
271
|
+
profile.dependencies.redis,
|
|
272
|
+
profile.dependencies.objectStorage,
|
|
273
|
+
]) {
|
|
274
|
+
if (dependency.mode !== "inCluster" || !dependency.storageClassName)
|
|
275
|
+
continue;
|
|
276
|
+
const storage = runner("kubectl", [
|
|
277
|
+
"--context",
|
|
278
|
+
profile.target.context,
|
|
279
|
+
"get",
|
|
280
|
+
"storageclass",
|
|
281
|
+
dependency.storageClassName,
|
|
282
|
+
"-o",
|
|
283
|
+
"name",
|
|
284
|
+
], { capture: true });
|
|
285
|
+
findings.push({
|
|
286
|
+
code: `storage-class:${dependency.storageClassName}`,
|
|
287
|
+
ok: storage.status === 0,
|
|
288
|
+
message: storage.status === 0
|
|
289
|
+
? `StorageClass ${dependency.storageClassName} exists.`
|
|
290
|
+
: `StorageClass ${dependency.storageClassName} is missing or inaccessible.`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return findings;
|
|
294
|
+
}
|
|
295
|
+
function currentHelmStatus(profile, runner, releaseName = profile.target.release) {
|
|
296
|
+
const result = runner("helm", helmArgs(profile, ["status", releaseName, "-o", "json"]), { capture: true });
|
|
297
|
+
if (result.status !== 0) {
|
|
298
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
299
|
+
if (/release(?:\s+\S+)?(?:\s+was)?\s+not\s+found|release:\s*not\s+found/i.test(detail)) {
|
|
300
|
+
return { revision: null, status: "not-installed" };
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Unable to inspect Helm release ${releaseName}${detail ? `: ${detail}` : "."}`);
|
|
303
|
+
}
|
|
304
|
+
const status = parseJson(result.stdout, `Helm status for release ${releaseName}`);
|
|
305
|
+
if (!Number.isInteger(status.version) ||
|
|
306
|
+
(status.version ?? 0) < 1 ||
|
|
307
|
+
typeof status.info?.status !== "string" ||
|
|
308
|
+
!status.info.status.trim()) {
|
|
309
|
+
throw new Error(`Helm status for release ${releaseName} has an invalid schema.`);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
revision: status.version,
|
|
313
|
+
status: status.info.status,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function referencedSecretNames(profile) {
|
|
317
|
+
return [
|
|
318
|
+
profile.secrets.api.name,
|
|
319
|
+
...(profile.secrets.web ? [profile.secrets.web.name] : []),
|
|
320
|
+
profile.secrets.imagePull,
|
|
321
|
+
...(profile.exposure.tlsSecretName ? [profile.exposure.tlsSecretName] : []),
|
|
322
|
+
...dependencySecretRequirements(profile).map((entry) => entry.name),
|
|
323
|
+
].filter((name, index, all) => all.indexOf(name) === index).sort();
|
|
324
|
+
}
|
|
325
|
+
function clusterState(profile, runner) {
|
|
326
|
+
const dependency = currentHelmStatus(profile, runner, `${profile.target.release}-dependencies`);
|
|
327
|
+
const secretReferences = referencedSecretNames(profile).map((name) => {
|
|
328
|
+
const identity = checked(runner, "kubectl", kubectlArgs(profile, [
|
|
329
|
+
"get",
|
|
330
|
+
"secret",
|
|
331
|
+
name,
|
|
332
|
+
"-o",
|
|
333
|
+
"jsonpath={.metadata.uid}:{.metadata.resourceVersion}",
|
|
334
|
+
])).trim();
|
|
335
|
+
if (!/^[^:]+:[^:]+$/.test(identity)) {
|
|
336
|
+
throw new Error(`Secret ${name} does not expose a stable Kubernetes identity.`);
|
|
337
|
+
}
|
|
338
|
+
return { name, identity };
|
|
339
|
+
});
|
|
340
|
+
const rolloutStateDigest = `sha256:${(0, node_crypto_1.createHash)("sha256")
|
|
341
|
+
.update(JSON.stringify(secretReferences))
|
|
342
|
+
.digest("hex")}`;
|
|
343
|
+
return {
|
|
344
|
+
dependencyRevision: dependency.revision,
|
|
345
|
+
dependencyStatus: dependency.status,
|
|
346
|
+
rolloutStateDigest,
|
|
347
|
+
clusterStateDigest: `sha256:${(0, node_crypto_1.createHash)("sha256")
|
|
348
|
+
.update(JSON.stringify({ dependency, secretReferences }))
|
|
349
|
+
.digest("hex")}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function resolveImage(reference, runner) {
|
|
353
|
+
if (/@sha256:[a-f0-9]{64}$/.test(reference))
|
|
354
|
+
return reference;
|
|
355
|
+
const output = checked(runner, "docker", [
|
|
356
|
+
"buildx",
|
|
357
|
+
"imagetools",
|
|
358
|
+
"inspect",
|
|
359
|
+
reference,
|
|
360
|
+
"--format",
|
|
361
|
+
"{{json .Manifest}}",
|
|
362
|
+
]);
|
|
363
|
+
const manifest = parseJson(output, `Image manifest inspection for ${reference}`);
|
|
364
|
+
if (!manifest.digest || !/^sha256:[a-f0-9]{64}$/.test(manifest.digest)) {
|
|
365
|
+
throw new Error(`Unable to resolve immutable digest for image ${reference}.`);
|
|
366
|
+
}
|
|
367
|
+
return `${reference}@${manifest.digest}`;
|
|
368
|
+
}
|
|
369
|
+
function resolveDeploymentImages(profile, release, runner) {
|
|
370
|
+
return {
|
|
371
|
+
api: resolveImage(`${profile.release.apiRepository}:${release}`, runner),
|
|
372
|
+
web: resolveImage(`${profile.release.webRepository}:${release}`, runner),
|
|
373
|
+
postgres: profile.dependencies.postgres.mode === "inCluster"
|
|
374
|
+
? resolveImage(profile.dependencies.postgres.image, runner)
|
|
375
|
+
: null,
|
|
376
|
+
redis: profile.dependencies.redis.mode === "inCluster"
|
|
377
|
+
? resolveImage(profile.dependencies.redis.image, runner)
|
|
378
|
+
: null,
|
|
379
|
+
objectStorage: profile.dependencies.objectStorage.mode === "inCluster"
|
|
380
|
+
? resolveImage(profile.dependencies.objectStorage.image, runner)
|
|
381
|
+
: null,
|
|
382
|
+
objectStorageClient: profile.dependencies.objectStorage.mode === "inCluster"
|
|
383
|
+
? resolveImage(profile.dependencies.objectStorage.clientImage, runner)
|
|
384
|
+
: null,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function parseDeployment(output, workloadName) {
|
|
388
|
+
const deployment = parseJson(output, `Kubernetes Deployment ${workloadName}`);
|
|
389
|
+
const replicas = deployment.spec?.replicas;
|
|
390
|
+
const readyReplicas = deployment.status?.readyReplicas ?? 0;
|
|
391
|
+
const containers = deployment.spec?.template?.spec?.containers;
|
|
392
|
+
if (deployment.metadata?.name !== workloadName ||
|
|
393
|
+
!Number.isInteger(replicas) ||
|
|
394
|
+
(replicas ?? -1) < 0 ||
|
|
395
|
+
!Number.isInteger(readyReplicas) ||
|
|
396
|
+
readyReplicas < 0 ||
|
|
397
|
+
!Array.isArray(containers) ||
|
|
398
|
+
containers.length === 0 ||
|
|
399
|
+
containers.some((container) => typeof container.image !== "string" || container.image.length === 0)) {
|
|
400
|
+
throw new Error(`Kubernetes Deployment ${workloadName} has an invalid schema.`);
|
|
401
|
+
}
|
|
402
|
+
return deployment;
|
|
403
|
+
}
|
|
404
|
+
function restartCount(output, workloadName) {
|
|
405
|
+
const values = output
|
|
406
|
+
.split(/\s+/)
|
|
407
|
+
.map((value) => value.trim())
|
|
408
|
+
.filter(Boolean);
|
|
409
|
+
if (values.some((value) => !/^(0|[1-9][0-9]*)$/.test(value))) {
|
|
410
|
+
throw new Error(`Kubernetes pod restart counts for ${workloadName} are invalid.`);
|
|
411
|
+
}
|
|
412
|
+
return values.reduce((sum, value) => sum + Number(value), 0);
|
|
413
|
+
}
|
|
414
|
+
function planDigest(input, runtime) {
|
|
415
|
+
return (0, node_crypto_1.createHash)("sha256")
|
|
416
|
+
.update(JSON.stringify(input))
|
|
417
|
+
.update(runtime.dependencyManifest)
|
|
418
|
+
.update(runtime.applicationManifest)
|
|
419
|
+
.update((0, node_fs_1.readFileSync)(runtime.migrationManifest))
|
|
420
|
+
.digest("hex");
|
|
421
|
+
}
|
|
422
|
+
function workloadImageFromManifest(manifest, workloadName) {
|
|
423
|
+
for (const document of manifest.split(/\n---\s*\n/)) {
|
|
424
|
+
if (!new RegExp(`^\\s*name:\\s*${workloadName}\\s*$`, "m").test(document))
|
|
425
|
+
continue;
|
|
426
|
+
const match = document.match(/^\s*(?:-\s*)?image:\s*["']?([^"'\s]+)["']?\s*$/m);
|
|
427
|
+
if (match?.[1])
|
|
428
|
+
return match[1];
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
function planDeployment(projectRoot, profileName, release, runner = defaultRunner) {
|
|
433
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
434
|
+
(0, deploy_profile_1.assertReleaseTag)(profile, release);
|
|
435
|
+
const clusterFingerprint = assertClusterTarget(profile, runner);
|
|
436
|
+
const current = currentHelmStatus(profile, runner);
|
|
437
|
+
const images = resolveDeploymentImages(profile, release, runner);
|
|
438
|
+
const state = clusterState(profile, runner);
|
|
439
|
+
const base = {
|
|
440
|
+
profile: profileName,
|
|
441
|
+
release,
|
|
442
|
+
profileDigest: (0, deploy_profile_1.deploymentProfileDigest)(profile),
|
|
443
|
+
currentRevision: current.revision,
|
|
444
|
+
currentStatus: current.status,
|
|
445
|
+
...state,
|
|
446
|
+
targetRevisionManifestDigest: null,
|
|
447
|
+
target: {
|
|
448
|
+
context: profile.target.context,
|
|
449
|
+
clusterFingerprint,
|
|
450
|
+
namespace: profile.target.namespace,
|
|
451
|
+
helmRelease: profile.target.release,
|
|
452
|
+
},
|
|
453
|
+
images,
|
|
454
|
+
actions: [
|
|
455
|
+
{
|
|
456
|
+
order: 1,
|
|
457
|
+
kind: "dependencies",
|
|
458
|
+
description: "Reconcile in-cluster dependencies and wait for readiness.",
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
order: 2,
|
|
462
|
+
kind: "migration",
|
|
463
|
+
description: `Run database migrations with ${images.api}.`,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
order: 3,
|
|
467
|
+
kind: "application",
|
|
468
|
+
description: `Roll out API, web${profile.workloads.worker ? ", and worker" : ""} workloads with zero unavailable replicas.`,
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
order: 4,
|
|
472
|
+
kind: "verification",
|
|
473
|
+
description: `Verify ${profile.verification.baseUrl}.`,
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
warnings: [
|
|
477
|
+
"Application rollback does not reverse database migrations.",
|
|
478
|
+
"PersistentVolumeClaims and Kubernetes Secrets are never deleted by apply or rollback.",
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
const temporaryRoot = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-deploy-plan-"));
|
|
482
|
+
try {
|
|
483
|
+
const runtime = (0, deploy_render_1.renderDeployment)(temporaryRoot, profileName, profile, release, images, state.rolloutStateDigest);
|
|
484
|
+
return { ...base, planHash: planDigest(base, runtime) };
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
(0, node_fs_1.rmSync)(temporaryRoot, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function helmFailureFlag(runner) {
|
|
491
|
+
const version = checked(runner, "helm", ["version", "--short"]).trim();
|
|
492
|
+
return version.startsWith("v4.") ? "--rollback-on-failure" : "--atomic";
|
|
493
|
+
}
|
|
494
|
+
function waitForDependencies(profile, runner) {
|
|
495
|
+
const release = profile.target.release;
|
|
496
|
+
const resources = [];
|
|
497
|
+
if (profile.dependencies.postgres.mode === "inCluster") {
|
|
498
|
+
resources.push(["statefulset", `${release}-postgres`]);
|
|
499
|
+
}
|
|
500
|
+
if (profile.dependencies.redis.mode === "inCluster") {
|
|
501
|
+
resources.push(["statefulset", `${release}-redis`]);
|
|
502
|
+
}
|
|
503
|
+
if (profile.dependencies.objectStorage.mode === "inCluster") {
|
|
504
|
+
resources.push(["statefulset", `${release}-minio`]);
|
|
505
|
+
}
|
|
506
|
+
for (const [kind, name] of resources) {
|
|
507
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["rollout", "status", `${kind}/${name}`, "--timeout=5m"]), false);
|
|
508
|
+
}
|
|
509
|
+
if (profile.dependencies.objectStorage.mode === "inCluster") {
|
|
510
|
+
checked(runner, "kubectl", kubectlArgs(profile, [
|
|
511
|
+
"wait",
|
|
512
|
+
"--for=condition=complete",
|
|
513
|
+
"job",
|
|
514
|
+
"-l",
|
|
515
|
+
`app.kubernetes.io/name=${release}-minio-initialize`,
|
|
516
|
+
"--timeout=5m",
|
|
517
|
+
]), false);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function migrationJobName(profile, release) {
|
|
521
|
+
const slug = release.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
522
|
+
return `${profile.target.release}-migrate-${slug}`;
|
|
523
|
+
}
|
|
524
|
+
function deploymentLockName(profile) {
|
|
525
|
+
const suffix = (0, node_crypto_1.createHash)("sha256")
|
|
526
|
+
.update(profile.target.release)
|
|
527
|
+
.digest("hex")
|
|
528
|
+
.slice(0, 8);
|
|
529
|
+
const prefix = profile.target.release.slice(0, 39).replace(/-+$/g, "");
|
|
530
|
+
return `${prefix}-${suffix}-deploy-lock`;
|
|
531
|
+
}
|
|
532
|
+
function acquireDeploymentLock(profile, runner) {
|
|
533
|
+
const name = deploymentLockName(profile);
|
|
534
|
+
const holderIdentity = `podokit:${(0, node_crypto_1.randomUUID)()}`;
|
|
535
|
+
const temporaryRoot = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-deploy-lock-"));
|
|
536
|
+
const manifestPath = (0, node_path_1.join)(temporaryRoot, "lease.json");
|
|
537
|
+
(0, node_fs_1.writeFileSync)(manifestPath, JSON.stringify({
|
|
538
|
+
apiVersion: "coordination.k8s.io/v1",
|
|
539
|
+
kind: "Lease",
|
|
540
|
+
metadata: {
|
|
541
|
+
name,
|
|
542
|
+
labels: { "app.kubernetes.io/managed-by": "podokit" },
|
|
543
|
+
},
|
|
544
|
+
spec: {
|
|
545
|
+
holderIdentity,
|
|
546
|
+
leaseDurationSeconds: 3600,
|
|
547
|
+
acquireTime: new Date().toISOString(),
|
|
548
|
+
renewTime: new Date().toISOString(),
|
|
549
|
+
},
|
|
550
|
+
}));
|
|
551
|
+
try {
|
|
552
|
+
const result = runner("kubectl", kubectlArgs(profile, ["create", "-f", manifestPath]), { capture: true });
|
|
553
|
+
if (result.status !== 0) {
|
|
554
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
555
|
+
if (/\balready\s+exists\b/i.test(detail)) {
|
|
556
|
+
const holder = checked(runner, "kubectl", kubectlArgs(profile, [
|
|
557
|
+
"get",
|
|
558
|
+
"lease",
|
|
559
|
+
name,
|
|
560
|
+
"-o",
|
|
561
|
+
"jsonpath={.spec.holderIdentity}",
|
|
562
|
+
])).trim();
|
|
563
|
+
throw new Error(`Deployment lock ${name} is already held${holder ? ` by ${holder}` : ""}. ` +
|
|
564
|
+
`Inspect the active deployment before deleting a stale Lease.`);
|
|
565
|
+
}
|
|
566
|
+
throw new Error(`Unable to acquire deployment lock ${name}${detail ? `: ${detail}` : "."}`);
|
|
567
|
+
}
|
|
568
|
+
return { name, holderIdentity };
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
(0, node_fs_1.rmSync)(temporaryRoot, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function releaseDeploymentLock(profile, lock, runner) {
|
|
575
|
+
const holder = checked(runner, "kubectl", kubectlArgs(profile, [
|
|
576
|
+
"get",
|
|
577
|
+
"lease",
|
|
578
|
+
lock.name,
|
|
579
|
+
"-o",
|
|
580
|
+
"jsonpath={.spec.holderIdentity}",
|
|
581
|
+
])).trim();
|
|
582
|
+
if (holder !== lock.holderIdentity) {
|
|
583
|
+
throw new Error(`Deployment lock ${lock.name} changed owners and was not released.`);
|
|
584
|
+
}
|
|
585
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["delete", "lease", lock.name, "--wait=true"]), false);
|
|
586
|
+
}
|
|
587
|
+
async function applyDeployment(projectRoot, profileName, release, confirmation, runner = defaultRunner, fetcher = fetch) {
|
|
588
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
589
|
+
const findings = doctorDeployment(projectRoot, profileName, runner);
|
|
590
|
+
const failed = findings.filter((finding) => !finding.ok);
|
|
591
|
+
if (failed.length) {
|
|
592
|
+
throw new Error(`Deployment doctor failed: ${failed.map((finding) => finding.message).join(" ")}`);
|
|
593
|
+
}
|
|
594
|
+
const lock = acquireDeploymentLock(profile, runner);
|
|
595
|
+
try {
|
|
596
|
+
const plan = planDeployment(projectRoot, profileName, release, runner);
|
|
597
|
+
if (confirmation !== plan.planHash) {
|
|
598
|
+
throw new Error(`Deployment plan hash changed. Expected confirmation ${plan.planHash}.`);
|
|
599
|
+
}
|
|
600
|
+
const runtime = (0, deploy_render_1.renderDeployment)(projectRoot, profileName, profile, release, plan.images, plan.rolloutStateDigest);
|
|
601
|
+
const failureFlag = helmFailureFlag(runner);
|
|
602
|
+
const common = ["--wait", "--timeout", "5m", failureFlag];
|
|
603
|
+
if (profile.dependencies.objectStorage.mode === "inCluster") {
|
|
604
|
+
checked(runner, "kubectl", kubectlArgs(profile, [
|
|
605
|
+
"delete",
|
|
606
|
+
"job",
|
|
607
|
+
"-l",
|
|
608
|
+
`app.kubernetes.io/name=${profile.target.release}-minio-initialize`,
|
|
609
|
+
"--ignore-not-found=true",
|
|
610
|
+
"--wait=true",
|
|
611
|
+
]), false);
|
|
612
|
+
}
|
|
613
|
+
checked(runner, "helm", helmArgs(profile, [
|
|
614
|
+
"upgrade",
|
|
615
|
+
"--install",
|
|
616
|
+
`${profile.target.release}-dependencies`,
|
|
617
|
+
runtime.dependencyChart,
|
|
618
|
+
...common,
|
|
619
|
+
]), false);
|
|
620
|
+
waitForDependencies(profile, runner);
|
|
621
|
+
const job = migrationJobName(profile, release);
|
|
622
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["delete", "job", job, "--ignore-not-found=true", "--wait=true"]), false);
|
|
623
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["apply", "-f", runtime.migrationManifest]), false);
|
|
624
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["wait", "--for=condition=complete", `job/${job}`, "--timeout=10m"]), false);
|
|
625
|
+
checked(runner, "helm", helmArgs(profile, [
|
|
626
|
+
"upgrade",
|
|
627
|
+
"--install",
|
|
628
|
+
profile.target.release,
|
|
629
|
+
runtime.applicationChart,
|
|
630
|
+
...common,
|
|
631
|
+
]), false);
|
|
632
|
+
for (const kind of [
|
|
633
|
+
"api",
|
|
634
|
+
"web",
|
|
635
|
+
...(profile.workloads.worker ? ["worker"] : []),
|
|
636
|
+
]) {
|
|
637
|
+
checked(runner, "kubectl", kubectlArgs(profile, [
|
|
638
|
+
"rollout",
|
|
639
|
+
"status",
|
|
640
|
+
`deployment/${profile.target.release}-${kind}`,
|
|
641
|
+
"--timeout=5m",
|
|
642
|
+
]), false);
|
|
643
|
+
}
|
|
644
|
+
const verification = await verifyDeployment(projectRoot, profileName, fetcher);
|
|
645
|
+
if (!verification.ok) {
|
|
646
|
+
const deployed = currentHelmStatus(profile, runner);
|
|
647
|
+
throw new Error(`Deployment verification failed after Helm revision ${deployed.revision ?? "unknown"} was deployed. ` +
|
|
648
|
+
`The new application remains deployed because database migrations cannot be reversed automatically. ` +
|
|
649
|
+
`Prepare a forward fix or explicitly plan a compatible rollback. Failed checks: ${verification.paths
|
|
650
|
+
.filter((entry) => !entry.ok)
|
|
651
|
+
.map((entry) => `${entry.path} (${entry.status ?? entry.error ?? "unknown"})`)
|
|
652
|
+
.join(", ")}`);
|
|
653
|
+
}
|
|
654
|
+
return getDeploymentStatus(projectRoot, profileName, runner);
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
releaseDeploymentLock(profile, lock, runner);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function getDeploymentStatus(projectRoot, profileName, runner = defaultRunner) {
|
|
661
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
662
|
+
assertClusterTarget(profile, runner);
|
|
663
|
+
const helm = currentHelmStatus(profile, runner);
|
|
664
|
+
const workloadNames = [
|
|
665
|
+
`${profile.target.release}-api`,
|
|
666
|
+
`${profile.target.release}-web`,
|
|
667
|
+
...(profile.workloads.worker ? [`${profile.target.release}-worker`] : []),
|
|
668
|
+
];
|
|
669
|
+
const items = workloadNames.flatMap((workloadName) => {
|
|
670
|
+
const result = runner("kubectl", kubectlArgs(profile, [
|
|
671
|
+
"get",
|
|
672
|
+
"deployment",
|
|
673
|
+
workloadName,
|
|
674
|
+
"-o",
|
|
675
|
+
"json",
|
|
676
|
+
]), { capture: true });
|
|
677
|
+
if (result.status === 0) {
|
|
678
|
+
return [parseDeployment(result.stdout, workloadName)];
|
|
679
|
+
}
|
|
680
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
681
|
+
if (/\bnot\s*found\b/i.test(detail))
|
|
682
|
+
return [];
|
|
683
|
+
throw new Error(`Unable to inspect Kubernetes deployment ${workloadName}${detail ? `: ${detail}` : "."}`);
|
|
684
|
+
});
|
|
685
|
+
const deployments = items.map((item) => {
|
|
686
|
+
const name = item.metadata.name;
|
|
687
|
+
const observedRestartCount = restartCount(checked(runner, "kubectl", kubectlArgs(profile, [
|
|
688
|
+
"get",
|
|
689
|
+
"pods",
|
|
690
|
+
"-l",
|
|
691
|
+
`app.kubernetes.io/name=${name}`,
|
|
692
|
+
"-o",
|
|
693
|
+
"jsonpath={range .items[*].status.containerStatuses[*]}{.restartCount}{\"\\n\"}{end}",
|
|
694
|
+
])), name);
|
|
695
|
+
return {
|
|
696
|
+
name,
|
|
697
|
+
readyReplicas: item.status?.readyReplicas ?? 0,
|
|
698
|
+
replicas: item.spec?.replicas ?? 0,
|
|
699
|
+
images: (item.spec?.template?.spec?.containers ?? [])
|
|
700
|
+
.map((container) => container.image)
|
|
701
|
+
.filter((image) => typeof image === "string"),
|
|
702
|
+
restartCount: observedRestartCount,
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
return {
|
|
706
|
+
profile: profileName,
|
|
707
|
+
helmRevision: helm.revision,
|
|
708
|
+
helmStatus: helm.status,
|
|
709
|
+
deployments,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async function verifyDeployment(projectRoot, profileName, fetcher = fetch) {
|
|
713
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
714
|
+
const paths = [];
|
|
715
|
+
for (const check of profile.verification.checks) {
|
|
716
|
+
try {
|
|
717
|
+
const response = await fetcher(new URL(check.path, profile.verification.baseUrl), {
|
|
718
|
+
redirect: "manual",
|
|
719
|
+
signal: AbortSignal.timeout(15_000),
|
|
720
|
+
});
|
|
721
|
+
let ok = response.status === check.expectedStatus;
|
|
722
|
+
if (ok && check.expectedJson) {
|
|
723
|
+
const body = await response.json().catch(() => null);
|
|
724
|
+
ok =
|
|
725
|
+
typeof body === "object" &&
|
|
726
|
+
body !== null &&
|
|
727
|
+
Object.entries(check.expectedJson).every(([key, value]) => body[key] === value);
|
|
728
|
+
}
|
|
729
|
+
paths.push({ path: check.path, status: response.status, ok });
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
paths.push({
|
|
733
|
+
path: check.path,
|
|
734
|
+
status: null,
|
|
735
|
+
ok: false,
|
|
736
|
+
error: error instanceof Error ? error.message : String(error),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
profile: profileName,
|
|
742
|
+
baseUrl: profile.verification.baseUrl,
|
|
743
|
+
ok: paths.every((entry) => entry.ok),
|
|
744
|
+
paths,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function planRollback(projectRoot, profileName, revision, runner = defaultRunner) {
|
|
748
|
+
if (!Number.isInteger(revision) || revision < 1) {
|
|
749
|
+
throw new Error("Rollback revision must be a positive integer.");
|
|
750
|
+
}
|
|
751
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
752
|
+
const clusterFingerprint = assertClusterTarget(profile, runner);
|
|
753
|
+
const current = currentHelmStatus(profile, runner);
|
|
754
|
+
if (current.revision === null)
|
|
755
|
+
throw new Error("Cannot roll back an application that is not installed.");
|
|
756
|
+
const state = clusterState(profile, runner);
|
|
757
|
+
const targetManifest = checked(runner, "helm", helmArgs(profile, [
|
|
758
|
+
"get",
|
|
759
|
+
"manifest",
|
|
760
|
+
profile.target.release,
|
|
761
|
+
"--revision",
|
|
762
|
+
String(revision),
|
|
763
|
+
]));
|
|
764
|
+
const apiImage = workloadImageFromManifest(targetManifest, `${profile.target.release}-api`);
|
|
765
|
+
const webImage = workloadImageFromManifest(targetManifest, `${profile.target.release}-web`);
|
|
766
|
+
if (!apiImage || !webImage) {
|
|
767
|
+
throw new Error(`Helm revision ${revision} does not contain the expected API and web workloads.`);
|
|
768
|
+
}
|
|
769
|
+
const targetRevisionManifestDigest = `sha256:${(0, node_crypto_1.createHash)("sha256")
|
|
770
|
+
.update(targetManifest)
|
|
771
|
+
.digest("hex")}`;
|
|
772
|
+
const release = `revision-${revision}`;
|
|
773
|
+
const base = {
|
|
774
|
+
profile: profileName,
|
|
775
|
+
release,
|
|
776
|
+
profileDigest: (0, deploy_profile_1.deploymentProfileDigest)(profile),
|
|
777
|
+
currentRevision: current.revision,
|
|
778
|
+
currentStatus: current.status,
|
|
779
|
+
...state,
|
|
780
|
+
targetRevisionManifestDigest,
|
|
781
|
+
target: {
|
|
782
|
+
context: profile.target.context,
|
|
783
|
+
clusterFingerprint,
|
|
784
|
+
namespace: profile.target.namespace,
|
|
785
|
+
helmRelease: profile.target.release,
|
|
786
|
+
},
|
|
787
|
+
images: {
|
|
788
|
+
api: apiImage,
|
|
789
|
+
web: webImage,
|
|
790
|
+
postgres: null,
|
|
791
|
+
redis: null,
|
|
792
|
+
objectStorage: null,
|
|
793
|
+
objectStorageClient: null,
|
|
794
|
+
},
|
|
795
|
+
actions: [
|
|
796
|
+
{
|
|
797
|
+
order: 1,
|
|
798
|
+
kind: "rollback",
|
|
799
|
+
description: `Roll back application release ${profile.target.release} to revision ${revision}.`,
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
order: 2,
|
|
803
|
+
kind: "verification",
|
|
804
|
+
description: `Verify ${profile.verification.baseUrl}.`,
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
warnings: [
|
|
808
|
+
"Rollback does not reverse database migrations.",
|
|
809
|
+
"Dependency releases, PersistentVolumeClaims, and Secrets are not changed.",
|
|
810
|
+
],
|
|
811
|
+
};
|
|
812
|
+
const planHash = (0, node_crypto_1.createHash)("sha256")
|
|
813
|
+
.update(JSON.stringify(base))
|
|
814
|
+
.update(targetManifest)
|
|
815
|
+
.digest("hex");
|
|
816
|
+
return { ...base, planHash };
|
|
817
|
+
}
|
|
818
|
+
async function rollbackDeployment(projectRoot, profileName, revision, confirmation, runner = defaultRunner, fetcher = fetch) {
|
|
819
|
+
const profile = (0, deploy_profile_1.loadDeploymentProfile)(projectRoot, profileName);
|
|
820
|
+
const lock = acquireDeploymentLock(profile, runner);
|
|
821
|
+
try {
|
|
822
|
+
const plan = planRollback(projectRoot, profileName, revision, runner);
|
|
823
|
+
if (confirmation !== plan.planHash) {
|
|
824
|
+
throw new Error(`Rollback plan hash changed. Expected confirmation ${plan.planHash}.`);
|
|
825
|
+
}
|
|
826
|
+
checked(runner, "helm", helmArgs(profile, [
|
|
827
|
+
"rollback",
|
|
828
|
+
profile.target.release,
|
|
829
|
+
String(revision),
|
|
830
|
+
"--wait",
|
|
831
|
+
"--timeout",
|
|
832
|
+
"5m",
|
|
833
|
+
]), false);
|
|
834
|
+
for (const kind of [
|
|
835
|
+
"api",
|
|
836
|
+
"web",
|
|
837
|
+
...(profile.workloads.worker ? ["worker"] : []),
|
|
838
|
+
]) {
|
|
839
|
+
const deployment = `deployment/${profile.target.release}-${kind}`;
|
|
840
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["rollout", "restart", deployment]), false);
|
|
841
|
+
checked(runner, "kubectl", kubectlArgs(profile, ["rollout", "status", deployment, "--timeout=5m"]), false);
|
|
842
|
+
}
|
|
843
|
+
const verification = await verifyDeployment(projectRoot, profileName, fetcher);
|
|
844
|
+
if (!verification.ok)
|
|
845
|
+
throw new Error("Rollback completed, but public verification failed.");
|
|
846
|
+
return getDeploymentStatus(projectRoot, profileName, runner);
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
releaseDeploymentLock(profile, lock, runner);
|
|
850
|
+
}
|
|
851
|
+
}
|